Skip to content
cdkd

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's BodyS3Location, or its BasePath / FailOnWarnings while the definition comes from BodyS3Location, 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 the cc-api-fallback integ. cdkd reads the definition object as the deploy identity, in the bucket's own region. It reads a pinned Version as that version, and follows a 301 redirect once to the region S3 names. A template declaring both Body and BodyS3Location is refused. Then it reimports the text through ReimportApi, as it does for an inline Body, and re-sends a declared Description. A refused read names the failure by its class and the next step: an AccessDenied names the s3:GetObject grant (plus s3:GetObjectVersion for a pinned version and kms:Decrypt for an SSE-KMS object), and AWS's own text goes to --verbose only. 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 OpenAPI Body now 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 the cc-api-fallback integ. Such an API is routed through Cloud Control, which applies an update to the model it reads back. That model carries Name, ProtocolType and four more keys that API Gateway refuses beside Body, and no Cloud Control patch can leave them out: removing the create-only ProtocolType is itself refused. So cdkd now sends a change to Body, BasePath or FailOnWarnings through ReimportApi, as the import path does, and sends any other changed property through Cloud Control without the import keys. A Tags or Description change on such an API goes through Cloud Control with the import keys left out, including the write-only Body every Cloud Control update re-adds. A declared Description is re-sent after the reimport, since a definition holding info.description overwrites it. A change to a BodyS3Location definition, or to BasePath / FailOnWarnings on an API defined by one, is refused with a message naming the two ways out (inline Body, or a replacement), since ReimportApi takes the definition's text only; CloudFormation accepts that change.
  • A malformed template, --env-vars file, 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's JSON.parse error text into a thrown message, a Caused by: line or a warning, and that text quotes about 30 characters of the body, which can be a plaintext secret. Each now reads invalid 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 a SyntaxError carrying that text; the others attach none. A YAML template's error names the yaml library's error code, line and column (or cdkd's own !GetAtt refusal) instead of a reason and code frame that quoted the source, and a YAML warning such as an unknown tag is a warn line in that form instead of a code frame on stderr. cdkd state refresh-observed prints only SyntaxError for a readback that failed to parse. Node's full text, ASCII-sanitized, is logged at --verbose only. src/provisioning/cloud-control-provider.ts's readCurrentState is not changed here.
  • A corrupt state.json or 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: ... and Rollback journal for '<S>' is not valid JSON: ... carried Node's JSON.parse error text, which quotes about 30 characters of the body. Before cdkd 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, whose Caused by: line repeated it. Both messages now read invalid JSON[ at code-unit position N]; <bytes> byte(s) read, the form the exports index already used, and neither the state refusal nor cdkd gc's corrupt-state abort (which printed the same window on its Caused by: line) attaches the parse error as its cause. Node's full text, ASCII-sanitized, is logged at --verbose only.
  • cdkd deploy --concurrency defaults to 20 instead of 10, and CDKD_PHASE_TIMINGS=1 counts 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 new tests/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 10 to keep the previous limit. The phase report now carries a retry 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 destroy refuses a stack deployed with terminationProtection: true (issue #5238) -- src/types/state.ts (terminationProtection, carried by stackInstanceIdCarriedFrom), 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), and cdkd state destroy refuses such a stack with STACK_TERMINATION_PROTECTION (exit 2) unless --remove-protection is given. cdkd destroy also 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 as NotFound in 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 like PageNotFound) 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 matched does not exist, not found or NotFound anywhere in the error message. So a live resource's state record was dropped when DeleteResource failed with TypeNotFoundException, when a FAILED delete's handler message named a missing dependency ("subnet ... not found"), or when the logical id itself spelled NotFound. Now only the handler's ErrorCode: NotFound, DeleteResource's ResourceNotFoundException, and InvalidVolume.NotFound on 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 *NotFound about 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's EntityNotFound, NoSuch* or *DoesNotExist (the disable step's referenced policy, function or key group is gone), API Gateway's NotFoundException from UpdateAccount, or Scheduler's ResourceNotFoundException from the redacted-GroupName ListSchedules search 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's RequestTokenNotFoundException was already safe: it surfaces as an abandoned wait, which every such check refuses.
  • cdkd destroy re-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: a cdkd refactor that 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 with STATE_CHANGED, deleting nothing, unless the record still holds exactly the resources it held before.
  • CDKD_SDK_CALL_LOG now 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 them AWS::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 filtered ListTypes walks instead of one DescribeType call 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-recreation as for the other data-bearing types.
  • A cdkd refactor interrupted 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.ts and src/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 page docs/refactor.md, new unit files, and new phases in the refactor-move integ. 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 every finally, 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 deploy and cdkd destroy of either stack refuse and cdkd diff exits 3; the next cdkd refactor of either stack finishes it (--dry-run only 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=1 reports how long resources waited for a --concurrency slot (issue #5209) -- src/deployment/dag-executor.ts, src/deployment/deploy-engine/execute.ts, docs/troubleshooting-slow-deploys.md, the new tests/integration/wide-independent fixture, and unit tests in tests/unit/deployment/dag-executor.test.ts. Each stack's report gains a ready, waiting for a --concurrency slot line: 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 diff and the deploy approval prompt name a removal cdkd refactor would 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 the refactor-command and refactor-move integ 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=destructive list, --json (movedTo), and the cdkd deploy --require-approval prompt (renames within the deployed stack). Rows the diff already holds; no AWS call.
  • cdkd refactor moves 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 new refactor-move integ. 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, and cdkd refactor refused 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 refactor keeps 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 new refactor-command integ. Before, renaming or moving a construct within a stack made cdkd deploy delete 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 diff cdkd deploy computes; look-alikes it cannot tell apart are reported as ambiguous and named with --override-file (the cdk refactor mapping format, --revert applies it in reverse). A pair whose properties also changed is refused, as by cdk refactor. Only cdkd state is rewritten, under the stack lock and conditional on the state it planned from; no AWS resource is touched. --dry-run prints the plan. Moving a resource between stacks is not supported yet (#5202).
  • CDKD_PHASE_TIMINGS=1 prints where a cdkd deploy spent 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 its stack-instance.json sibling 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.ts and src/cli/interactive-select.ts (new), src/cli/commands/state.ts (printStackResources / printStackShow, the bodies of state 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 as cdkd state resources, cdkd state resources --long and cdkd 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-agentcore or cdkd 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 with x instanceof Error ? x.message : String(x), and String() throws for a null-prototype object, so the handler itself threw: on invoke and invoke-agentcore a failed container-log drain skipped the response write and stopped the cleanup before docker rm -f, and a failed docker rm -f skipped the remaining tmpdir and credentials removals and the stdio flush; on run-task a failed cleanupEcsRun skipped the credentials-file dispose that covers it, leaving the file behind. Each now goes through describeAwsFailure(x).detail, byte-identical whenever the value converts and a placeholder only when it cannot; the same rendering in those commands' --event / --env-vars read and parse errors and in cdkd local start-api's WebSocket error log 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 with String(), which throws for a null-prototype object. So a TypeError: Cannot convert object to primitive value replaced the ServiceAttributes failure a Cloud Map create rethrows when its cleanup DeleteService rejected with such a value, replaced the Lambda create failure, and, when Cognito refused the settings echo with such a name, 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's name, losing the read's fail-open answer and the restore warn. That --remove-protection warn 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 whose name cannot be converted, since its summary interpolated the name; it now renders a placeholder there (unchanged for a string name). ACM's in-use classifier (src/provisioning/providers/acm-certificate-provider.ts) is converted as defence in depth: delete() always hands it an Error today. Each now goes through describeAwsFailure(x).detail or safeStringify(x), byte-identical for every value String() 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.ts and src/types/rollback-journal.ts, with a new unit file and the replication-check suite. When the exports index failed to parse, the Exports 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 with x 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 unrelated TypeError. 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 a TypeError: Cannot convert object to primitive value took the place of the handler's own error when an SDK call rejected with such a value: a rejected macro-expansion CreateChangeSet, a failed S3 HeadObject asset check (which also threw on a null rejection), and a provider failure that wrapMaskedAwsError wraps. 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-timeout per-type parse failure are converted as defence in depth: nothing known today throws them a non-Error. Each now goes through describeAwsFailure(x).detail or safeStringify(x), byte-identical for every value String() 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 in docs/import-resource-types.md / docs/state-management-physical-ids.md / docs/state-schema-internals.md, and a new VPN gateway arm in the ec2-route-targets integ. Before, an attachment naming VpnGatewayId routed through Cloud Control. It now sends AttachVpnGateway and waits for the attachment to be attached, so a route through the gateway can be created next. The record is <vgwId>|<vpcId>. Destroy sends DetachVpnGateway and waits for detached, because AWS refuses to delete the VPN gateway or the VPC until then. Drift reads the attachment with DescribeVpnGateways. --migrate-from-cloudformation and cdkd import now adopt a CloudFormation VGW|<vpcId> attachment. A template naming both InternetGatewayId and VpnGatewayId, or neither, is refused before any AWS call: CloudFormation's documentation allows exactly one of the two. Changing InternetGatewayId / VpnGatewayId now swaps the attached gateway in place, as CloudFormation does (issue #5216). Before, it was refused, and --replace could 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.ts and tests/unit/provisioning/cloud-control-statusmessage-identity-2319.test.ts. The handler's StatusMessage, and the text of a rejected CreateResource / 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 ...), and deployments/{runId}.jsonl outlives 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 as account reads <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, and deployments/{runId}.jsonl outlives the run. The message now carries the error class (for example 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, 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's cause, 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 a NotFound other 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}.jsonl recorded 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 example AccessDenied. Re-run with --verbose for AWS's own message.), next to the awsErrorCode and requestId it 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's cause. A cdkd-authored or transport error keeps its text. Two refusals get the same treatment: the --strict-getatt output refusal, and the refusal of an AWS CDK-generated cross-account Fn::GetStackOutput whose lookup role was denied. The lookup role's ARN stays in that message. cdkd events also 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 a rollback-command integ check. The thrown message quoted AWS's raw text, which names the caller on an access denial, and deployments/{runId}.jsonl outlives the run. The message now carries the error class (for example 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. This includes a failure after CreateStream returned (a denied status read while waiting for ACTIVE) and an EventBridge rule whose targets step failed after PutRule, which still name the stream or rule left behind for cdkd 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's cause, 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, and deployments/{runId}.jsonl outlives the run. The message now carries the error class (for example AccessDenied. 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 --replace delete-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 the cause.
  • 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.ts unit cases, and remove-protection / created-before-failure-revert integ checks. The create, update and delete failure messages quoted AWS's raw text, which names the caller on an access denial, and deployments/{runId}.jsonl outlives the run. The message now carries the error class (for example 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, masked. The AWS error is still the wrap's cause, so an IAM grant that is still propagating, or a throttle, is retried as before, a --remove-protection load 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 example KMSNotFoundException) 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.ts unit cases. The create, update and delete failure messages quoted AWS's raw text, which names the caller on an access denial, and deployments/{runId}.jsonl outlives the run. The message now carries the error class (for example 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, 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's cause, 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 example KMSNotFoundException) 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, and deployments/{runId}.jsonl outlives 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 its ErrorCode when AWS reports one), the ECS service --full-wait steady-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 the cause, 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::GetAZs lookup 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, and deployments/{runId}.jsonl outlives 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, the Underlying collision: line of a refused reverse replacement, and the reason recorded for a new resource that could not be deleted. The N node(s) failed summary at the end of a deploy names an AWS-authored failure (an asset upload's AccessDenied) the same way; its Failed: line above still prints AWS's text. A cdkd-authored or transport error keeps its text, and the AWS error stays as the cause, 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.ts unit cases. The SSM failure message now carries the error class (for example 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; the AWS error is still the wrap's cause, 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 removed StorageCapacity, StorageType or NetworkType now 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 OpenZFS SINGLE_AZ_1 file system kept NetworkType DUAL, StorageCapacity and StorageType SSD. A Lustre PERSISTENT_1 file system kept StorageType HDD and StorageCapacity. 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, and NetworkType on OpenZFS only), and never for an INTELLIGENT_TIERING file system. A warning that names an unmeasured property, such as a Windows or ONTAP removal or a Lustre NetworkType, keeps its old wording. Tests: tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts; the fsx-openzfs integ gains a phase that removes all three.
  • AWS::FSx::FileSystem: the warning for a removed OpenZFS ThroughputCapacity, ReadCacheConfiguration or EndpointIpv6AddressRange now 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 OpenZFS MULTI_AZ_1 file system. Every update completed in place, with the same file system, the value unchanged and no FSx administrative action: ThroughputCapacity on SSD and on INTELLIGENT_TIERING, ReadCacheConfiguration on INTELLIGENT_TIERING, and EndpointIpv6AddressRange on a dual-stack SSD file system in the same update that removed NetworkType. 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, for ThroughputCapacity and ReadCacheConfiguration only as the deploy's single removal with NetworkType not declared DUAL (CloudFormation refused every update while it was). Any other shape keeps the line that claims nothing about CloudFormation: a SINGLE_AZ_* file system, a deploy that also changes StorageType, an EndpointIpv6AddressRange removal while NetworkType stays declared, and, on MULTI_AZ_1, a NetworkType removal while EndpointIpv6AddressRange stays declared (CloudFormation refused both). Tests: tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts; the fsx-openzfs integ gains a phase that removes ThroughputCapacity on its SINGLE_AZ_1 file 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 available is retried once it reads available again (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 its VpcSecurityGroupIds) and a member instance together could fail with Database instance is not in available state.: the cluster change moves the member to modifying a moment after the cluster update returns, and the instance's ModifyDBInstance was sent before that showed up. CloudFormation lets the cluster change settle first, so the same template succeeded there. Now only that refusal (InvalidDBInstanceStateFault / InvalidDBClusterStateFault with the not-available wording) is retried. cdkd waits out any transitional status and resends, for up to 30 minutes or what --resource-timeout leaves, whichever is less. A terminal status (failed, incompatible-*, ...) fails at once and names the status. An update AWS accepts makes no call beyond the one ModifyDBInstance. 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 drift compares AWS::Budgets::Budget again, 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 the budgets integ. 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 with DescribeBudget, DescribeNotificationsForBudget, one DescribeSubscribersForNotification per notification and ListTagsForResource, 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 absent ThresholdType reads as AWS's default PERCENTAGE on both sides. Budget.BudgetLimit is not compared on a budget declaring AutoAdjustData or PlannedBudgetLimits, 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 its observedProperties off the critical path; --no-capture-observed-state turns that off.
  • cdkd local start-api serves 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 declare new HttpApi(this, 'Api') backed by a Handler Lambda, 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-vars key when more than one served stack declares that Lambda. The error names the stacks, and for --env-vars it 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 stackName is 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 the shared-stackname-multi-region integ fixture. Before, cdkd deploy '**' (and diff, list, synth, publish-assets, scrub) kept one stack per physical name and silently deployed only the first region's. cdkd destroy UsWest/Api destroyed 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::ImportValue now prefers a producer in the consumer's own region. A cross-region import with no same-region producer resolves as before. cdkd list without a pattern no longer drops a same-named stack. cdkd synth --verbose writes <name>.<region>.template.json for a shared name. scrub --all reads each shared-name stack in its own region. cdkd local run-task no 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, and deployments/{runId}.jsonl outlives the run. The message now carries the error class (for example 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. This includes a failure after CreateDBCluster / CreateDBInstance returned (a denied status read while waiting for available), which still names the cluster or instance left behind for cdkd rollback --revert-failed. A cdkd-authored or transport error keeps its text, masked. The AWS error is still the wrap's cause, 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-protection no 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. After DeletionProtection was turned off, AWS could keep reading the resource available, then move it to modifying and 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 reads available again, within the same 15-minute cap and resource timeout. Both before the delete and between retries, cdkd waits through any status that is neither available nor terminal (modifying, configuring-enhanced-monitoring, backing-up, ...). A terminal status (stopped, failed, inaccessible-*, ...) is not waited on, and neither is storage-optimization or storage-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 and available before 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 (on cdkd destroy, cdkd state destroy and 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 in src/provisioning/cloud-control-provider.ts and src/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 line Could 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 --verbose output 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 --verbose and 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 drift no longer reports false drift on an unchanged AWS::Budgets::Budget, or a permanent read failure on an AWS::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 default ThresholdType: PERCENTAGE, so NotificationsWithSubscribers showed 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-api refuse 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 declare new Stack(stage, 'Api', { stackName: 'SharedApiStack' }). Before this change, cdkd local invoke SharedApiStack:Fn (or SharedApiStack/...) silently ran the first Stage's function, and cdkd local start-api --stack SharedApiStack served only the first Stage. Now invoke and run-task refuse with matched 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-assets and scrub given a CDK path such as Prod/Api now 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-agentcore now resolve a stack inside a cdk.Stage by its display path, and a bad --max-tasks / --restart-policy value 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 (as cdkd deploy does), and two stacks sharing a physical name are both kept, so a command needing one stack reports the ambiguity instead of taking the first. Their Available stacks lists show Physical (Display/Path) when the two differ. A value such as --max-tasks 84 used to be rethrown as a cdkd error; it now prints error: 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 \uXXXX where cdkd showed a space, a plain identifier loses the quotes it used to carry (Lambda 'Fn' -> Lambda Fn), and an argv element on the --verbose docker 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 its ServiceName (or its construct id when it sets none), logical id or Stack:LogicalId, and --strict-overrides refuses 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>. Under start-alb, whose services are keyed Stack:LogicalId, a key such as WebService=./Dockerfile matched 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=src and --image-target <svc>=stage forms. 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-overrides used 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 only ALL_PROXY=socks5://...), its AWS clients take the SDK's default handler and the one-time Unsupported proxy scheme warning is printed when the client is built. Requests still go direct, as before.
  • cdkd destroy --remove-protection no 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 turned DeletionProtection off and sent the delete at once. When the delete took a final snapshot (DeletionPolicy: Snapshot, or no DeletionPolicy), AWS could refuse it because the resource was still modifying from 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 and available before 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 not available within 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 an AWS::DocDB::DBCluster, AWS::RDS::DBCluster or AWS::RDS::DBInstance that declares MasterUserPassword sent the password again, even when only another property changed. AWS treats a supplied password as a credential reset, so a DocDB cluster entered resetting-master-credentials after, for example, a PreferredMaintenanceWindow-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, ManageMasterUserPassword and the MasterUserSecret KMS key are likewise sent only with a master-credential change. A password fed by a NoEcho parameter 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, and deployments/{runId}.jsonl outlives the run. The message now carries the error class (for example 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. The same holds for the failed cleanup a create appends when it cannot deregister the targets it registered; the manual aws rds deregister-db-proxy-targets command is still printed. A cdkd-authored or transport error keeps its text. The AWS error is still the wrap's cause, 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, and deployments/{runId}.jsonl outlives 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 the cause, 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. A DeleteUser that 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, and deployments/{runId}.jsonl outlives the run. The message now carries the error class (for example 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 covers the create, update and delete failures of the four types, the Fn::GetAtt lookup failure of a schedule, lifecycle policy or pipe, a pipe's *_FAILED state reason, and the RDS RemoveFromGlobalCluster / DescribeGlobalClusters failures 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 its cause still 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, and deployments/{runId}.jsonl outlives the run. The message now carries the error class (for example 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. This includes a failure after CreateDBCluster / CreateDBInstance returned (a denied status read while waiting for available), which still names the cluster or instance left behind for cdkd rollback --revert-failed. A cdkd-authored or transport error keeps its text, masked. The AWS error is still the wrap's cause, 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, and deployments/{runId}.jsonl outlives 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 the partial reason a renamed IAM role or managed policy records when the old one could not be deleted, the budget Arn attribute 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 the cause, 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 AvailabilityZoneImpairmentPolicy from an AWS::AutoScaling::AutoScalingGroup now 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 and cdkd diff reported no changes afterwards. A real CloudFormation A/B showed that CloudFormation sends ZonalShiftEnabled: false with the previous ImpairedZoneHealthCheckBehavior, after which AWS reports no policy; cdkd now sends the same. The same A/B confirmed that CloudFormation keeps a removed DesiredCapacity and ServiceLinkedRoleARN, and sends only the newly declared member on a LaunchTemplate / MixedInstancesPolicy or VPCZoneIdentifier / AvailabilityZones switch, which is what cdkd already did.
  • Removing Monitoring from an AWS::ECS::Service now 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 to UpdateService when a template dropped Monitoring, 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 removed VolumeConfigurations or VpcLatticeConfigurations is cleared, and a removed AvailabilityZoneRebalancing keeps its live value.
  • AWS::RDS::DBProxy: removing Auth from 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 an Auth removal, and AWS refuses it while DefaultAuthScheme is NONE or unset. cdkd used to keep Auth and warn; it now stops the deploy with an error that says to declare Auth again. A rollback replay still keeps it and warns; drift --revert keeps it without a warning, as before. CloudFormation keeps a removed DBProxy RequireTLS / IdleClientTimeout / DebugLogging / VpcSecurityGroupIds, an FSx FileSystemTypeVersion, a Lustre DataCompressionType / AutoImportPolicy / WeeklyMaintenanceStartTime, and an OpenZFS AutomaticBackupRetentionDays / 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 (FSx StorageType, 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; the fsx-lustre and rds-aurora integ 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 of UpdateReplacePolicy: 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's Integration or MethodResponses now reaches AWS (issue #5140) -- src/provisioning/providers/apigateway-provider.ts. An update patched only the Method's own fields: a changed integration (for example its Uri, after the Lambda function behind a LambdaIntegration was replaced) was recorded in state but never sent, so the API kept invoking the old target, or answered 500 once 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: PutIntegration replaces 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::Permission no 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 --replace it 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 whose FunctionNames 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-approval prompt 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. Answering n used to print Failed: stack:<name>: ... and then Error: 1 node(s) failed: ... in red. The deploy now prints only Deployment 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's AbortError exits 1 too). A decline that happens alongside a genuine failure keeps the red failure report.
  • A Cloud Map Service whose Description comes from a secret dynamic reference or a NoEcho parameter now keeps a removed HealthCheckConfig when the Description did 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 a Description as 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 sent UpdateService (a Tags- or ServiceAttributes-only change included), clearing a removed HealthCheckConfig and, with a removed DnsConfig, 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 a NoEcho value (for a Service, cdkd reads it back to check); the Service sends UpdateService only for a Description / HealthCheckConfig change among them. A rollback and cdkd drift --revert compare 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, and deployments/{runId}.jsonl outlives the run. The message now carries the error class (for example 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 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 the cause, 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, and deployments/{runId}.jsonl outlives 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 the cause, 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 VpcSecurityGroupIds from 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, and ModifyDBCluster then kept the removed groups attached while the deploy reported success. A real CloudFormation A/B showed CloudFormation attaches the VPC's default security group in place of the removed ones on AWS::RDS::DBCluster and AWS::Neptune::DBCluster. cdkd now finds that group (the cluster's DB subnet group gives the VPC; ec2:DescribeSecurityGroups gives the group named default) and sends it. If the group cannot be found, the update fails before any change and says to declare VpcSecurityGroupIds. The lookup runs only on this removal, and the deploy credentials need ec2:DescribeSecurityGroups for 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 groups active and each member instance is available, 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-wait skips the wait, as it does on create. An empty list is still never sent, and cdkd drift --revert does not reset. AWS::DocDB::DBCluster is 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, and tests/integration/stream-delete-redeploy/ (Phase 1 and Phase 3c). Before this change, FileExtension, CustomTimeZone and DynamicPartitioningConfiguration were 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: UpdateDestination refuses a call that omits that block. Creates also dropped SecretsManagerConfiguration on HttpEndpoint, Redshift and Splunk destinations, RetryOptions and the S3 backup settings on Redshift, BufferingHints on Splunk, and VpcConfiguration / DocumentIdOptions on the OpenSearch and Elasticsearch destinations. cdkd drift now reads FileExtension / CustomTimeZone too. 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. VpcConfiguration is 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 (UpdateDestination merges), 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, Configuration and CrawlerSecurityConfiguration. They were removed all at once, first with no other change and then beside a Targets change. CloudFormation kept every live value, because its UpdateCrawler carries only Name, Role and Targets. 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. The glue-update-hardening integ 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 with ResourceInUseException: DeleteTable returns while the table is still DELETING, 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) until DescribeTable reports the table gone. cdkd destroy and 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's Table already exists while 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 so cdkd rollback finishes 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 with AWS 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 with AWS_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's NamespaceNotFound lag (issue #5113) -- src/provisioning/providers/servicediscovery-provider.ts. Cloud Map can answer CreateService with NamespaceNotFound just 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; the servicediscovery integ 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 survived cdkd destroy and 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 destroy deletes 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 and cdkd destroy as 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 rollback and the automatic rollback reverted the resources that moved onto it (an IAM managed policy's Roles, 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 rollback keeps the new resource in supersededResources, 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 when cdkd rollback is 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 Lambda Invoke, the SNS Publish or the response placeholder PutObject was denied, the thrown message quoted AWS's raw text, which names the caller, and deployments/{runId}.jsonl outlives 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 its StateReasonCode / LastUpdateStatusReasonCode in 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 the cause.
  • AWS::ApiGatewayV2::*: the remaining properties removed from the template now follow CloudFormation (issue #1160) -- src/provisioning/providers/apigatewayv2-provider.ts. A removed Authorizer IdentitySource is now cleared (UpdateAuthorizer gets []); 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 Integration IntegrationSubtype without declaring an IntegrationUri is now refused before any call, because CloudFormation fails that update. Before, cdkd reported success and kept the subtype. Declared beside an IntegrationUri, 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 (Api RouteSelectionExpression; Stage Description / DefaultRouteSettings; Integration IntegrationUri / IntegrationMethod / PayloadFormatVersion / TimeoutInMillis and the other #609 fields; Route Target / ModelSelectionExpression / RouteResponseSelectionExpression; Authorizer AuthorizerUri / EnableSimpleResponses / JwtConfiguration) is kept, as before. Also fixed: an update to an EventBridge-PutEvents service integration that did not change its RequestParameters (a Description-only change, for example) failed with requires 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; integ apigatewayv2-update-removal phases 2, 2b, 2c.
  • AWS::EC2::Instance property removals now match CloudFormation: a removed Monitoring is turned off, and a removed DisableApiTermination is refused (issue #1160) -- Two rounds of real CloudFormation A/B decide each case. When Monitoring is removed, CloudFormation turns detailed monitoring off. cdkd used to send nothing, so monitoring stayed on; it now calls UnmonitorInstances. A removed MetadataOptions or CreditSpecification keeps its live value, in both. When DisableApiTermination is removed (from true or false), CloudFormation fails the whole update and keeps the value, but it applies an explicit true / 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 to false to 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 --revert is 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 removed AlpnPolicy, MutualAuthentication, Certificates, SslPolicy, Port or Protocol now 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 a DefaultActions change) showed CloudFormation keeps all six live values and no update fails. cdkd already kept them, because ModifyListener keeps 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; the alb integ fixture gains a phase that drops the listener's Port and Protocol.
  • AWS::EMR::Cluster / AWS::EMR::InstanceFleetConfig: the warning for a removed StepConcurrencyLevel, ResizeSpecifications or InstanceTypeConfigs now 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, and StepConcurrencyLevel / ResizeSpecifications also beside a companion change) showed CloudFormation keeps all three live values and no update fails, although its documented StepConcurrencyLevel default is 1. cdkd already kept them, because ModifyCluster / ModifyInstanceFleet keep 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; the emr-cluster and emr-instance-fleets integ fixtures' Phase 2b needles now require the new wording.
  • AWS::KMS::Key: a removed RotationPeriodInDays or KeyPolicy is reset, as CloudFormation does (issue #1160) -- src/provisioning/providers/kms-provider.ts. A CloudFormation A/B showed that a Key update resets a removed RotationPeriodInDays to 365 and a removed KeyPolicy to 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 with EnableKeyRotation whenever 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 --revert still 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 denying kms:DisableKey deploys. 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 (declare Enabled: true, or enable it in AWS) or drop the rotation change. A string 'false' for Enabled or EnableKeyRotation (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 removed Description, Enabled and EnableKeyRotation already matched CloudFormation. Tests: tests/unit/provisioning/kms-provider-removal-1160.test.ts; the kms-encryption integ fixture gains a verify.sh with a removal phase.
  • AWS::Lambda::EventSourceMapping: a BatchSize removed from the template is reset to the source's default, and a removed LoggingConfig or ProvisionedPollerConfig on a Kafka mapping is cleared, as CloudFormation does (issue #1160) -- src/provisioning/providers/lambda-eventsource-provider.ts. UpdateEventSourceMapping keeps a field it is not sent, so cdkd used to leave all three at their old AWS value while the deploy succeeded and cdkd diff reported 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: BatchSize 10 for SQS and 100 for DynamoDB and self-managed Kafka, and {} for a self-managed Kafka mapping's LoggingConfig / 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's ProvisionedPollerConfig and a Kafka mapping's MaximumBatchingWindowInSeconds, and cdkd still does too. Tests: tests/unit/provisioning/lambda-eventsource-provider.test.ts; the sqs-esm-max-concurrency integ fixture now drops batchSize in its update phase, and lambda-esm-self-managed-kafka gains a Phase 2c dropping BatchSize, the poller config and a LoggingConfig.
  • AWS::Route53::HostedZone: removing VPCs from 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 when VPCs is 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, and cdkd drift --revert does not warn. Dropping some VPCs while keeping others still disassociates the dropped ones. Tests: tests/unit/provisioning/route53-hostedzone-vpcs-removal-1160.test.ts; the ec2-vpc integ fixture gains a private hosted zone whose VPCs its removal phase drops.
  • AWS::ServiceDiscovery::Service: a Description, DnsConfig or HealthCheckConfig removed from the template now follows CloudFormation (issue #1160) -- src/provisioning/providers/servicediscovery-provider.ts. UpdateService replaces its body, and cdkd sent it whenever any of the three was declared, so a removal on its own cleared a removed Description / HealthCheckConfig and failed the deploy for a removed DnsConfig (Service dns config cannot be null). CloudFormation A/Bs on the issue showed it sends the update only when a declared Description or HealthCheckConfig changed, 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 removed DnsConfig is refused by AWS, as for CloudFormation). Deliberate divergence: CloudFormation drops a DnsConfig.DnsRecords-only change (a TTL); cdkd applies it and keeps any removed Description / HealthCheckConfig from the live service. Known divergence: an unchanged Description recorded as any dynamic reference ({{resolve:...}}) or a NoEcho *** counts as changed, so a HealthCheckConfig removed beside it is cleared (#5112). The type is audited into removalHandledInUpdate. Tests: tests/unit/provisioning/providers/servicediscovery-provider.test.ts; the servicediscovery integ fixture gains three removal phases.

Recently Implemented (2026-10-19):

  • cdkd rollback now 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 names cdkd deploy --replace of 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-named AWS::CodeCommit::Repository, AWS::RDS::DBProxy or AWS::RDS::DBProxyEndpoint used to fail, because their schemas require the name; it now gets the name the SDK provider gives. An un-named AWS::S3Express::DirectoryBucket on Cloud Control used to be sent a name without the required --<zone id>--x-s3 suffix; it now gets the suffix from LocationName, and a create whose LocationName is 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 an AWS::Events::EventBus name 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 AlreadyExists is 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 as HostedZoneAlreadyExists. 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's HostedZoneAlreadyExists reports a reused caller reference, not a taken name, and S3's BucketAlreadyExists reports 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 the cause chain, 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.json in 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 example RuleAction from allow to deny, or its CidrBlock, Protocol, PortRange or Icmp) now updates the entry in place with one ReplaceNetworkAclEntry call, 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 the ec2-vpc integ's Phase 2. The refusal's suggested cdkd deploy --replace deleted 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, and cdkd drift --revert of a console-side rule edit, now replace the rule back in place too. A change to NetworkAclId, RuleNumber or Egress is createOnly and still replaces the entry.
  • Changing an AWS::EC2::Route's target (for example GatewayId to TransitGatewayId) now updates the route in place with one ReplaceRoute call, 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 the ec2-route-targets integ's Phase 2. The old DeleteRoute + CreateRoute left 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). After cdkd destroy, a redeploy gets a new id and new names, so it no longer collides with, or takes back, a resource a DeletionPolicy: Retain (or cdkd state orphan) left behind; the first deploy warns once and names cdkd import for 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. An update() that re-derives a name keeps the recorded one of an un-named resource rather than replace it; cdkd drift --revert of an un-named EventBridge rule now updates the rule its ARN names, where it used to send PutRule under 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 names cdkd import, and --replace deletes 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 under Retain is no longer taken back: the deploy creates a new resource and names cdkd 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, or cdkd import creating the record) records stackInstanceId, 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 to stack-instance.json beside state.json before any resource is created, so a re-run after a crash reuses it; the object is deleted once state.json carries 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-run none. cdkd never prints the id (cdkd state show --json shows <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.json in plaintext when a provider records its own effectiveProperties (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 a Tags[].Value or a Description. 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; integ tests/integration/secrets-dynamic-ref (a Kinesis stream tag).
  • A 1-3 character secret embedded in a literal beside a public ssm reference no longer reaches state.json in 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 as postgres://{{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's effectiveProperties echoing 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, and deployments/{runId}.jsonl outlives 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 the cause, 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 an AccessDeniedException (or S3's AccessDenied when a *S3Location object cannot be fetched), and deployments/{runId}.jsonl outlives 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 the cause, 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 an AccessDeniedException, and deployments/{runId}.jsonl outlives 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 the cause, 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, and deployments/{runId}.jsonl outlives 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 the cause, 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 for cdkd 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), and deployments/{runId}.jsonl outlives 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's HostedZoneName lookup failure. A cdkd-authored or transport error keeps its text. The AWS error stays as the cause, 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 an AccessDeniedException, and deployments/{runId}.jsonl outlives 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 the cause, 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 CloudWatchRoleArn from an AWS::ApiGateway::Account now 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. UpdateAccount is a patch call, and cdkd sent it with no operations for the removed property, so the old role ARN stayed set. cdkd now sends replace /cloudwatchRoleArn '' for a removed CloudWatchRoleArn, 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 EnableDnsHostnames or EnableDnsSupport from an AWS::EC2::VPC now 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. ModifyVpcAttribute changes 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 --revert writes 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-declared Policy is now applied to the bus (issue #5044) -- src/provisioning/providers/eventbridge-bus-provider.ts. Before, the provider declared Policy handled but never sent it, so the deploy succeeded and the bus had no resource policy: a cross-account PutEvents the policy granted was refused. The bus is now created and then given the whole document through PutPermission (an object or a JSON string). An update with a changed Policy replaces the whole bus policy, statements a separate AWS::Events::EventBusPolicy added included, as CloudFormation does; an update that leaves Policy unchanged sends nothing for it, and removing Policy from 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 --verbose instead 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, integ tests/integration/eventbus-policy.
  • A failure that cannot be converted to text no longer turns a best-effort handler in cdkd deploy or cdkd diff into a hard failure, nor replaces a provider's rejection with a TypeError (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 with x instanceof Error ? x.message : String(x) (or its x.message || x.name variant), and String() 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-getatt failed 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 a TypeError before the not-found check and the per-resource failure report could read it; and cdkd diff's partial parameter binding and condition evaluation fallbacks failed the diff. Each now goes through describeAwsFailure(x).detail (safeStringify(x) on the output handler's String arm), 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 with x instanceof Error ? x.message : String(x), and String() throws for a null-prototype object, so the handler itself threw: a failed CloudFormation ListExports / DescribeStacks fallback stopped Fn::ImportValue / Fn::GetStackOutput with a TypeError instead 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 failed Ipv6CidrBlocks / launch-template version read lost its [] / $Latest fallback; an Fn::Sub variable 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 answering failed; and Fn::GetAZs, the Fn::GetAtt refusal for a constructed attribute whose live read failed (DescribeSecurityGroups, DescribeInstances, GetNamespace or the provider read, via describeFailureObserved) and the lookup-role denial message replaced the failure they describe with the converter's TypeError. Each now goes through describeAwsFailure(x).detail (or safeStringify(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 with x instanceof Error ? x.message : String(x) (or a bare String(x)), and String() 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 with Cannot convert object to primitive value instead of a placeholder naming the failure. The per-op failure line (rollbackFailureText) and cdkd rollback's journal-backend error lines (backendErrorText) rendered such a value with the same String() 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-events threw instead of warning that the event history was not purged; and a synthesis failure in cdkd destroy threw instead of falling back to the state's stack list. Each now goes through describeAwsFailure(x).detail (or safeStringify(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 an UnauthorizedOperation, and deployments/{runId}.jsonl outlives 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 the cause, 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's ListenerAttributes now 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 for routing.http.response.server.enabled, to an empty value for other keys) and now sends nothing for it, as it already does for LoadBalancerAttributes and TargetGroupAttributes. 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, LogConfig or DeadLetterConfig from an AWS::Events::EventBus now 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, LogConfig at OFF / NONE, and no dead-letter queue. UpdateEventBus keeps 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 --revert writes 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 with x instanceof Error ? x.message : String(x) (or a bare String(x)), and String() throws for a null-prototype object, so the handler itself threw: an unreadable cdk.json / cdk.context.json failed the command instead of warning; a failed DescribeType skipped cdkd 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 in local start-api's shutdown skipped the cleanup after it, the profile-credentials tmpdir removal among them, and a failed --warm pre-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 a TypeError before 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 through describeAwsFailure(x).detail (or safeStringify(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 TypeError when 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, and deployments/{runId}.jsonl outlives 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 the cause, 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::LoadBalancer or TargetGroup now does what a CloudFormation update does (issue #1160) -- A real CloudFormation A/B decided each case. A removed load balancer IpAddressType now resets to ipv4; before, it stayed dualstack. A removed HealthCheckPort on a GENEVE target group now resets to traffic-port like every other protocol; before, it kept its value. A key removed from LoadBalancerAttributes or TargetGroupAttributes now 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 kept access_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. ListenerAttributes still resets. drift --revert writes no IpAddressType reset for a value its recorded baseline lacks. Changed: src/provisioning/providers/elbv2-provider.ts, tests/unit/provisioning/elbv2-tg-lb-removal-1160.test.ts and 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). On AWS::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::Alarm is not in STICKY_CC_MIGRATION_EXEMPT, so an existing SDK-provisioned resource of that type whose template gains one moves to Cloud Control and pins provisionedBy: 'cc-api' ONE-WAY. The snapshot marks none of them create-only, so createOnlyDrops and 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; Stage StageVariables (per key), ClientCertificateId; Integration Description, RequestParameters (per key); Route AuthorizationType, AuthorizerId, OperationName, RequestModels and RequestParameters (per key); Authorizer AuthorizerCredentialsArn, AuthorizerPayloadFormatVersion, AuthorizerResultTtlInSeconds, IdentityValidationExpression. Api CorsConfiguration, Stage AutoDeploy / AccessLogSettings / RouteSettings and Route AuthorizationScopes are reset by CloudFormation too and keep their resets. Changed: src/provisioning/providers/apigatewayv2-provider.ts, docs/provider-rules.md, the apigatewayv2-update-removal integ fixture and the ApiGatewayV2 unit tests. Route ApiKeyRequired was not measured and is still reset, and cdkd drift --revert still removes a console-added map key.
  • Removing nine AWS::AutoScaling::AutoScalingGroup properties from a template no longer resets them, matching CloudFormation (issue #5004) -- A real CloudFormation A/B showed CloudFormation keeps the live value of a removed HealthCheckType, HealthCheckGracePeriod, Cooldown, TerminationPolicies, NewInstancesProtectedFromScaleIn, CapacityRebalance, DesiredCapacityType, DefaultInstanceWarmup or DeletionProtection, while cdkd reset each to a default (for example HealthCheckType back to EC2). cdkd now sends nothing for them, so the live value stays. InstanceMaintenancePolicy, MaxInstanceLifetime, CapacityReservationSpecification and AvailabilityZoneDistribution still 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 Role Description and Lambda Url Cors are 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 the fifo-sqs-event-source, iam-role-prefixed-name-update, sns-sqs-event, secrets-dynamic-ref and cloudfront-function-url integ 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, CrossRegionCopyTargets and Exclusions keep 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 DeletionProtection or BackupRetentionPeriod from an AWS::DocDB::DBCluster, DeletionProtection, IamAuthEnabled or BackupRetentionPeriod from an AWS::Neptune::DBCluster, or AutoMinorVersionUpgrade from an AWS::Neptune::DBInstance no longer resets the live value: cdkd now leaves it untouched, as CloudFormation does (measured in a real CloudFormation A/B). A removed DeletionProtection previously switched protection off. Changed docdb-provider.ts, neptune-provider.ts. Issue #5004.
  • Removing PlatformVersion, HealthCheckGracePeriodSeconds, PropagateTags, EnableECSManagedTags, EnableExecuteCommand, CapacityProviderStrategy, PlacementConstraints or PlacementStrategies from an AWS::ECS::Service template no longer resets them (issue #5004) -- src/provisioning/providers/ecs-provider.ts (removalDefaults is now empty; updateService sends nothing for these members), tests/unit/provisioning/ecs-provider.test.ts, and the ecs-service-update-props integ 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. ServiceConnectConfiguration removal still resets to disabled, which the same A/B confirmed CloudFormation does; VolumeConfigurations and VpcLatticeConfigurations were not measured and are unchanged.
  • Removing SnapshotRetentionLimit, AutoMinorVersionUpgrade or NotificationTopicArn from an AWS::ElastiCache::CacheCluster no 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. LogDeliveryConfigurations keeps its reset (CloudFormation disables delivery).
  • Removing DeletionProtection, EnableIAMDatabaseAuthentication (RDS DBCluster / DBInstance) or MonitoringInterval (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 (DeletionProtection to false, EnableIAMDatabaseAuthentication to false, MonitoringInterval to 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, and cdkd destroy --remove-protection still lifts it. DBCluster BackupRetentionPeriod (CloudFormation resets it to 1) is unchanged, and DBCluster MonitoringInterval is 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::TargetGroup with TargetType: lambda no longer fails intermittently with "Lambda function ... is not in Active state" (issue #5002). The function's create returns while it is still Pending, and ELBv2 rejects registering a Pending function, so the first deploy sometimes rolled back and a re-run passed. The target group now waits for each Lambda target to reach State=Active immediately before RegisterTargets (create and update); no other target group and no Lambda create pays for it. A function that ends Failed or 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-arn now 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-local bumped to 0.153.1. --from-state already resolved the role's account (#3230); what changes is the case with NO state source (a local run-task image Fn::Sub or 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-stack keeps 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-arn are unchanged.
  • Fn::GetAtt on an AWS::Lambda::WebFunctionEndpoint's whole RegionalEndpoints map (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 logged Unknown attribute ... returning physical ID; 0.301.1 (#4978) restored only the scalar members. The provider now also records RegionalEndpoints as 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 the Fn::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-1pw12 and pw12-str4ddle-4973 inside us-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 fresh NoEcho value or the Fn::Base64 encoding 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, which cdkd scrub does 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 of state.json in the versioned state bucket keep the partial value: cdkd scrub --purge-history deletes 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::GetAtt of 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::GetAtt into an AWS::Lambda::WebFunctionEndpoint's RegionalEndpoints.<region>.<Member> resolves again instead of returning the endpoint ARN (issue #4978). Since the dedicated SDK provider landed in 0.300.0, Fn::GetAtt on RegionalEndpoints.us-west-2.DomainName (and the other members) logged Unknown attribute ... returning physical ID and served the endpoint ARN, so a CloudFront origin built from it failed with origin name cannot contain a colon. The provider now records RegionalEndpoints.<region>.{DomainName,AuthType,State,StateReason,UpdateStatus,UpdateStatusReason} from GetWebFunctionEndpointResponse.regionalEndpoints; a member AWS did not assign (a MultiRegion endpoint reports domainName: null per region) is not recorded. A record written by 0.300.x gains the keys on the next deploy that reaches the Fn::GetAtt, through the stale-attribute heal. Source: src/provisioning/providers/lambda-web-function-endpoint-provider.ts.
  • cdkd diff --recursive --fail no 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 an Fn::Select / Fn::Split / Fn::If over an Fn::Sub (or Fn::Join) naming AWS::AccountId (issue #4975). The #4962 fix left AWS::AccountId unbound 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 as AWS::AccountId itself, so every pass binds the same value whether or not the account was already known. That can add one STS GetCallerIdentity call per process, made only by a nested child that inherits parent secrets and has a resource naming AWS::AccountId beside an Fn::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 --fail no longer reports a change on an unchanged nested stack in which a child's Outputs read 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 an Fn::Join / Fn::Sub naming 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 reads AWS::Region, AWS::Partition, AWS::URLSuffix and (where the child already looks it up, #4975) AWS::AccountId from 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 an AWS::AccountId the 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::Stream and the EC2 VPC, Subnet, InternetGateway, EIP, NatGateway, RouteTable, SecurityGroup, Instance and NetworkAcl now 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, where cdk deploy succeeds. The tags now go in CreateRole.Tags, PutParameter.Tags, CreateStream.Tags and each EC2 create's TagSpecifications, 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 when CreateTags failed, so a role without ec2:CreateTags could still create: such a role's tagged create is re-sent once untagged and then tagged with CreateTags as before. Updates still tag with the separate calls (SSM refuses Tags with Overwrite). 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::Bucket now sends its tags on CreateBucket itself, 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 with PutBucketTagging, so an organization SCP requiring a tag on s3:CreateBucket (aws:RequestTag/...) denied the create, where cdk deploy succeeds. The template's tags now go in CreateBucketConfiguration.Tags, and a create that carried them no longer repeats them with PutBucketTagging; an adopted bucket still gets the PutBucketTagging. Tags on CreateBucket need s3:TagResource: a role that lacks it but holds s3:PutBucketTagging gets the old path back automatically (one untagged retry of each bucket's create, then PutBucketTagging); when that untagged create is denied too, the error names the missing s3:TagResource (and fails at once when the untagged denial is an explicit deny); an endpoint that refuses the Tags member itself takes the same untagged path. Separately, an AccessDenied that says the call was refused with 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 holding s3:TagResource makes no extra AWS call, and its tagged bucket's create makes one call fewer.
  • cdkd diff --recursive --fail no 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 an Fn::Select / Fn::Split over an Fn::Sub (or Fn::Join) naming AWS::Region, AWS::Partition or AWS::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, as cdkd deploy, cdkd scrub and the diff side all resolve them; no AWS call is added. AWS::AccountId is bound since #4975; AWS::StackName, AWS::StackId and AWS::NotificationARNs are 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 diff and cdkd destroy now mask two more resource names taken from a secret (issue #3869). (1) With --verbose, a parent resource reading a nested stack's output with Fn::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 the Resolved Fn::GetAtt ... resolved to line 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 a NoEcho parameter 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's Deleting old / was replaced lines (shown without --verbose) and on cdkd destroy's delete lines. Each record's own value at a position the template fills from a NoEcho parameter 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 under src/provisioning/providers/ that rethrow read the caught value with a bare String(err), which throws for a null-prototype object or a hostile toString and replaced the real AWS failure (and the text persisted to deployments/{runId}.jsonl). They now read it through describeAwsFailure(err).detail or safeStringify(err), byte-identical for every value that converted before. Enforced by an exact, shrink-only ratchet in tests/unit/provisioning/provider-swallowing-catch-out-throw.test.ts.
  • The four engine cdkd local start-service / start-alb / start-cloudfront / start-agentcore commands no longer hand the --role-arn role 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-arn and 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-stack as the role and made the --assume-role hop as the role. They now resolve all of it as the caller (pass --profile to choose the identity), via cdk-local 0.152.0's workloadIdentity: 'caller'. A literal layer ARN on local invoke / start-api is likewise fetched as the caller now, and those four cdkd-owned commands record their assume with cdk-local through applyRoleArnForLocal. The startup warning the engine commands printed is gone. Under --role-arn the --assume-role / --ecr-role-arn / --layer-role-arn hops are signed by your own identity, so the target role's trust policy must trust you, and local 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-state state 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-cloudfront accepts --from-state again, start-alb's --from-state reaches Lambda target groups, and cdkd local invoke <image-fn> | jq gets 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-cloudfront no 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-arn with no --profile, the startup warning now also names the engine's origin, KeyValueStore and ${AWS::AccountId} reads under either state flag. start-alb drops its boot warning that --from-state misses a Lambda target, since it no longer does. cdk-local's own log lines now go through cdkd's logger, so local invoke keeps Building container image ... off stdout, --verbose reaches cdk-local's debug lines, and its warnings print in cdkd's format (no WARN: prefix) unless CDKL_LOG_LEVEL / CDKL_LOG_STREAM is set. Bundled-engine fixes picked up by the bump: the start-cloudfront --help text says Lambda@Edge runs locally, a --profile name is rendered safely in cdk-local's messages, and a --verbose build line masks --build-arg and cache-credential values.

Recently Implemented (2026-10-15):

  • cdkd diff --recursive --fail no 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 an Fn::If whose branches resolve to that secret spelled two ways, an Fn::If whose other branch is an attribute, a resource or a pseudo parameter, or an array of which only some Fn::If elements resolve to AWS::NoValue or 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 each Fn::If took and which array elements dropped out as AWS::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. An Fn::Sub naming a pseudo parameter inside Fn::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 denying iam:DeletePolicy but 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 next cdkd deploy saw no difference to repair. Such a delete now marks its error, and the replacement journals oldResourceMaySurvive: unconfirmed: the rollback refuses that operation with the journal kept and offers cdkd 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. The cdkd rollback plan 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 the iam-access-key, appsync, cognito, ec2, emr-cluster, emr-instance-fleet-config, emr-instance-group-config and lambda-web-function-revision providers. 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. An AWS::IAM::AccessKey with Status: Inactive, deployed by a principal denied iam:UpdateAccessKey and iam: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 on LimitExceeded with 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, so cdkd rollback --revert-failed and 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 destroy and cdkd state destroy now 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, new tests/unit/cli/destroy-nested-cross-region-scope-4941.test.ts and integ fixture destroy-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's DeletionPolicy: Snapshot final 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 X run 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, as cdkd destroy --region does. A stack in the CLI's region is otherwise unchanged.
  • cdkd orphan now reads live Fn::GetAtt values in the orphaned stack's region, not the CLI's --region / AWS_REGION (issue #4937) -- src/cli/commands/orphan.ts, new tests/unit/cli/orphan-stack-aws-scope-4937.test.ts, and a new phase 4b in tests/integration/orphan-getatt-takeover/. cdkd orphan picked the stack's region from its synthesized env.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 inside runWithStackAwsClients for the stack's region, as deploy does. The state backend and lock stay on the CLI's clients, and process.env.AWS_REGION is left unchanged.
  • cdkd rollback now runs its providers in the rolled-back stack's region, not the CLI's ambient AWS_REGION (issue #4932) -- src/cli/commands/rollback.ts, new tests/unit/cli/rollback-stack-aws-scope-4932.test.ts. A standalone cdkd rollback resolved the stack's region from its journal or state key and pinned the global getAwsClients() set to it, but never entered the stack AWS scope that deploy uses. Providers that build their own SDK client read the region from ambientRegion(), which outside a scope is process.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 inside runWithStackAwsClients for 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_REGION is left unchanged.
  • cdkd diff --recursive no 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 reading Fn::Select [1, Fn::Split [':', {Ref: Conn}]] over a parent row Conn: postgres://{{resolve:...}}:{{resolve:...}}@host showed a change on every run, and --fail exited 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::Join or Fn::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 (a Ref to its own parameter, an Fn::FindInMap value) 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::NetworkConnector now deploys through an SDK provider; deleting a connector takes about 3 s instead of about 13 s -- new src/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 is ACTIVE (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 through UpdateNetworkConnector and waits for the new version, a tags-only change through Lambda's tagging API. Removing OperatorRole from 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 drift and cdkd import (by ARN or name) are supported. Issue #4928.
  • AWS::Lambda::WebFunctionRevision and AWS::Lambda::WebFunctionEndpoint now deploy through SDK providers, about 5 s faster per Web Function code change -- new src/provisioning/providers/lambda-web-function-revision-provider.ts, lambda-web-function-endpoint-provider.ts and lambda-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's RevisionWeights to 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 support cdkd drift and cdkd import. AWS::Lambda::WebFunction stays on Cloud Control: the public CreateWebFunction refuses a function without a first revision and endpoint, which only CloudFormation's handler may create. Issue #4926.
  • cdkd drift --revert can now revert drift on an AWS::GuardDuty::Detector (issue #4921) -- src/provisioning/guardduty-detector-patch.ts, src/provisioning/cloud-control-provider.ts, unit cases and a new phase in the guardduty-detector-update integ fixture. The revert hands Cloud Control the AWS snapshot with the drifted keys overlaid. That snapshot carried both DataSources and the full Features list on every read measured, which UpdateDetector refuses together, so reverting a drifted scalar or feature failed with EKS_RUNTIME_MONITORING and RUNTIME_MONITORING cannot be provided in the same request. Now an undrifted Features or DataSources (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 drifted Features list is narrowed to the entries that differ from the live entry of the same Name, so a toggled feature is reverted alone, and a drifted DataSources beside 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, which UpdateDetector cannot undo: the revert fails with an error saying so, rather than reporting a revert that changed nothing, and warns that cdkd drift --accept records every drifted property of the stack, so other drift on the detector should be reverted first. A desired entry without a string Name, 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 --fail no 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 through Fn::If, through Fn::Sub inside Fn::Select, or inside an array holding an Fn::If or AWS::NoValue element (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 while cdkd diff --recursive rendered its literal. The child leaf re-read (#4731) now evaluates Fn::Sub and Fn::If: an Fn::If is 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 which AWS::NoValue elements 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 diff no longer report an unchanged resource whose Fn::Split delimiter occurs inside a secret {{resolve:...}} reference's text, such as : (issue #4914). A nested child splitting a parameter the parent built as postgres://{{resolve:...}}:{{resolve:...}}@host on : showed an UPDATE row for that resource in every child redeploy's plan, and on a create-only property a replacement that --require-approval=destructive asked about each time (refused without a terminal); the engine's post-resolution check then skipped the call, so AWS was never touched. cdkd diff reported a change on every run, and --fail exited 1, for a stack splitting a literal, Fn::Join or Fn::Sub holding 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::Join or Fn::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 (a Ref to a parameter, an Fn::FindInMap value) is still split as text, as the deploy does. cdkd diff --recursive still 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 --fail no longer reports a change on an unchanged nested stack whose child Outputs read 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) while cdkd diff --recursive rendered the literal. Now the outputs redaction carries the outputs pass's parameter reads and the parent's per-parameter associations, so an output whose Value is a bare Ref, an Fn::Select / Fn::Split re-read, or an Fn::Join / Fn::Sub over the parameters (with at most one other non-literal part) persists each parameter's own spelling, and the collision check reads each output Value as 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 (under Fn::If, through Fn::Sub inside Fn::Select, inside a list-valued output, in an Fn::Join / Fn::Sub with two or more other non-literal parts, or in an Export.Name) keeps the old behaviour. cdkd scrub writes 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 inside update() (an AWS::CertificateManager::Certificate whose ValidationMethod changes; 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 with oldResourceMaySurvive, from the provider's own verdict on the old resource: kept when 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, unconfirmed otherwise, 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 and kept (the replacement is deleted, as CloudFormation would), otherwise refused with the journal kept and cdkd rollback --orphan offered (the refusal now also names a delete that only partly ran). Providers also changed: acm-certificate-provider.ts, apigateway-provider.ts, and src/types/resource.ts (oldResourceSurvivor on a partial update). The deliberate UpdateReplacePolicy: Retain survivor 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 a cdkd deploy --no-rollback made an AWS::IAM::AccessKey or an AWS::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 rollback and cdkd destroy kept it the same way. The providers now compare the two: an access key id and a Cloud Map ns- / 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 a cdkd deploy --no-rollback made an AWS::Pipes::Pipe, the pipe never settled (a *_FAILED state, 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 rollback and cdkd destroy kept 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 (from CreatePipe'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 reports DELETING. Such a failure is no longer retried by re-sending CreatePipe (which could only collide with the pipe it left), so its message keeps the aws pipes delete-pipe advice 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-protection on a Cognito User Pool with a DEVELOPER (SES) EmailConfiguration now says plainly when a write reset it to Cognito's default sender (issue #4149) -- src/provisioning/providers/cognito-provider.ts and tests/unit/provisioning/providers/cognito-remove-protection-rmw.test.ts. Measured in us-east-1: an UpdateUserPool that omits a DEVELOPER EmailConfiguration resets it to EmailSendingAccount: COGNITO_DEFAULT, dropping its SES SourceArn; echoed back unchanged it is accepted and kept. The DeletionProtection flip 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 the DEVELOPER sending 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 --destroy or cdkd publish-assets into 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 or Export.Name that cdkd diff could 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 failed bootstrap --destroy instead of warning and continuing; and, with more than one stack selected, one stack's failure in cdkd publish-assets stopped the run instead of being recorded while the remaining stacks were published. Each now goes through describeAwsFailure(x).detail or safeStringify(x), byte-identical for every value String() 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-task or cdkd 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 with x instanceof Error ? x.message : String(x), and String() throws for a null-prototype object, so the handler itself threw: the container pool skipped docker rm after a failed log-stream stop, and a failed in-flight start made dispose() 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 $disconnect were 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 --watch loop 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 through describeAwsFailure(x).detail, or safeStringify(x) beside a kept err.stack arm, 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 --watch reload (src/cli/commands/local-start-api.ts, go-to-k/cdkd#3361). The uncaughtException and unhandledRejection listeners, and the reload's synth catch, rendered the value they received with a bare String(), which throws for a null-prototype object. A throw inside the uncaughtException listener ended the process without the shutdown that removes the warm containers and the profile credentials tmpdir; the unhandledRejection listener reported the converter's TypeError instead of the rejection; and a failed re-synth printed nothing, because the reload chain swallowed the TypeError in place of the "keeping previous version" warning. Each now renders through safeStringify / 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-state fallback of cdkd 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 with x instanceof Error ? x.message : String(x), and String() throws for a null-prototype object, so the handler itself threw: a failed state-bucket resolution or bootstrap-marker read failed cdkd local run-task instead 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 an Fn::ImportValue a later stack could answer was dropped from the env, and failed cdkd local invoke-agentcore when it named a fromS3 code bucket; and a failed stack listing or Fn::GetStackOutput state read dropped its entry with the converter's TypeError as the reason. Each now goes through describeAwsFailure(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 by cdkd destroy and cdkd 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, no journalVersion bump). 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 the cdkd import remedy, 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 and cdkd 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 (assertRegionMatch now runs before any AWS call in these update()s; their delete()s compare the regions only on a NotFound), scripts/check-provider-update-context.ts (OMITS_UPDATE_CONTEXT is 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 (assertRegionMatch now runs before any AWS call in these update()s and delete()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 leave OMITS_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 TypeError when 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, and deployments/{runId}.jsonl outlives 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 the cause, 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 treats MY_TOKEN and my_token as one variable, but both spellings reached the container client's environment, so a secret named my_token reached the container as the host's MY_TOKEN value. 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 (ß against SS) is dropped with a warning instead, so it can never remove the operator's variable. Secrets named ComSpec, SystemRoot, windir, SystemDrive, PSModulePath or PSExecutionPolicyPreference, 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. TEMP and TMP are still forwarded. A new windows-env-precedence CI job runs this on a real Windows host. Linux and macOS behaviour is otherwise unchanged.

Recently Implemented (2026-10-14):

  • An AWS::GuardDuty::Detector can now be updated in place (issue #4910) -- src/provisioning/cloud-control-provider.ts, a new src/provisioning/guardduty-detector-patch.ts, unit cases and a new guardduty-detector-update integ 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 both DataSources and the full Features list on every read measured (us-east-1 and us-west-2), and UpdateDetector refused them together, whichever of the two the template declared: a scalar-only change failed with EKS_RUNTIME_MONITORING and RUNTIME_MONITORING cannot be provided in the same request, and a Features change with both 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 gets add /<key> with the declared value, or remove /<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, and RDS_DATA_RISK, which GuardDuty enables under RDS_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). On AWS::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::HostedZone is not in STICKY_CC_MIGRATION_EXEMPT, so an existing SDK-provisioned resource of that type whose template gains one moves to Cloud Control and pins provisionedBy: 'cc-api' ONE-WAY. The snapshot marks AWS::Route53::HostedZone.DelegationSetId create-only, so it joins createOnlyDrops and 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 (deleteReplacedAfterCreate defers its first failure; new retryDeferredReplacedDeletes / 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, an AWS::Lambda::WebFunctionEndpoint refuses the delete of its AWS::Lambda::WebFunctionRevision with a 409 ConflictException. cdkd warned Failed to delete old resource and 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 warns Failed 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 every AWS::IAM::Policy / AWS::IAM::UserToGroupAddition whose 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 --revert now removes an out-of-band AWS::SSM::Parameter policy instead of reporting a revert that left it in place -- src/provisioning/providers/ssm-parameter-provider.ts. When the template declares Policies and the recorded baseline holds none, a policy added out of band is drift. The revert sent no Policies, and PutParameter keeps a parameter's policies until it is sent new ones or an empty one, so the policy stayed and the next cdkd drift reported 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 no Policies when the template has none, as CloudFormation does. Issue #4884.
  • cdkd destroy resolves 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. An AWS::IAM::Policy or AWS::IAM::UserToGroupAddition whose Roles / Groups / Users names 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, so cdkd destroy and cdkd state destroy now resolve it in that region, as the rollback replay, cdkd drift and cdkd scrub already 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-cloudformation records 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 the UpdateStack that adds the Retain policies. When that update replaced a resource (an auto-named AWS::SSM::Document is a known case), state named the superseded copy, and cdkd destroy left 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 by UpdateReplacePolicy: Retain for 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 import without the flag, then DeleteStack) 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 failed DeleteStack says 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-mapping is 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 before DeleteStack rather 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-cfn now 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 its Arn as arn:...:{{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 deploy and cdkd diff now compare the secret versions a reader recorded with the ones its upstream records, promote it (cdkd diff labels 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::GetStackOutput with Region) stores its Arn as arn:...:{{resolve:...}}; a reader of that Arn added 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 drift and cdkd scrub resolve 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 --revert of an AWS::SSM::Parameter no 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 to PutParameter, 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 no Policies, as an absent template property is. Found by the rollback-cross-region-secret integ of issue #4847.
  • cdkd diff --recursive --fail no 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 through Fn::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 bare Ref inside an array that also holds an Fn::If or AWS::NoValue element, 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 while cdkd diff --recursive rendered its literal. A child leaf whose source holds Fn::Select / Fn::Split (possibly with Fn::Join beneath 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 under Fn::If, through Fn::Sub inside Fn::Select, or inside an array holding an Fn::If or AWS::NoValue element (#4919); a split that cuts a token (#4914); and two reads in the child's Outputs (#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 a cdkd deploy --no-rollback made an AWS::IAM::InstanceProfile or AWS::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 rollback and cdkd destroy kept 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 the InstanceProfileId / PolicyId its CreateInstanceProfile / CreatePolicy returned, 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 its DeletePolicy (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 a cdkd deploy --no-rollback made an AWS::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 rollback and cdkd destroy kept 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 the RoleId its CreateRole returned, so the earlier role is deleted only while its name still reads back under that RoleId. A role deleted by hand and re-created under the name has another RoleId and 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 a cdkd deploy --no-rollback made an AWS::IAM::User or AWS::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 rollback and cdkd destroy kept 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 the UserId / GroupId its CreateUser / CreateGroup returned, 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 a cdkd deploy --no-rollback made an AWS::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 rollback and cdkd destroy kept 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 the creationTime CloudWatch 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, import and the cdkd diff / cdkd deploy attribute 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 whose RoleName is a {{resolve:secretsmanager:...}} reference, the provider's --verbose lines printed its physical id. So did a failed read's warning quoting AWS's error, and a resource reading the name (an instance profile's Roles). Each read now runs under the per-resource printing bag cdkd drift --revert and cdkd destroy bind: the record's own name spellings and those of each secret-named record it holds. cdkd drift's report, --json and 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; --accept still writes the raw value. cdkd orphan and cdkd state orphan --resource mask it in the rewrite, audit and unresolvable report. cdkd import masks a name its template takes from a secret in the read (an id from --resource, --resource-mapping[-inline] or --migrate-from-cloudformation) and the Import plan: listing. Display only. Judged from the records (for import, the template), so a NoEcho-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. Integ secret-derived-immutable-names step 5k runs cdkd orphan --dry-run over 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 (except update.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 with safeMsg, 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-resource Failed 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 (asIsMessage in src/utils/display-safe.ts), so a remedy command it ends in keeps its own last line. Under --strict-getatt, the output failure now reads Failed 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.json drops these files, so a new raw interpolation in them fails audit: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: the Cannot update <id>: resource not found in state error, the Skipping <id>: no actual changes debug line, and the unwritten create-only refusal. The custom-resource ServiceToken refusal (Refusing to deploy <stack>: ...) renders the stack name through displayStackName, and its remedies keep their own line breaks. tests/raw-log-interpolation-baseline.json drops update.ts, so a new raw logger interpolation there fails audit: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, new src/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 failed UpdateReplacePolicy: Snapshot snapshot, 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 with AlreadyExists (named types). The rollback journal now records oldResourceMaySurvive on such an operation (kept when AWS refused the delete outright or it was never sent, unconfirmed otherwise; 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 and kept, it is re-adopted, with a warning to check it with cdkd 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 offers cdkd rollback --orphan. The cdkd rollback plan 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 the UpdateContext and calls assertRegionMatch before any AWS call), src/provisioning/providers/{agentcore-browser,agentcore-code-interpreter,wait-condition-handle}-provider.ts (they take the UpdateContext too, with no guard: their update() sends nothing), scripts/check-provider-update-context.ts (nine names leave OMITS_UPDATE_CONTEXT), and unit cases in each provider's test file. These providers ignored the recorded region on update, so a deploy, rollback or cdkd drift --revert run 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 DLM DefaultPolicy change 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 the UpdateContext and calls assertRegionMatch before any AWS call), scripts/check-provider-update-context.ts (five names leave OMITS_UPDATE_CONTEXT), and unit cases in each provider's test file. These providers ignored the recorded region on update, so a deploy, rollback or cdkd drift --revert run 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 EventBridge PutRule creates 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 destroy now 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.ts and src/provisioning/providers/nested-stack-provider.ts, new tests/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 earlier state.json body (every recorded property and attribute) stayed readable through GetObject with a VersionId. 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.json key's history, but only when no other region of the stack has state.json versions, 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 needs s3:ListBucketVersions and s3:DeleteObjectVersion. Without them the destroy still succeeds, and a warning names both grants.
  • Removing AlpnPolicy, MutualAuthentication, Certificates, SslPolicy, Port or Protocol from an AWS::ElasticLoadBalancingV2::Listener now warns that the live value stays -- src/provisioning/providers/elbv2-provider.ts. ModifyListener keeps any field it is not sent, so a removed one stayed live while the deploy reported success and the next cdkd diff showed 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. SslPolicy and Certificates are not named on an HTTPS to HTTP or TLS to TCP change, where AWS drops them itself. drift --revert prints 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 from src/cli/commands/), src/utils/docker-cmd.ts. The ECR-pull refusals (STS GetCallerIdentity, --ecr-role-arn's AssumeRole -- printed twice, the second time as Caused by: -- and ECR GetAuthorizationToken), the ECS secret refusals (Secrets Manager, SSM), the --assume-task-role / local start-api --assume-role / --profile credential refusals, the --from-state state-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 a credential_process failure already arrives as the generic Could 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 their cause through a redacted copy (redactedAwsCause); No cdkd state bucket found ... Run 'cdkd bootstrap' is now a StateError and still prints in full; a withheld --profile failure names the aws sso login remedy.
  • cdkd local start-agentcore / start-cloudfront / start-service / start-alb synthesize 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 a synthesizer, go-to-k/cdk-local#787). These four commands synthesized the CDK app through cdk-local's @aws-cdk/toolkit-lib synthesizer, the only cdkd commands that did. They now use cdkd's synthesizer, as local invoke / start-api / run-task do, on boot and on every --watch reload. The stacks these commands pick a target from now include a cdk.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-bucket for a template too large to expand inline, and context lookups and cdk.json / cdk.context.json context resolve as in every other cdkd command. A macro-bearing stack is expanded on every --watch reload too, which needs credentials and a region. --verbose shows the synthesis debug output again, and --role-arn is the identity of the synthesis's own AWS calls (macro change set, upload) under --profile as 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 to RawMessageDelivery, FilterPolicy, FilterPolicyScope, RedrivePolicy, DeliveryPolicy, ReplayPolicy or SubscriptionRoleArn on an AWS::SNS::Subscription made cdkd deploy unsubscribe 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 with SetSubscriptionAttributes, keeping the ARN, as CloudFormation does. An attribute removed from the template is reset to its default (RawMessageDelivery off, no FilterPolicy or RedrivePolicy, FilterPolicyScope back to MessageAttributes). A change to the topic, protocol or endpoint, the removal of DeliveryPolicy, ReplayPolicy or SubscriptionRoleArn, and a new FilterPolicy together with a change of its FilterPolicyScope (declared or dropped) still replace the subscription.
  • A resource named from a NoEcho parameter 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 a NoEcho template parameter (an SNS topic with TopicName: !Ref Name), state.json stored 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 using Fn::GetAtt Topic.TopicArn) failed with Cannot resolve Topic.TopicArn ... redaction mask and 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 the NoEcho value 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 gains referenceProvenance and the stack outputReferenceProvenance: per token, the region that resolved it and the secret's own version ids (a Secrets Manager VersionId, an SSM Version), 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 import through Cloud Control keeps ProposedSegmentChange on NetworkManager attachments (PR #4840) -- src/provisioning/getatt-attributes.generated.ts, regenerated from @aws-cdk/aws-service-spec 0.1.219. On AWS::NetworkManager::ConnectAttachment, SiteToSiteVpnAttachment, TransitGatewayRouteTableAttachment and VpcAttachment, ProposedSegmentChange is both a writable property and a Fn::GetAtt attribute. An import recorded it as ***, so a later deploy reading Fn::GetAtt <attachment>.ProposedSegmentChange from the imported resource was refused. It is now kept, like the other writable attributes (#3735).
  • --remove-protection keeps a user pool's custom ACR level names (PR #4839) -- src/provisioning/providers/cognito-provider.ts. The AWS SDK bump adds AcrConfiguration to UpdateUserPool, and the API reference says a level a write does not name gets its default name, so the DeletionProtection flip 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 default urn:cognito:loa:<n> are not sent, so a pool with no custom name -- every Lite pool -- gets no AcrConfiguration in 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 renamed sdin-q-1 -> sdin-q-2), a reader that also spells the same {{resolve:...}} reference (a tag beside Value: Fn::GetAtt Queue.Arn) recorded the new name against that reference, so the new ARN redacted to the same arn:...:{{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 deploy no 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 NoEcho value is now stored masked, as a boolean one is (issue #4831) -- src/deployment/intrinsic-resolver/parameter-secrets.ts. For a parameter declared NoEcho: "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 in state.json. The value was recorded only as a log-only needle, unlike NoEcho: 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 another NoEcho source 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 in noEchoLeaves. The --verbose log lines that print a resolved value holding it now mask the WHOLE value, as for NoEcho: true (Resolved Fn::Sub: *** rather than Resolved Fn::Sub: str-***). Tests: tests/unit/deployment/noecho-string-value-arm-4831.test.ts. Integ: noecho-parameter-masking Phase 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 SQS Arn around a queue named from a secret) is resolved before a reader's Fn::GetAtt / Ref is served, but only when the resource's template spelled the token literally and read no other stack. A name built by Fn::Join / Fn::Sub (CDK's secretValueFromJson on a secret in the stack), a literal name beside an imported dead-letter queue, a name read by Fn::ImportValue, or one copied by Fn::GetAtt from another resource was still served as stored: an AWS::SSM::Parameter reader refused it and the deploy failed. cdkd deploy now also proves the region from the stack's cross-stack reads (its state's imports / 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 a cdkd deploy --no-rollback made an AWS::Lambda::Function, failed configuring it (RecursiveLoop, ReservedConcurrentExecutions or RuntimeManagementConfig) 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 rollback and cdkd destroy kept 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 the LastModified its CreateFunction returned, 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 another LastModified and is kept, with a warning; so is one a later code or configuration update changed. A successful PutRuntimeManagementConfig moves LastModified too, 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.
  • broadening no 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=broadening and cdkd deploy --require-approval=broadening read 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-diff is 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 resources no longer print U+2028 / U+2029 in a record key, logical id or value, and cdkd diff masks 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 with stripControlChars alone, 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 through stripForDisplay, 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. The cdkd diff headers, 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 a NoEcho value holding a space, carried as correct<U+2028>horse, prints as *** rather than correct horse, including in --json's blocking. 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 STS GetCallerIdentity (for ${AWS::AccountId}) or --assume-role's AssumeRole failed interpolated the SDK error's raw message, which for a credential_process failure carries the failed command line. Each of the eight sites now renders through cdk-local's describeAwsFailureForWarn: 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 at debug through cdkd's own logger. A CdkdError (cdkd's own AssumeRole(<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-state state-bucket warns and the src/local/** relays: #4857.
  • A bare cdkd command uses your AWS profile's region, and keeps finding stacks the old us-east-1 default 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 and AWS::Region by us-east-1 while their pre-flight clients followed the profile. They now resolve --region, AWS_REGION, AWS_DEFAULT_REGION, the profile's region, then us-east-1, as cdkd bootstrap / gc already did. Compatibility: when the region came from AWS_DEFAULT_REGION or the profile, an env-agnostic stack that has state only in us-east-1 stays there, and cdkd prints Continuing 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, the us-east-1 one while it exists) and a region-less legacy state record (S3StateBackend.stateExistsOwnedBy), and the default state bucket also reads cdkd-state-<account>-us-east-1, preferred while it holds state; cdkd state migrate with no --region picks its source the same way. A stack with state in both regions stays in us-east-1, and a us-east-1 legacy bucket that answers 403 never wins over a working one. A region taken from AWS_DEFAULT_REGION, which the AWS SDK does not read, is copied into AWS_REGION for 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.ts and 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. DeleteContext had no maskSecrets, 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 printed aws s3 rm s3://*** --recursive once 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), and cdkd destroy or 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 NoEcho no 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 parameter NoEcho, the deploy compares what it would store (***) with that plaintext. The migration witness reconciles the two only when the record carries no noEchoLeaves at all. A record another NoEcho source had already marked read the leaf as moved: cdkd diff and cdkd deploy reported a create-only change and replaced the resource (a fixed-name topic, bucket or table), and cdkd diff printed 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 to noEchoLeaves. 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-masking Phase 4w declares the parameter behind a fixed-name SNS topic NoEcho with 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, while cdk diff --security-only is 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-on counts only the stacks it prints: any-change fails on a security change in a broadening tree, broadening fails without printing the tables twice, destructive still reads the whole diff. The security diff (#4601) is computed only when the flag or --fail-on=broadening asks. Tests: tests/unit/cli/{diff-fail-on,diff-deploy-refusal-exit}.test.ts; integ require-approval-broadening.
  • A template parameter declared NoEcho: "true" (the string form) is masked like NoEcho: 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 or CfnInclude template). Several readers tested only the boolean. With the string form, the value printed on cdkd deploy's log lines (the resolver's --verbose lines 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 diff also 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 whose Export.Name spells 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-masking declares 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::Policy and an AWS::IAM::RolePolicy / UserPolicy / GroupPolicy on 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 drift no longer reports an AWS::IAM::RolePolicy / UserPolicy / GroupPolicy's policy as Policies drift on its role, user or group (issue #4821) -- src/provisioning/providers/iam-role-provider.ts (collectInlinePolicyNamesManagedBySiblings), src/deployment/deploy-engine/observed-capture.ts. Only an AWS::IAM::Policy sibling's inline policy was excluded from the principal's Policies, so a CfnRolePolicy on a role that declares inline policies showed its name as drift on the role. A standalone policy naming the principal in RoleName / UserName / GroupName is 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 NoEcho parameter, 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 with Ref / Fn::GetAtt in the same stack stays as the id is. A queue policy whose Queues imports another stack's secret-named queue URL with Fn::ImportValue / Fn::GetStackOutput, and whose document spells the secret reference literally, still had the URL rewritten into https://sqs.../{{resolve:...}} by the save, so cdkd destroy of that stack failed with InvalidAddress. 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, in cdkd deploy and cdkd scrub, whether or not the parent's row holds a secret. A queue named from a NoEcho parameter, read by a reader that also reads the parameter, had its URL copy flattened to ***; the NoEcho containment arm now keeps a copy's spans too, and still masks the value anywhere else.
  • cdkd destroy no 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. An AWS::SQS::QueuePolicy whose Queues reads a queue named from a secret, and whose policy document spells the secret reference literally, had its plaintext Queues URL rewritten into https://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 / --revert did the same. The provider deletes by that URL, so cdkd destroy failed with InvalidAddress. Each physical id a Ref / Fn::GetAtt reads (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 next cdkd deploy, which sends and replaces nothing for it, though its plan, cdkd diff and --require-approval show the reader as changed once; a cdkd destroy run first still fails. A copy read from another stack, through a nested stack's parameter, or of a NoEcho value is not covered (issue #4819).
  • A cross-account Fn::GetStackOutput that 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 new src/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 an AssumeRole denial). cdkd deploy and cdkd diff now recognize it, only for a reference carrying a RoleArn: the RoleArn is a literal ARN or CDK's exact Fn::Join over AWS::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 any RoleArn; 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 names cdk bootstrap --trust-for-lookup <consumer-account> and a user-written cdk.Fn.getStackOutput(); cdkd deploy refuses 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 without RoleArn runs 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 its Fn::GetAtt Queue.Arn was 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 destroy can delete it (issue #4801) -- src/deployment/rollback-executor/replay-secrets.ts (redactRollbackRecord, keepPersistedPlainLeaves), with the previous record's attributes passed in by src/deployment/rollback-executor.ts, src/deployment/rollback-executor/replay-revert.ts and src/deployment/rollback-executor/replay-reverse-replacement.ts, and the rollback journal's copy of each previous record in src/deployment/deploy-engine/rollback.ts. When an automatic rollback (or cdkd rollback) reverted a resource such as an AWS::SQS::QueuePolicy whose queue is named from a {{resolve:secretsmanager:...}} reference, the restored record had the queue name inside its Queues URL replaced by the reference, so cdkd destroy failed with "The address https://sqs.../{{resolve:secretsmanager:...". The restore, and the journal cdkd rollback restores 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 scrub repairs 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 its Arn as arn:aws:sqs:...:{{resolve:secretsmanager:...}}. A deploy that left the queue unchanged served that record to a reader's Fn::GetAtt Queue.Arn and sent the spelling to AWS: an AWS::SSM::Parameter refused it and the deploy rolled back, while an AWS::SQS::QueuePolicy accepted it and its Resource matched nothing. A value an Fn::GetAtt or Ref serves 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's Data, an AWS::S3Tables::Table TableName on 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=broadening and cdkd 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" in cdk.json (the AWS CDK CLI's default) was ignored with a warning, so a deploy that cdk would stop for approval went ahead. Both now take broadening, decided by the AWS CDK CLI's own security diff (@aws-cdk/cloudformation-diff's permissionsBroadened, now a direct dependency, loaded only at this level): an added IAM statement, managed-policy attachment, AWS::Lambda::Permission or security-group rule. cdk.json's broadening is honoured, so such a deploy now asks, and without a terminal it fails unless --yes. The approval prompt and the --fail-on report 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 as cdkd diff resolves it; a reference to a replaced resource is not a change. --fail-on=broadening walks nested stacks; under --require-approval each nested stack asks for its own changes. The default level stays never. 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::Policy resources 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. An AWS::IAM::Role / Group / User update that drops a name from its own Policies now also keeps it when another resource of the same deploy has already put that name there, as a rollback already did. Covered by tests/unit/deployment/inline-policy-write-order.test.ts, tests/unit/deployment/deploy-engine-inline-policy-claims.test.ts, and an unordered arm in the iam-role-policies-drift-clean integ fixture.
  • cdkd destroy masks a resource name taken from a NoEcho parameter 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, a NoEcho value 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 destroy now binds the stack's NoEcho values once from the synthesized template, falling back to each literal Default when the binding fails, and hands them to a nested child. It binds nothing when no NoEcho parameter 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 next cdkd deploy rewrites the record. The prompt's list of resources an earlier rollback kept (state.orphans) now masks an id named from any secret, as cdkd diff does. A NoEcho: "true" string now redacts the parameter's --verbose line 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 import through Cloud Control no longer masks a Fn::GetAtt attribute that is also a writable property (issue #3735) -- src/provisioning/read-only-properties.ts (new getTopLevelAttributeNames), src/provisioning/cloud-control-provider.ts, the new generated src/provisioning/getatt-attributes.generated.ts and its generator scripts/gen-getatt-attributes.ts (vp run gen:getatt-attributes, vp run audit:getatt-attributes:check, chained into gen:all-matrices), and src/deployment/deploy-engine/masking.ts. The import kept only the type's registry readOnlyProperties and recorded every other model key as ***, but CloudFormation also accepts some writable properties in Fn::GetAtt (AWS::EC2::Subnet VpcId / CidrBlock / AvailabilityZone, AWS::SQS::Queue QueueName, ...). 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-spec devDependency (no new runtime dependency). Other writable properties stay masked (so does AWS::SSM::Parameter Value), 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) -- new src/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 an unverifiable-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 an incomplete-resolution refusal, 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::Select evaluated against the stack's records, anything else refused), and a record holding a parameter's Default. The prompt states the assertion: AWS holds no secret the template does not spell as a dynamic reference, because whatever it holds is written to state.json. -y / --yes logs 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 both observedBaselineRefused and observedBaselineRefusalReason are removed only after the read succeeds. The sticky remedies name the command. Tests: tests/unit/cli/state-refresh-observed-accept-refused.test.ts. Integ import-deployed-parameter-secret step 6d refuses an undeployed template and the parameter-bound record, then accepts a function moved to a dynamic reference.
  • AWS::DynamoDB::Table now refuses an OnDemandThroughput ceiling 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.ts and the dynamodb-ondemand integ fixture (Phase 1.8). A MaxReadRequestUnits / MaxWriteRequestUnits spelled padded, hex, exponent, decimal, null, boolean, array or object, at the table level or in a GlobalSecondaryIndexes[] 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, and cdkd drift reported it on every run. create() and update() 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 after cdkd import keeps the import's narrowing (issue #2925) -- src/provisioning/cloud-control-provider.ts (maskDeployedNonAttributeKeys), src/provisioning/read-only-properties.ts (getTopLevelDeployAttributeNames), the generated SHAPE_MISMATCHED_GETATT_ATTRIBUTES, src/cli/commands/import.ts, src/deployment/deploy-engine/masking.ts. Every model key that is not an Fn::GetAtt attribute of the type is now recorded as ***, key kept, as cdkd import already 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::DBCluster Port, which CDK's allowDefaultPortFrom reads), and keys the enrichment computes (Endpoint.Address, ...) are never masked; AWS::SSM::Parameter Value stays clear unless the deploy resolved it from a secret. When the deploy cannot read the schema (no cloudformation:DescribeType), it still records the whole model, as before, rather than refusing every Fn::GetAtt. A re-import's "produced no value to replace the redaction mask" warning now names only keys the template reads through Fn::GetAtt or Fn::Sub; other kept masks go to --verbose. The masked-attribute refusal's Cloud Control cause no longer says only cdkd import writes 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, Data included, under custom-resource-responses/ in the state bucket. A run killed before cdkd deleted it left that object live until cdkd gc ran. cdkd bootstrap now adds the lifecycle rule cdkd-custom-resource-responses-expiration for custom-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: one GetBucketLifecycleConfiguration per run, started beside the response placeholder write and never awaited, plus a listing and one PutBucketLifecycleConfiguration the 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-responses with a stack named cdkd-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 after aws://<account>/ as the region. For US-EAST-1 the 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-1 and a region followed by a line break all read as us-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 with Unable 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 that cdkd state show and cdkd state orphan still reach that record with --stack-region given the exact raw spelling. The account part of the environment and the unknown-* sentinels are unchanged.

Recently Implemented (2026-10-11):

  • cdkd's errors, warnings and --help text no longer cite GitHub issue numbers or probe dates -- 40 files under src/ (the type-change and ServiceToken refusals, the Glue Iceberg refusal, the Cognito, DynamoDB, EFS, Firehose and RDS notes, the cdkd local start-alb / start-cloudfront warnings and help, and the cdkd events, destroy --purge-events, state show --show-nested and deploy --recreate-via-sdk-provider help text, among others). Messages such as which 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 and cdkd export messages are gone too. Wording only: no message changes what cdkd does.
  • cdkd destroy of 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 (journaledOrphanSkips on the result), src/provisioning/nested-stack-context.ts, and the new src/cli/commands/destroy-skip-message.ts used by destroy.ts and state.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 its AWS::CloudFormation::Stack row 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 a cdkd destroy / cdkd state destroy whose 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 a cdkd deploy --no-rollback made an AWS::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 rollback and cdkd destroy kept it the same way. The global table provider now answers both questions by the TableId of the table in the stack's region, which AWS generates and never reuses: the failed CREATE records the id its CreateTable returned, and the successful deploy deletes the earlier table only when the record's table reads back under another TableId and the old name still reads back under the recorded one. A table deleted by hand and re-created under the name has another TableId and is kept, with a warning. Tests: tests/unit/provisioning/dynamodb-globaltable-provider-same-resource-4606.test.ts; integ: globaltable-fix-forward-orphan.
  • cdkd diff and cdkd deploy mask 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-only LogGroupName moves 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. The requires replacement (from <old> to <new>) line of cdkd diff --verbose and cdkd deploy --verbose printed it. So did cdkd 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's Deleting old <id> (<old id>) and was replaced lines, logged without --verbose, whose old id embeds the name. storedSecretNameNeedles now takes each record named from a secret, judged from state and the stack's whole NoEcho values, wherever another record or a stored output holds its name. A segment the record itself stores in the clear stays public. cdkd diff masks 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. Integ secret-derived-immutable-names steps 5c and 5d move a metric filter off a secret-named log group under cdkd diff --verbose, cdkd deploy --dry-run --verbose and 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 the UpdateContext and calls assertRegionMatch before any AWS call), scripts/check-provider-update-context.ts (four names leave OMITS_UPDATE_CONTEXT), and unit cases in each provider's test file. These providers ignored the recorded region on update, so a deploy, rollback or cdkd drift --revert run 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 deploy and cdkd destroy of a stack with hundreds of CloudWatch alarms no longer fail on Rate exceeded (issues #4774, #4781) -- src/provisioning/providers/cloudwatch-alarm-provider.ts (create(), update(), delete() through one sendWithThrottleRetry), a new unit file, and the throttle-wide-dag integ fixture (now with 300 alarms). The provider sends one PutMetricAlarm or DeleteAlarms per 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 delete ResourceNotFound is 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 the alb integ's Phase 6. Before this change, when a --recreate-via-cc-api / --recreate-via-sdk-provider recreate (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 run cdkd 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, no journalVersion bump). 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 by UpdateReplacePolicy: 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 a cdkd deploy --no-rollback made an AWS::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's TableId, which AWS generates and never reuses: the failed CREATE records the id its CreateTable returned, and the successful deploy deletes the earlier table only when the record's table reads back under another TableId and the old name still reads back under the recorded one. A table deleted by hand and re-created under the name has another TableId and is kept, with a warning. The same identity serves cdkd rollback and cdkd destroy for such a journaled table. Tests: tests/unit/provisioning/dynamodb-table-provider-same-resource-4606.test.ts; integ: dynamodb-fix-forward-orphan.
  • cdkd diff and 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 as Parameters: { QueueUrl: Ref SecretQueue }. cdkd diff --recursive discarded the needles of that read, so its walk into the child printed the queue name on the child's Parameter QueueUrl: using user-provided value line, its Ref lines 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 in deployments/*.jsonl was 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. The physicalId field 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. Integ secret-derived-immutable-names step 5b now runs cdkd diff --recursive and FAILs when the child's lines name the queue; it used to print a NOTE.

Recently Implemented (2026-10-09):

  • AWS::EMR::InstanceFleetConfig now records InstanceFleetId as well as Id in its attributes (issue #4770) -- src/provisioning/providers/emr-instance-fleet-config-provider.ts and emr-instance-group-config-provider.ts, plus tests. The CFn schema renamed the read-only attribute from Id to InstanceFleetId (#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, and cdkd orphan could not rewrite it. An aws-cdk-lib regenerated from that schema emits the new name as attrInstanceFleetId. create, update and getAttribute now serve both names as the instance fleet id, which is also the physical id. A fleet whose state was written before this change keeps only Id until its next update or replacement. Until then, InstanceFleetId still resolves through the fallback. The same holds for an AWS::EMR::InstanceGroupConfig update that changes nothing mutable: it now records InstanceGroupId too, 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. The local start-api VPC and AWS_IAM warnings and help link to https://cdkd.dev/local-start-api/; the AssumeRole failure to the trust-policy setup on cross-stack-internals; the AWS_PROFILE-with-assumed-role warning to cli-reference/#profile-vs-role-arn; the deletion-protection replacement refusals and the LogGroupClass refusal to their section of cli-deploy-safety; the sticky Cloud Control warning and the stateful-types help text to that page; and the scrub legacy-key refusals to the version purge on cli-scrub. The distributed cdkd-skills plugin links to cdkd.dev the same way (0.7.14). Tests: tests/unit/scripts/cdkd-dev-links.test.ts resolves every cdkd.dev link in src/ and plugins/ to a page and heading.
  • A retried S3 bucket create with an explicit BucketName no 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 answered BucketAlreadyOwnedByYou (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 the CreationDate this account's ListBuckets reports), 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 destroy and 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 for cdkd 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 its DeletionPolicy is Retain or Snapshot) 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 destroy and cdkd rollback now 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 later cdkd destroy or cdkd rollback skipped 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. The cdkd rollback plan 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 ServiceToken is now refused, as CloudFormation refuses it, instead of being sent as an Update to 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 a Delete, so whatever it created was orphaned silently. Two different ARNs are refused before anything is provisioned (--dry-run too), with no request to either handler and state unchanged; cdkd diff reports it under Blocking and exits 3. 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 diff warns about it. A recorded token cdkd cannot compare (missing, ***, a {{resolve:...}} reference) is refused when the template moves it; a token fed by a NoEcho parameter, 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-api target 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 BucketName on 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 and CreateBucket, took BucketAlreadyOwnedByYou (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 at cdkd import and 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 at cdkd import. Tests: tests/unit/provisioning/providers/s3-bucket-explicit-name-held-4684.test.ts, s3-bucket-create-retry-4639.test.ts; integ: s3-lifecycle phase 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. ListBuckets can 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 a cdkd deploy --no-rollback made an AWS::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 the CreationDate this account's ListBuckets reports, 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 serves cdkd rollback and cdkd destroy for such a journaled bucket. Such a bucket is never emptied on any of these paths, even with autoDeleteObjects: 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 --accept and --revert write 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, or properties when there is none) and merge into it what was accepted or what the provider reported after a revert. A baseline that still held a NoEcho value in the clear at a position the record names in noEchoLeaves, 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 in properties. An observed baseline is also masked at every *** leaf of properties, as the deploy's capture is. Every writer that masks an observed baseline at a NoEcho position (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 --accept reports as recorded is unchanged. Tests: tests/unit/cli/drift-noecho-v11.test.ts.
  • cdkd import and cdkd scrub store a NoEcho parameter's position as ***, and cdkd export exports 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 import now writes a property its template fills from a NoEcho template parameter as *** whatever the value's type or length, names it in noEchoLeaves, 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 scrub applies 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 older Default), that value also masks its copies elsewhere in the record. A stack with no other secret is scrubbed for it, and --dry-run --fail counts it. An Export.Name the deploy refuses for holding or reading a NoEcho value no longer makes scrub keep every unnamed output key as a possible alias. cdkd export no 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 whose AWS::IAM::Policy pre-delete principals or name a NoEcho parameter fills, and refuses a nested child parameter that resolves to the mask. Scrub also masks an output a NoEcho parameter 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 a NoEcho template parameter (or a NoEcho attribute read through Fn::GetAtt) is recorded as *** at the coordinates noEchoLeaves names, and every rollback revert of such a resource, automatic or cdkd rollback (with or without --revert-failed), failed with ROLLBACK_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 a NoEcho value 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 its noEchoLeaves, 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 a NoEcho-specific remedy. A nested stack row's masked Parameters entry 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 in tests/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 each NoEcho position. 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's Deleted subscriber line 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 a NoEcho parameter, or in a property the template removed, or in a removed list entry when the list no longer holds any NoEcho position (a pre-v11 record does not say which of its positions a NoEcho parameter fed); and a previous value of exactly true / false inside a list whose length changed, since recording it would mask every line containing that word. As with every NoEcho value, 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 Path is a replacement through the engine's guards, not a deploy failing with EntityAlreadyExists (issue #4739) -- src/analyzer/replacement-rules.ts, src/provisioning/providers/iam-role-provider.ts. Path is create-only, but the replacement registry listed it as updatable, so the deploy sent a path edit on an iam.Role to 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 with EntityAlreadyExists, with no guidance. A Path change now replaces the role: the create-first attempt's name collision is refused with the replacement-collision guidance (the name's origin, and cdkd deploy --replace), and under --replace the role is deleted and re-created on the new path, and resources reading its ARN are updated to the new one. / and an absent Path are the same path and replace nothing. The provider's update() refuses a Path-only change as not updatable in place instead of attempting the colliding create. A Path taken 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 a Path fed by a changed NoEcho parameter 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 a NoEcho parameter 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 (an AWS::Budgets::Budget's NotificationsWithSubscribers, 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 diff no longer prints a rotated NoEcho parameter'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 published Export.Name: {Fn::Sub: 'x-${Short}-y'} while the NoEcho parameter Short held ab left the alias x-ab-y in state; after the value rotated, the diff withheld only a stored name equal to today's refused name (x-cd-y) and printed x-ab-y in the human rows and in --json. While any declared output (a condition-skipped one included) has an intrinsic Export.Name that can read a NoEcho parameter or custom-resource attribute under any Fn::If verdict (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's exportNames lists, 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 a NoEcho parameter prints its stale aliases as before. This also withholds the stored alias of a condition-false exporter, or of an Fn::If branch 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; integ noecho-parameter-masking (Phase 1c plants the legacy alias).

  • --recreate-via-cc-api of an imported Application Auto Scaling scalable target is now refused instead of silently losing its scaling policies, and cdkd import records a resource it read through Cloud Control as provisionedBy: 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 as sdk, including a type with no SDK provider, which a deploy records as cc-api. For an AWS::ApplicationAutoScaling::ScalableTarget that let --recreate-via-cc-api past its already-on-Cloud-Control refusal: the recreate deregistered the target, which deletes its scaling policies, and re-registered it under the same ResourceId|ScalableDimension|ServiceNamespace id, so each AWS::ApplicationAutoScaling::ScalingPolicy resolved 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-api of 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; integ app-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-bucket or 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>.json names 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; integ cross-backend-same-stack, cross-backend-same-stack-migration, perf-cross-backend-check.

  • cdkd rollback --remove-protection now 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 with deletion_protection.enabled, an EC2 instance with DisableApiTermination) was deleted without it, AWS refused, and cdkd rollback --remove-protection exited 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; integ remove-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 is AWS::ApplicationAutoScaling::ScalingPolicy.ScalingTargetId: a change that replaces the scalable target (a renamed DynamoDB table, a moved ResourceId) 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 fresh NoEcho secret AWS never returns (go-to-k/cdkd#3803). That rule now applies only when the property is not exactly a Ref or Fn::GetAtt of 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. A Fn::GetAtt of a custom resource or nested stack, and any value built from a reference (a {{resolve:...}} dynamic reference, an Fn::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 ElastiCache ReplicationGroup, VPC Lattice listeners and rules, DataSync locations and ServiceDiscovery::PrivateDnsNamespace.Vpc. Tests: tests/unit/analyzer/diff-calculator-synthetic-create-only.test.ts, tests/unit/deployment/recreate-target-readers.test.ts; integ dynamodb-autoscaling Phase 3 (a table rename replacing both scalable targets and their policies).

  • A successful deploy and cdkd rollback no 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 a cdkd import adopting the cluster journaled as MyCluster under mycluster (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-protection ownership 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; integ rds-fix-forward-orphan.

  • Replacing an ALB listener that has rules now replaces each AWS::ElasticLoadBalancingV2::ListenerRule onto the new listener, instead of failing the deploy with NotFound (src/analyzer/replacement-rules.ts, go-to-k/cdkd#4689). A --recreate-via-cc-api / --recreate-via-sdk-provider of the listener, or a change that replaces it (its LoadBalancerArn, or a replaced load balancer), planned each rule reading it as an in-place update: the rule's ListenerArn is 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 failed One or more rules not found after the listener had already been replaced, and the --recreate-via-* pre-flight did not list the rule. The replacement rules now name ListenerArn for 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; integ alb Phase 5 (a --recreate-via-cc-api of 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 the AmbiguousCreateLatch providers (kms, cognito, appsync, apigateway, apigatewayv2, emr-*, lambda-layer, lambda-eventsource, dlm-lifecycle-policy, ecs, ec2), their unit tests and docs/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 -- for AWS::IAM::AccessKey a live credential no state records -- and nothing reported it. After a create the SDK retried inside its call, cdkd now checks right away: for CreateAccessKey, 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 the aws iam delete-access-key command); 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 NoEcho parameter is deleted again by cdkd destroy, with the real value in its Delete request (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 destroy with 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 for cdkd state destroy, a deploy that removed the resource, a rollback, a position an attribute declared NoEcho fills, and a template that no longer reads a NoEcho parameter there, changed that property's text or resolved inputs, changed the type, carries a Transform or was synthesized for another region. Behavior change: a custom resource whose recorded properties hold *** at a position the record's noEchoLeaves does not name (one embedded through Fn::Join, a record an earlier cdkd wrote, one cdkd import / cdkd scrub wrote) used to receive *** in its Delete; that delete is now skipped too, keeping the record (for an import / scrub record, a cdkd deploy first records the positions). Tests: tests/unit/deployment/noecho-delete-reresolution.test.ts, tests/unit/cli/destroy-runner-noecho-reresolve-4682.test.ts; integ schema-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 a ListenerAttributes key 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 its Tags handled, was created through Cloud Control, and the sticky routing rule kept that record there. Cloud Control's update sends no ModifyListenerAttributes for a removed key, so for example routing.http.response.server.enabled stayed false after it was dropped. AWS::ElasticLoadBalancingV2::Listener is now an sdk-coverage sticky exemption: the record moves to the SDK provider in place, with its listener ARN unchanged, and that deploy resets the removed key. cdkd diff marks 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; integ alb Phase 2.5 (a listener record seeded to cc-api as for go-to-k/cdkd#4117, Cloud Control identifier parity, in-place return and attribute reset).

  • cdkd rollback --remove-protection now 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 with DisableApiTermination, a load balancer with deletion_protection.enabled) was deleted without it, AWS refused, and every cdkd rollback exited 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 trust cdkd destroy --remove-protection gives 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; integ remove-protection-journaled-orphan.

  • --remove-protection now clears a deletion-protected resource that only the rollback journal records, on cdkd destroy / cdkd state destroy and, new, on cdkd 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 with DisableApiTermination, a load balancer with deletion_protection.enabled) left it journaled; destroy dropped the flag on the way to the journal's delete and cdkd rollback had 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; integ remove-protection-journaled-orphan.

  • cdkd rollback and cdkd destroy no 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 later cdkd import had 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 resourceIdentity must 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 rollback and cdkd destroy both 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-failed removes 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.ts and the rollback and destroy suites; integ rollback-command steps O10 and O11.
  • An Export.Name that reads a NoEcho: true parameter 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). An Export.Name intrinsic that reads a NoEcho parameter through Ref, an Fn::Sub variable, or an Fn::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 to state.json, exportNames and the exports index. An Fn::If reads 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 a NoEcho source. 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's Fn::ImportValue of it stops resolving. cdkd diff previews 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 NoEcho parameter 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 read differs on an unchanged value. A record now carries noEchoExactEchoLeaves (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 (--yes approves); 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; integ schema-v10-to-v11-migration.

  • cdkd export's and cdkd drift's pasteable cdkd state commands now carry the run's --profile, resolved --state-bucket and non-default --state-prefix (issue #4648). Changed files: src/cli/commands/export.ts, src/cli/commands/drift.ts and src/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's cdkd state refresh-observed remedy, which WRITES the record, and its cdkd state show inspect line;
    • cdkd export's cdkd state orphan drops: the failed pre-delete / phase 2, the per-stack recovery list, the tree's Recover with: template, and a missing or out-of-sync nested child (also reached by cdkd state show --show-nested);
    • cdkd drift --revert's Populate with: cdkd state refresh-observed line.

    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, its cdkd state list --json pointer says to run it with the same flags. This completes #4648.

  • cdkd diff --recursive --fail no 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's Parameters.<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 while cdkd diff --recursive rendered 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, its Fn::Join / Fn::Sub leaves and the inherited-secret carry (so Fn::Select, Fn::Split, arrays and Fn::If too) read it. An Fn::Join / Fn::Sub parent source, a literal holding a public ssm token, and a parameter that one child resource reads beside such a sibling, either of the two through an intrinsic other than Fn::Join / Fn::Sub (Fn::Select, ...), or that the child's Outputs read 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 a withoutServerErrorRetries client (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 CreateBucket is refused, not adopted (issue #4639, S3 bucket residual) -- src/provisioning/providers/s3-bucket-provider.ts. CreateBucket carries no idempotency token. When a 5xx hid a create S3 had completed, the replay answered BucketAlreadyOwnedByYou (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 of cdkd rollback --revert-failed's reach. CreateBucket now 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 with aws s3api list-buckets --prefix (only this account's buckets; CreationDate is a hint) and list-object-versions, then a delete-bucket command 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 (an OperationAborted, a SlowDown, 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. After cdkd deploy --no-rollback failed with an AWS::DocDB::DBCluster or AWS::DocDB::DBInstance already 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, exited 2 and left it in AWS. DocDBProvider now implements isSameResource and resourceIdentity, both resting on the immutable DbClusterResourceId / 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 integ docdb-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 an AWS::EC2::VPC, AWS::EC2::Subnet or AWS::EC2::SecurityGroup CREATE 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, exited 2 and left it in place. EC2Provider now answers isSameResource for these types, using the AWS-generated vpc-, subnet- or sg- id. The client must be in the stack's region, and the record's resource must read back live (a VPC or subnet pending or available; a security group whenever EC2 lists it). The earlier resource is then different, whether it still exists or is not found, so the settle deletes it. Equal ids are same. Any other id form and a failed read stay unknown, which keeps the previous warning and exit 2. An earlier resource EC2 no longer lists (its own InvalidVpcID.NotFound, InvalidSubnetID.NotFound or InvalidGroup.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. Integ ec2-instance Phase 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. After cdkd deploy --no-rollback failed with an AWS::Neptune::DBCluster or AWS::Neptune::DBInstance already 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, exited 2 and left it in AWS. NeptuneProvider now implements isSameResource and resourceIdentity, as RDSProvider does: both rest on the immutable DbClusterResourceId / 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 integ neptune-fix-forward-orphan.

  • cdkd deploy masks 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 as Parameters: { 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's Resolved Ref to parameter: QueueArn and Parameter QueueArn: using user-provided value lines, 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. The cdkd diff walk into a child is not covered yet. Tests: tests/unit/deployment/deploy-engine-derived-name-needle-3869.test.ts. Integ secret-derived-immutable-names gains the nested stack QueueReaderChild, 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 gc or cdkd force-unlock into 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 with x instanceof Error ? x.message : String(x), and String() throws for a null-prototype object, so the handler itself threw: a failed lock release after a successful import, state orphan, refresh or cdkd orphan failed the command; one resource whose import or readCurrentState failed 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-cloudformation retire after CloudFormation had succeeded, and a mid-walk nested-template failure lost the uploads it should have carried out for deletion; a failed DescribeStackResources lookup aborted the import instead of falling back to per-resource lookups; cdkd gc's purge warning replaced its GC_DELETE_FAILED error, and its delete failure lost that code; and cdkd force-unlock stopped 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 failed UpdateStack surfaced as the converter's TypeError. Each now goes through describeAwsFailure(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 with x instanceof Error ? x.message : String(x), and String() 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 in WorkGraph.execute that exited cdkd deploy / cdkd publish-assets with the converter's TypeError instead 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 through describeAwsFailure(x).detail, byte-identical for an Error and 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 drift into 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 with x instanceof Error ? x.message : String(x), and String() 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. --accept and --revert rejected 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 as AWS update failed when 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 through describeAwsFailure(x).detail, byte-identical for an Error and 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 live Fn::GetAtt read in cdkd orphan rejected the rewrite instead of being reported as unresolvable (or, under --force, falling back to the recorded attribute); a failed DeleteStack of 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 failed ExpectedBucketOwner resolution rejected the state-bucket call instead of omitting the header; and formatError / normalizeAwsError, whose last arm exists for a non-Error value, threw on it. Each now goes through describeAwsFailure(x).detail or safeStringify(x), byte-identical for every value String() 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 scrub no 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 with x instanceof Error ? x.message : String(x) (or a bare String(x)), and String() 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's TypeError, losing their code and remedy. Each now goes through describeAwsFailure(x).detail (or safeStringify(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 with x instanceof Error ? x.message : String(x), and String() 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 on state.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's TypeError in place of its own keys and reason. Each now goes through describeAwsFailure(x).detail, byte-identical for an Error and 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 (new sameRegion), src/deployment/intrinsic-resolver/{cross-stack,stack-output,cfn-fallback,clients}.ts, src/deployment/intrinsic-function-resolver.ts, and a new fence tests/unit/deployment/resolver-region-compare-fence.test.ts. IntrinsicFunctionResolver.resolverRegion stays 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 a Region and once with Region: us-east-1 was written twice into state.outputReads, and likewise into state.imports across the exports-index and state-scan arms. The CloudFormation fallback built ListExports with the raw region and DescribeStacks with 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 through sameRegion, which folds both operands at the comparison and moves no state key. The CFn client memo and the DescribeStacks cache 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 drift reports an AWS::CloudWatch::AnomalyDetector as 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 no readCurrentState, so drift fell back to Cloud Control GetResource with 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; integ cloudwatch-anomaly-detector.
  • cdkd export refuses an AWS::CloudWatch::AnomalyDetector before locking the stack (issue #4668) -- src/cli/commands/export.ts. AWS gave the type Cloud Control handlers and the registry identifier AnomalyDetectorId, so the import-support pre-flight no longer blocked it. The single-key path would then have sent cdkd's descriptor-derived physicalId as AnomalyDetectorId, 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] on AWS::CloudWatch::AnomalyDetector now resolves (issue #4668) -- src/provisioning/providers/cloudwatch-anomaly-detector-provider.ts. The registry schema renamed the type's read-only primary identifier from Id to AnomalyDetectorId. The SDK provider recorded and resolved only Id, so AnomalyDetectorId fell 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 by getAttribute, with the same value (cdkd's physical id); Id stays for aws-cdk-lib releases whose attrId still 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. After cdkd deploy --no-rollback failed 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 optional resourceIdentity; the failed CREATE journals its token as createdResourceIdentity (additive, no journalVersion bump), 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 exits 2. A journal an older cdkd wrote, and a name-keyed type without resourceIdentity, now lands there too instead of being deleted. AWS::Kinesis::Stream implements 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 rollback and cdkd destroy are not covered yet (#4658). Tests: tests/unit/deployment/journaled-orphan-identity-4655.test.ts, tests/unit/provisioning/providers/kinesis-resource-identity-4655.test.ts; integ rollback-command step O9.
  • More pasteable cdkd state commands now carry the run's --profile, resolved --state-bucket and non-default --state-prefix (issues #4648, #4159). Changed files: src/state/malformed-resources-bag.ts (exports accountArgs / withheldAccountClause), src/deployment/deploy-engine/delete.ts, src/cli/commands/{destroy,destroy-runner,state,gc}.ts, src/utils/error-handler.ts and src/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-region cdkd state orphan, its nested-child refusal (cdkd destroy <parent> / cdkd state destroy <child>) and its termination-protection Retry with: line, cdkd state orphan's Destroy 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), and cdkd gc's unreadable-state-file cdkd state show line, 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: the cdkd export and cdkd drift commands, including cdkd 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-tables and s3-directory-bucket providers. Glue CreateDatabase / CreateTable / CreateWorkflow / CreateSecurityConfiguration / CreateJob / CreateCrawler / CreateConnection / CreateTrigger, Firehose CreateDeliveryStream, Kinesis RegisterStreamConsumer, Pipes CreatePipe, CloudTrail CreateTrail, S3 Vectors CreateVectorBucket, S3 Tables CreateTableBucket / CreateNamespace / CreateTable and S3 Express CreateBucket carry 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. Scheduler CreateSchedule (its SDK-filled ClientToken is serialized once and reused by the SDK's retries) and S3 CreateBucket (a replay answers BucketAlreadyOwnedByYou, 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-4639 unit files. CreateDBSubnetGroup / CreateDBCluster / CreateDBInstance (RDS, DocumentDB, Neptune), CreateDBProxy, CreateDBProxyEndpoint, CreateCacheSubnetGroup / CreateCacheCluster and CreateTable (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 one send, 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 by withoutServerErrorRetries: 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-send retries. 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.ts and the iam-role, iam-user-group, iam-instance-profile, iam-managed-policy and iam-access-key providers. CreateRole, CreateUser, CreateGroup, CreateInstanceProfile, CreatePolicy and CreateAccessKey carry 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 failed EntityAlreadyExists against 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. For CreateAccessKey the 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 when ListAccessKeys already 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.ts and the lambda-function, lambda-url, lambda-permission, eventbridge-bus and ecr providers. CreateFunction, CreateFunctionUrlConfig, AddPermission, CreateEventBus and CreateRepository carry 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, wafv2 and efs providers. CloudFront CreateOriginAccessControl, SSM PutParameter (Overwrite: false), CodeBuild CreateProject, Budgets CreateBudget, Auto Scaling CreateAutoScalingGroup, WAFv2 CreateWebACL and EFS CreateMountTarget carry 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. AgentCore CreateAgentRuntime / CreateEvaluator, the Cloud Map namespace and service creates, Lambda CreateMicrovmImage and Secrets Manager CreateSecret are 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), every cdkd deploy exited 2 and every cdkd destroy ended "State preserved", and the only way out was removing the stack's rollback-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 / -y skip 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-failed or --skip-final-snapshot. The retry warnings of cdkd destroy, a successful cdkd deploy and cdkd rollback now 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 a physicalId that 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-failed force-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: a Cannot ... -- its state record has no non-empty string 'physicalId' warning, a ROLLBACK_RESOURCE_SKIPPED event, exit 2, and the state record left as it is. Before, cdkd rollback --revert-failed (the remedy a failed deploy prints) sent provider.update a blank id for an UPDATE cdkd deploy had refused over such a record, a delete could read the provider's *NotFound as 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 on cdkd 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 an AWS::EC2::Instance CREATE failed after RunInstances and 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, exited 2 and left it running. EC2Provider now answers isSameResource for instances, using the AWS-generated i- instance id. The client must be in the stack's region, and the record's instance must read back live (pending, running, stopping or stopped). The earlier instance is then different, whether it still runs, is shutting-down / terminated, or is not found, so the settle terminates it. Equal ids are same. Any other id form and a failed read stay unknown, which keeps the previous warning and exit 2. An earlier instance EC2 no longer lists is named in one info line; one still listed as shutting-down or terminated is 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. Integ ec2-instance Phase 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. After cdkd deploy --no-rollback failed with an AWS::ElasticLoadBalancingV2::LoadBalancer, TargetGroup or Listener already 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, exited 2 and left it in AWS. ELBv2Provider now answers isSameResource for 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 then different when 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 are same; any other id form, a read naming another resource and a failed read stay unknown (the previous warning and exit 2). 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; integ alb Phase 4 asserts the earlier load balancer deleted, the new one kept, exit 0.
  • 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. After cdkd deploy --no-rollback failed with an AWS::EMR::Cluster that RunJobFlow made 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 exited 2. EMRClusterProvider now answers isSameResource from the j- 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 then different, so the settle deletes it (terminating a live one) and the deploy exits 0. Equal ids are same; any other id form and a failed read stay unknown (the previous warning and exit 2). An earlier cluster already terminated or gone is named in one info line. AWS::EMR::InstanceFleetConfig / AWS::EMR::InstanceGroupConfig keep 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; integ emr-cluster Phase 3b asserts the earlier cluster settled, the new one kept, exit 0.
  • 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. After cdkd deploy --no-rollback failed 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, exited 2 and left it in AWS. Providers gain an optional isSameResource, answering same, different or unknown from 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 journaled DeletionPolicy, still subject to the other-stack ownership check) only on different, without reading or dropping that record, and settles same silently as a tracked resource. AWS::Kinesis::Stream implements 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 is different when it reads back under another StreamARN or is gone. Equal names are never compared and never deleted; an earlier stream already gone is named in one info line. Only AWS::Kinesis::Stream implements it so far: every other type, an unreadable record, and a read that fails keep the previous warning and exit 2 (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; integ rollback-command step O8 now asserts the earlier stream deleted, the new one kept, exit 0.
  • 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. After cdkd deploy --no-rollback failed with an AWS::FSx::FileSystem already 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, exited 2 and left it in AWS, billed by the hour. FSxFileSystemProvider now answers isSameResource for every variant (Lustre / Windows / ONTAP / OpenZFS) from the fs- file system id FSx generates: the client must be in the stack's region and the record's file system must read back short of DELETING / FAILED, and the earlier one is then different, so the settle deletes it; equal ids are same. An unrecognised id form and a failed read stay unknown (the previous warning and exit 2). An earlier file system already gone is named in one info line. Tests: tests/unit/provisioning/fsx-same-resource-4606.test.ts; integ fsx-lustre Phase 2c asserts the earlier file system deleted, the new one kept, exit 0.
  • 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. After cdkd deploy --no-rollback failed with an AWS::EC2::NatGateway or AWS::EC2::EIP already 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, exited 2 and left it in AWS. EC2Provider now answers isSameResource for those two types when SDK-routed (a Cloud Control-routed one, e.g. a NAT gateway setting MaxDrainDurationSeconds, is unchanged) from the AWS-generated id (the nat- gateway id; the eipalloc- allocation id from the composite PublicIp|AllocationId or 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 gateway pending or available), and the earlier one is then different, so the settle deletes it; equal ids (including the composite and bare spellings of one allocation id) are same, and a bare journaled allocation id (the create's fallback when the composite fence refuses) is never different, since the settle's holder checks compare the id string. Every other EC2 type, an unrecognised id form and a failed read stay unknown (the previous warning and exit 2). 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; integ vpc-nat-gateway Phases 1d / 1e assert the earlier resource deleted, the new one kept, exit 0.
  • 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. After cdkd deploy --no-rollback failed with an AWS::RDS::DBCluster or AWS::RDS::DBInstance already 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, exited 2 and left it in AWS. RDSProvider now implements isSameResource and resourceIdentity. Both rest on the immutable DbClusterResourceId / 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 integ rds-fix-forward-orphan.
  • The malformed-record refusals and warnings from cdkd deploy, cdkd diff, cdkd drift, cdkd scrub, cdkd export, cdkd local --from-state and cdkd state show / cdkd state resources now print their cdkd state show commands with the run's --profile, resolved --state-bucket and 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}.ts and src/local/s3-local-state-provider.ts. Before this change only cdkd destroy, cdkd import, cdkd orphan, cdkd rollback and cdkd state refresh-observed carried 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-prefix such as team/dev is 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: true parameter's value, and a value an attribute was declared NoEcho for, 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); noEchoLeaves names those positions, noEchoAttributeNames the 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's Update runs every deploy with *** in OldResourceProperties; its Delete is 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 drift reports a masked position in its own bucket; cdkd diff no longer shows a changed NoEcho value (one per-stack note, not counted for --fail); cdkd export blocks 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"; earlier state.json versions 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. CreateStream carries 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 one send, and the replay's ResourceInUseException surfaced from the deploy engine's FIRST attempt, read as a name another holder owns. CreateStream now goes through a dedicated client wrapped by withoutServerErrorRetries: 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 of NAMED_REPLACEMENT_COLLISION. A CreateStream 5xx that did NOT hide a success is now retried on the engine's logged schedule rather than the SDK's quick in-send retries. 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 destroy masks 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, and cdkd destroy deletes 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, the reason and 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 in deployments/*.jsonl, while the log line beside them was masked. They are now masked with the same needles. An event's physicalId field stays exact. Tests: tests/unit/cli/destroy-journaled-orphans-secret-name-3869.test.ts, tests/unit/deployment/secret-name-needles-3869.test.ts; integ secret-derived-immutable-names gains 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 diff previews the refusal cdkd deploy throws 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 rollback and 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 on cdkd deploy and cdkd diff too. cdkd rollback also 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's physicalId field 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; integ secret-derived-immutable-names adds a completed-CREATE queue named from the secret to steps 6c and 6d.
  • cdkd deploy masks 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, a NoEcho parameter 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 the Creating final snapshot and ready lines of a template-removal or replacement delete, and AWS's SnapshotAlreadyExistsFault text 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. The noecho-parameter-masking integ gains Phases 4a and 4b: a replication group named rg-<NoEcho value> under DeletionPolicy: Snapshot is removed, and its snapshot line must print no fragment of the name.
  • cdkd rollback and 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, and cdkd rollback --revert-failed replays 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. Its ROLLBACK_RESOURCE_FAILED and skip events were masked with the op's own masker only, which holds no name the resource READ from another record (an access key's UserName). cdkd rollback now replays under the printing bag cdkd destroy uses (#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 its Adopting ... as <physicalId> line, its notices about kept records, and its refusal of a record another stack holds. An event's physicalId field 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; integ secret-derived-immutable-names gains steps 6c and 6d, a plain cdkd rollback and 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 scrub of 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 a postgres://u:<secret>@host connection string the parent built with Fn::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. Composite Fn::Sub / Fn::Join leaves that every positioning arm refuses keep the old behaviour. cdkd diff --recursive still 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 ListenerAttributes key resets it in AWS (issue #2085) -- src/provisioning/providers/elbv2-provider.ts, src/provisioning/property-coverage.generated.ts. An AWS::ElasticLoadBalancingV2::Listener carrying Tags (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 from ListenerAttributes (e.g. routing.http.response.server.enabled) left the old value live in AWS. ELBv2Provider now declares Tags handled 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; integ alb (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, the RESOURCE_FAILED event 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 no cause, so the event's awsErrorCode / requestId were absent. The delete error is now chained as the cause, 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 --replace re-create wrap's existing cause is 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; integ recreate-via-cc-api gains 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-Error refusals that no retry can change (an unsupported intrinsic, a malformed Fn::GetAtt / Fn::Join / Fn::Select / Fn::Base64 / Fn::Cidr / Fn::And / Fn::Or / Fn::Not argument, a missing Fn::FindInMap mapping or key, an invalid Fn::GetAZs region or an empty zone list, every Fn::GetStackOutput argument 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 as DependencyViolation read 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 containing NotFoundException made cdkd destroy and 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_COLLISION refusal ends on a pasteable cdkd import command (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 with cdkd import" and named a deploy interrupted earlier as the only own-resource case. It now names a resource a cdkd destroy kept under DeletionPolicy: Retain too, and ends on Adopt 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, for AWS::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::QueuePolicy or AWS::SNS::TopicPolicy journal 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 destroy and 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 exit 2 (cdkd destroy warns 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 of sns-subscription-filter, Phase 2b of sns-sqs-event.
  • An AWS::SNS::TopicPolicy is now actually removed from a topic: on destroy, and from a topic an update drops from Topics (issue #4610) -- src/provisioning/providers/sns-topic-policy-provider.ts. delete() sent an empty Policy, which SNS would reject: CloudFormation's own TopicPolicy handler source says SNS requires a policy on a topic. delete() read an Invalid parameter rejection 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 an InvalidParameter fails the delete. A record whose id is not a list of topic ARNs (the policy name an old --migrate-from-cloudformation recorded) falls back to the topic ARNs its Topics lists. When neither names one, the destroy now refuses, naming the by-hand reset and the cdkd state orphan --resource remedy. update() wrote only the new Topics, so a dropped topic kept the old statement; this is also what an in-place rollback of Topics [T1] -> [T1,T2] left on T2. 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's PolicyDocument by content. Tests: tests/unit/provisioning/sns-topic-policy-provider-dropped-topics-4610.test.ts; integ arm in tests/integration/sns-sqs-event/.
  • A removed or orphaned resource is listed by its construct path, and --require-approval=any-change asks 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 optional constructPath (the template's aws:cdk:path, no schema bump), stamped on every deploy save and by cdkd import. cdkd diff --fail-on=destructive and the --require-approval=destructive list 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-change now 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; integ diff-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 destroy and a later successful deploy that did not touch the logical id delete it per its DeletionPolicy without 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 under Retain / Snapshot or a failed delete). Separately, the rollback treated every completed UPDATE whose physical id changed as a replacement, so an in-place SQS QueuePolicy or SNS TopicPolicy update 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's wasReplaced, 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 in tests/integration/rollback-command/.
  • A skipped DELETE during cdkd deploy names 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 on Drop 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 --resource form. 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 --resource on the drop. For a nested stack's AWS::CloudFormation::Stack row, where --resource refuses while the child has a record, the Inspect it with: and Drop 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 during cdkd deploy printed cdkd state orphan <stack>, which drops the record of every resource in the stack. The new repeatable --resource removes only the named entries, rewrites the Ref / Fn::GetAtt / Fn::Sub / dependencies / outputs that other records hold to them as cdkd orphan does (an unresolvable one refuses unless --force), takes the stack's lock (a held lock refuses, --force included) 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 --resource alternative) only on cdkd destroy / cdkd state destroy; the SecurityGroupIngress update refusal always names --resource. cdkd orphan --force and state orphan --resource --force no 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 in tests/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 rollback and cdkd destroy acted 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 journaled DeletionPolicy (Retain keeps it, Snapshot snapshots 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 exits 2 (--allow-unaddressed exits 0); 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 in tests/integration/rollback-command/.
  • A custom-resource delete skipped during cdkd deploy no longer tells the user to run cdkd 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 answered FAILED, the invoke did not complete, or the recorded ServiceToken was 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 with cdkd state orphan <stack> --stack-region <region>. That is safe on cdkd 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 on cdkd destroy / cdkd state destroy. Everywhere else it says to fix the handler (or restore ServiceToken in state.json) and re-deploy, which re-attempts the delete, that --allow-unaddressed lets 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 run cdkd state orphan on a stack that is still deployed. The error for a recorded ServiceToken that is not a string no longer offers cdkd state orphan on 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::QueuePolicy naming several queues is now cleared from every queue it wrote on destroy, and from a queue an update drops from Queues (issue #4594) -- src/provisioning/providers/sqs-queue-policy-provider.ts. cdkd destroy cleared 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 a cdkd:WrittenQueues attribute, which delete() clears and update() 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 rollback and cdkd destroy now 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 only cdkd rollback --revert-failed acted on it: the automatic rollback left the resource in AWS, a plain cdkd rollback popped the entry, and cdkd destroy swept 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 plain cdkd rollback replays it as --revert-failed does (other failed operations still need the flag); and cdkd destroy lists 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 journaled DeletionPolicy (Retain keeps it, Snapshot snapshots it) and the #1710 ownership checks; a failed delete keeps the record (the full segment, or the state and journal on destroy). --no-rollback deletes 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 in tests/integration/rollback-command/.
  • cdkd rollback --revert-failed now 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 id delete() takes (markCreatedBeforeFailure), so the failed CREATE is journaled with it and --revert-failed deletes 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::Policy instead removes the policy from the principals it had already written and warns with the commands when that fails; AWS::SNS::TopicPolicy and AWS::SQS::QueuePolicy mark only the topics or queues they had written; an AWS::ElasticLoadBalancingV2::Listener create, and an AWS::ECS::Service create 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::DirectoryBucket resolves its ARN before CreateBucket. 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-on and cdkd 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|destructive picks which change exits 1; --fail / --no-fail alias any-change / never, combining either with --fail-on or repeating it is refused, default never. destructive fails only on a replacement (will / may be replaced), deletion or orphaning (Retain / RetainExceptOnCreate removal), always walks nested stacks, and lists them; --json carries destructiveChanges per stack. cdkd deploy --require-approval=never|any-change|destructive (also requireApproval in cdk.json, default never) 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-timeout paused meanwhile. CDK's broadening is not offered (no security diff); cdk.json's broadening is 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; integ diff-fail-on-destructive.
  • cdkd diff, import, scrub, export, destroy and drift --revert mask 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 with deploy), 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 deploy already 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, scrub and export printed that name, or an ARN or URL embedding it, on the resolver's resolved to line for a Ref / Fn::GetAtt to it, or as an Fn::Base64 encoding or Fn::Split piece of one; cdkd diff also printed it in a reader's rendered rows. cdkd destroy printed it on the resource's own delete lines, on a deleted reader's lines (an access key's UserName, an instance profile's Roles, a role detaching a policy whose path came from a secret) and on the final-snapshot lines; cdkd drift --revert printed 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.md now says an event's physicalId field 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; integ secret-derived-immutable-names gains a cdkd diff --verbose step 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 a NoEcho parameter (resolved this deploy, or still recorded as a {{resolve: reference after a rotation), a Ref / Fn::GetAtt to it resolves to a name or ARN no masker recognised. cdkd deploy --verbose printed it on the resolver's resolved to line, 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's secretDerivedNamePairs) are now masked in those lines. The resource's own lines mask them too, from its create onward, including Deleting old <id> and was 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's physicalId field 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-names gains a queue policy and a role that read the secret-named queue and policy.
  • cdkd deploy no longer hands a resource record's torn properties map or missing physicalId to 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 whose properties is absent, null or 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 to provider.readCurrentState. For a record whose physicalId is 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 with skipped (state record has no physical id) and keeps the record, as cdkd destroy does; before, the provider was called with that value, and a *NotFound answer 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 calling provider.update (or a replacement's delete) with it. The auto-refresh and the Fn::GetAtt attribute heal (shared with cdkd diff) no longer read such a record. An unchanged record is not refused. A custom resource handler answering a whitespace-only PhysicalResourceId now 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 drift stops 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 new notCompared cause readAborted (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-resource readFailed it 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 --all run are still read. Tests: tests/unit/cli/drift-per-resource-failure.test.ts, tests/unit/cli/drift-outcome.test-d.ts.
  • cdkd local resolves 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-state Fn::ImportValue / Fn::GetStackOutput of a secret-bearing output, which cdkd persists redacted to its token (#1899). local invoke, local start-api, local invoke-agentcore and local run-task now resolve it with the developer's credentials (--profile honoured, never the --role-arn role: 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 the docker run argv and every log line; an --env-vars override skips the lookup; a failed lookup fails the command before any container starts, naming the reference and the IAM permission. local run-task refuses a token in Command / EntryPoint / a health-check command. The engine commands (start-agentcore, start-cloudfront, start-service, start-alb) get the same behaviour from the cdk-local bump. 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; integ local-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 every Fn::GetAtt [Child, Outputs.X] (and ${Child.Outputs.X}) as written, so replacing the resource behind X compared *** 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 and cdkd diff agree: a passed parameter takes the class the parent gives its value, an unpassed one its Default, and a NoEcho parameter, 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 in tests/integration/nested-stack-3level/.
  • cdkd rollback --revert-failed warns 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 no cdkd import mark on its segment (an import by a cdkd older than #4547), was planned as failed 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 new skip-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 a ROLLBACK_RESOURCE_SKIPPED event, and counts a warning, so the run exits 2, like a completed op's skip-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, a Ref to 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 unchanged maskedPropertyFingerprints text hash, which an older cdkd keeps reading. A NoEcho parameter, 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 the Default; the next classified deploy re-sends it once) stay in template form; a new NoEcho value is therefore still not sent through the mask. A record without the input field is re-baselined from today's inputs without sending. cdkd diff labels 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 in tests/integration/secrets-dynamic-ref/ and Step 4d in tests/integration/nested-stack-3level/.
  • A Scheduler Schedule created or imported from this version on, whose GroupName comes from a secret, can be updated in place and destroyed; a rotated secret can no longer make --replace or 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 (attribute cdkd: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 --replace fallback; 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 with cdkd 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: under UpdateReplacePolicy: Retain the 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 in tests/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 in tests/unit/provisioning/cloud-control-provider-create-delete-id-masking.test.ts, and the secret-derived-immutable-names integration fixture now asserts its deploy and destroy logs. A Cloud Control identifier can carry a name taken from a secret (AWS::Logs::MetricFilter is <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; now create() 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 its physicalId field, 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' physicalId field, import()'s two unreadable-model WARNs and other providers' lines stay open under #3869.
  • cdkd destroy / cdkd state destroy no longer hand a resource record's torn properties map or missing physicalId to the provider's delete (issue #3211, destroy half) -- src/cli/commands/destroy-runner.ts, src/state/malformed-resources-bag.ts. A record whose properties is absent, null, a string, a list, a number or a boolean is now refused before the lock, naming each such resource and ending on a cdkd state show command; nothing is deleted or written for the stack. Before, the value reached provider.delete as it stood, where it decides the RDS final snapshot and whether an ECR repository is emptied first. A record whose physicalId is absent, empty, whitespace-only or not a string (other than a nested stack's, whose delete never reads it) is now skipped per resource with skipped (state record has no physical id), its record kept and state preserved. Before, the provider was called with that value, and a resulting *NotFound read 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 --fail help text says the same. docs/import.md documents the population, why state.json is sensitive by construction, and how to remove such a value: rotate it through a reference and deploy, or edit the record by hand, then run cdkd 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 --all and cdkd destroy --purge-events now purge every earlier version under the stack's deployments/ 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.md and 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 a VersionId and the output was scoped to "the deleted keys". Because --all empties 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 prune needs --stack-region, and its "No deployment-event history found" error now says so. --stack-region is 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-than and the writer's self-bounding prune are unchanged and still purge only the keys they delete. Without s3:ListBucketVersions / s3:DeleteObjectVersion it still succeeds and warns.
  • cdkd scrub purges the earlier versions of every state.json and exports index it rewrites, and --purge-history purges every examined record's (issue #2624) -- src/cli/commands/scrub.ts, docs/cli-scrub.md. On a versioned state bucket, scrub's PutObject used to leave the pre-scrub body readable by VersionId. A real run now purges the noncurrent versions of each state.json it 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.json once per region whose entries it rewrote. The purge reuses the state backend's fail-soft purgeNoncurrentVersions (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. The Done: 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-history flag 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 plain cdkd deploy never 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 by cdkd 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 deploy and 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.ts and the rollback delete arms, docs/deployment-events.md. A delete whose guard could not answer (for example an AWS::S3::Bucket routed through Cloud Control whose s3:GetBucketLocation is denied) recorded a RESOURCE_GUARD_INDETERMINATE event only under cdkd 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 and operation: DELETE even 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 no result changes. Tests: tests/unit/deployment/deploy-engine-delete-guard-events.test.ts, tests/unit/deployment/rollback-executor-delete-guard-events.test.ts.
  • cdkd destroy now 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-#804 GetFunction pre-check used to answer ResourceNotFoundException by dropping the record and letting cdkd destroy exit 0. Its usual caller was the second destroy after a handler refused its Delete (#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 stack DELETE_FAILED until you retry with RetainResources. 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 with cdkd state orphan <stack> --stack-region <region>. A cdkd deploy delete (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: one GetFunction call, no 10-minute waiter. Docs: docs/cli-destroy.md, docs/troubleshooting.md. Tests: tests/unit/provisioning/custom-resource-delete-failed-response.test.ts and its CR siblings; integ phase 4 of tests/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 ambiguous CreateVpc, CreateSubnet, CreateInternetGateway or AllocateAddress, 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 a describe-* 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 with InvalidSubnet.Conflict / InvalidGroup.Duplicate rather 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 by cdkd state refresh-observed and cdkd 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 public String parameter from a SecureString, 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 plain ssm one 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 drift gets it from its own resolution of the record. cdkd state refresh-observed asks ssm:GetParameter with WithDecryption: false in 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 in tests/integration/secrets-dynamic-ref/.
  • cdkd rollback --revert-failed now 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-failed skipped 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 with physicalIdRecoveredFromError and the template's DeletionPolicy, and --revert-failed deletes, 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.physicalId is 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-numeric RetentionPeriodHours or a KMS StreamEncryption without a KeyId before CreateStream, 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 in tests/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, so cdkd drift Parent printed the parent's AWS::CloudFormation::Stack row as drift unknown and 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, as cdkd diff and cdkd destroy act on nested stacks. The auto-select counts a parent with its nested stacks as one stack. The nested row is now skipped when its record is checked, and deleted (exit 1, printed as RECORD MISSING naming the record, nestedStackRecordMissing: true in --json) when that record is gone from state; a parent holding only nested stacks prints no drift detected here rather than NOTHING was compared; --accept / --revert refuse it by name and act on each nested stack's drifted resources under that record's lock. --json change: the row moves from notSupported to skipped, or to deleted. Tests: tests/unit/cli/drift.test.ts; integ arm in tests/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 as Fn::Sub 'jdbc:mysql://${AWS::Region}.${AWS::AccountId}.host/?pw=${Child.Outputs.Pw}', the same through Fn::Join, or an Fn::If selecting 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 or cdkd drift --revert would re-resolve and push to AWS as the wrong secret version. No plaintext was exposed. The resolver now records where each Fn::ImportValue / Fn::GetStackOutput / Fn::GetAtt read (and each ${Res.Attr} placeholder) landed on an Fn::Sub / Fn::Join output, 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 parameter Ref. 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 arm MultiUnknownSecretPair in tests/integration/nested-stack-secret/.
  • cdkd rollback no longer deletes a resource cdkd import adopted 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 additive importedResources field on every segment that holds an operation of that logical id, and marks the id superseded on the newest segment. cdkd rollback and 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 as adopted 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 a ROLLBACK_RESOURCE_SKIPPED event, warns and makes the rollback exit 2. Completed operations of an id passed to --orphan are 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 in tests/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 its aws-cdk-lib / constructs peers optional and loads @aws-cdk/toolkit-lib on first synth instead of at module load. A clean install of the packed cdkd shrinks from 453 MB to 254 MB of node_modules, and cdkd --help takes 510 ms of CPU instead of 659 ms (Node 24, median of 9, compile cache off). A CDK app still resolves aws-cdk-lib from 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 calls module.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-cache or ~/.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=1 turns it off and NODE_COMPILE_CACHE moves it (CLI Reference, "Startup compile cache"). The --version fast 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: graphql and adm-zip load 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 AppSync GraphQLSchema drift read, adm-zip for a CodeCommit repository seeded from Code.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, so cdkd --help, synth and list evaluated all of them. The classes now load through loadProviderClasses() (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 every src/*.ts file (sourcemapExcludeSources), and JSDoc blocks are dropped from the emitted JS. The emitted code is token-for-token unchanged; line numbers in dist/ shift where JSDoc was removed, while the maps keep their mappings, so node --enable-source-maps still reports the same src/*.ts lines.
  • A --revert-failed rollback that re-creates an AWS::EC2::SecurityGroupIngress rule no longer keeps the revoked rule's sgr- 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 under attributes.Id for cdkd export and Fn::GetAtt <rule>.Id to 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 --revert records 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 narrowed effectiveProperties delta, so a provider that re-creates on update left the record naming what it removed: reverting a drifted AWS::EC2::SecurityGroupIngress revokes and re-authorizes the rule under a new sgr- id, while the record kept the revoked one for cdkd export and a later Fn::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. A NoEcho attribute 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 in tests/integration/drift-revert-vpc/verify.sh.
  • An edit around a secret reference inside one Fn::Base64 (the EC2 UserData shape) is now deployed, and cdkd diff reports 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 carries maskedPropertyFingerprints (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 diff labels 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 in tests/integration/secrets-dynamic-ref/.
  • cdkd drift reads an Elastic IP back, so one released outside cdkd reports deleted and exits 1 (issue #4447) -- src/provisioning/providers/ec2-provider.ts, src/analyzer/drift-cc-api-deny-list.ts, docs/cli-drift.md. AWS::EC2::EIP had no arm in EC2Provider.readCurrentState, so it read as drift unknown (exit 0) whether it existed or not. It is now read with DescribeAddresses: InvalidAllocationID.NotFound / InvalidAddress.NotFound report the address deleted, and Domain, a declared InstanceId, NetworkBorderGroup, PublicIpv4Pool and Tags are read back, so a change to one the baseline holds reports as drift. A deploy now also captures an EIP's drift baseline. A nested AWS::CloudFormation::Stack row still reads as drift 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 standalone AWS::EC2::SecurityGroupIngress whose Description was edited out of band, or a same-account SG-to-SG rule (the template omits the peer owner AWS reports), read as drift unknown because 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 and drift --revert can 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 an AWS::IAM::Policy and 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 as Rollback: put back the inline policy <logicalId> records on its <role|group|user> and recorded as a ROLLBACK_RESOURCE_SUCCEEDED event 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 or cdkd drift <stack> --revert. Tests: tests/unit/deployment/rollback-executor-inline-policy-claims.test.ts; integ arm Phase 2c-rollback in tests/integration/iam-role-policies-drift-clean/ now includes the delete-beside-create pair.
  • cdkd diff no 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 a SecureString keyed by the token text alone. So once one stack's region answered SecureString, another stack whose region holds a plain String under 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. The dynamic-ref-cross-region integ 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 in cdkd 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. cutMarker is now the one spelling of the marker. Tests: tests/unit/utils/display-safe.test.ts and 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 --replace on 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 every CreateTable AlreadyExistsException so the engine never treated it as a name collision (#3750). That kept --replace from 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-level Name alone): 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: --replace deletes the old table first only when it holds the address the create sent, reading the name as the provider does (TableInput.Name, else Name) and placing it by DatabaseName and, 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 a CatalogId given 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) in tests/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, the GetResource failure line, the attribute-enrichment lines, the model-parse failure lines and the account-lookup WARN of accountInfoForSynthesizedArn) and a new unit file. A Cloud Control identifier can carry a name taken from a secret (AWS::Logs::MetricFilter is <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 on cdkd deploy, its rollback revert, and cdkd 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's create() / 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 or properties baseline, 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, so cdkd events showed a finished rollback naming nothing left unreverted. Each such op now records a ROLLBACK_RESOURCE_SKIPPED event 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 its ROLLBACK_RESOURCE_SUCCEEDED event a reason. A create refused with NAMED_CREATE_COLLISION (or any create refused before anything was applied that recorded no physical id) is no longer journaled as a failed op, so cdkd rollback --revert-failed no 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 in tests/integration/{rollback-command,s3-lifecycle,sg-circular-dependency}/verify.sh.
  • --from-cfn-stack on cdkd local invoke / start-api / run-task / invoke-agentcore now reads the deployed stack with your own credentials instead of the --role-arn role, and the four engine start-* 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-arn and no profile, the --from-cfn-stack reader 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 --profile already 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-agentcore run 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-arn or CDKD_ROLE_ARN is set without the --profile flag. Tests: tests/unit/cli/local-state-source-caller-identity.test.ts.
  • cdkd deploy no longer reports success for a create-only property it never wrote once --prefer-sdk-route is 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 optional acceptedCreateOnlyDrops field (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-api applies 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 --replace to replace the resource and apply it, and the --prefer-sdk-route entries that keep dropping it. A template that removes such a recorded key no longer forces a replacement. cdkd diff previews 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) in tests/integration/sdk-to-cc-autoroute/verify.sh.
  • cdkd destroy --purge-events and cdkd events prune now 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 under deployments/), 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 and docs/state-management.md. Before, every delete there sent DeleteObjects with no VersionId. 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-soft purgeNoncurrentVersions over the deleted keys, in a finally. The writer's self-bounding prune is included, because a stream it drops is no longer listed and a later --all could not reach it, so a deploy past 20 runs can print the purge's IAM or replication warning too. Without s3:ListBucketVersions / s3:DeleteObjectVersion the command still succeeds, and a warning prints before the Pruned / Purged line, 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 an index.json that 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 as Fn::Sub 'jdbc:...password=${Child.Outputs.Pw}', or an Fn::Join / bound Fn::Sub variable over an Fn::ImportValue / Fn::GetStackOutput / nested-stack Fn::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 or cdkd drift --revert would 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 arm EmbedSecretPair in tests/integration/nested-stack-secret/.
  • cdkd scrub no longer reports a stack clean over a {{resolve:...}} reference an Fn::Sub placeholder 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 as Fn::Sub '{{resolve:secretsmanager:${Typo}-db:SecretString:password}}', where Typo names no resource or parameter of the template, keeps its ${Typo} with a keeping placeholder warning, so the reference is never looked up and nothing throws. Scrub recorded nothing for it and printed No plaintext secrets found, and --dry-run --fail exited 0, over a record that could still hold the plaintext an older deploy wrote. resolveSub now remembers such a placeholder and the dynamic-reference pass reports it as a placeholder unit in the abandoned-resolution bag once the assembled string (that Fn::Sub, or an enclosing Fn::Join / Fn::Sub) puts it inside a reference; scrub counts it as an abandoned scan: the stack is not printed clean and --fail exits 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-empty Default when 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 a Default-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 deploy is unchanged, since it collects no abandoned units. Tests: intrinsic-sub-kept-placeholder-report, scrub-cross-region-secret, scrub-all-refusal-boundary; integ dynamic-ref-cross-region phases 3g / 3h.
  • cdkd drift --revert keeps 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 that cdkd drift discards 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 in tests/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. CreateApiKey mints 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 as CreateGraphqlApi; 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 needs appsync: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, CodeCommit CreateRepository / 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 with ParentCommitIdRequiredException. 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. PublishLayerVersion and CreateEventSourceMapping mint 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, then delete-layer-version to 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 needs lambda: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's Updated: 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-level Updated: total and its of which left an orphaned predecessor row, although Updated: 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, and RunCounts.updated in cdkd events matches the console. The exit code and RunCounts.skipped still count them. docs/cli-deploy-safety.md no 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-task and cdkd local start-api --watch no 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-task recorded a container only after its docker run returned, so a ^C during that run (or during the network + metadata sidecar start, or a docker volume create) removed what it knew and exited, and the run then left its container behind. start-api --watch disposed 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-api waits 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 -f or docker network rm), since a docker command 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 in tests/integration/local-run-task/verify.sh and tests/integration/local-start-api/verify.sh.
  • cdkd deploy no longer fails when two AWS::EC2::SecurityGroupIngress resources 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 a DatabaseProxy on the same security group plus an explicit addIngressRule(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 — a SecurityGroupIngress record 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 different sgr- 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 export refuses 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 --watch no 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 runs docker run for the new one. A ^C after the removal reached a cleanup that knew no container id, so it exited while the docker run was still starting a container nothing then removed. Once the cleanup begins, a rebuild now starts no container, and a container whose docker run was already in flight is removed before the process exits, with a line saying the ^C is waiting for that docker run. The wait is bounded at 15 s; past it, cdkd removes the container by its name if it exists, exits, and warns with the docker rm -f <name> to run in case a late docker run still starts it. Tests: tests/unit/cli/local-invoke-agentcore-stdout-stream.test.ts.
  • cdkd local invoke, local invoke-agentcore and local run-task no 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 SIGTERMed docker logs -f (after a fixed 250 ms on invoke) and then removed the container, so any line the daemon had not yet relayed was lost: under host load the handler's console.log output and the RIE's END / REPORT lines went missing while the invoke reported success. invoke / invoke-agentcore now run docker kill, wait (up to 5 s) for docker logs -f to end on its own, and only then write the response to stdout and remove the container, so every log line precedes the response. run-task waits 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-task removes its --profile credentials file as soon as the containers are stopped, before the drain. invoke-agentcore --watch keeps 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 diff reuses the condition verdict the last deploy recorded, so an Fn::If on 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. An Fn::If on one took its FALSE branch, so a stack whose last deploy took the TRUE branch diffed as a perpetual UPDATE, and cdkd diff --fail exited 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 both Fn::If and 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 outside Fn::And / Or / Not / Equals over 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 --all selects top-level stacks only (issue #4474) -- src/utils/glob-match.ts, src/cli/stack-matcher.ts, src/synthesis/assembly-reader.ts, and src/cli/commands/deploy.ts, destroy.ts and diff.ts, with unit tests beside each. A pattern without / used to match only the physical CloudFormation name, so cdkd deploy Api missed new Stack(app, 'Api', { stackName: 'prod-api' }), and * crossed /. Every pattern now matches the stack's hierarchical id (its CDK display path), as cdk does: * 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. --all changed scope: on deploy, destroy and diff it selects the top-level stacks only, as cdk deploy --all / cdk destroy --all do; the run names the Stage stacks it left out with the 'MyStage/*' / '**' patterns, and a stage-only app is refused. publish-assets --all and scrub --all still cover every stack. Re-audit scripts that pass physical names or globs to cdkd 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::If selecting AWS::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 on tests/integration/conditions-and-if/ (Phase 2b). The diff resolves desired properties one top-level key at a time, so the resolver returned the bare AWS::NoValue symbol for such a key instead of dropping it, and the symbol compared unequal to the absent recorded property: cdkd diff --fail exited 1 on an unchanged stack and every cdkd deploy planned 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: an AWS::Lambda::Function whose DurableConfig such a branch now removes is planned as a REPLACEMENT (it was an in-place UPDATE that kept the live durable config), one with no recorded DurableConfig no 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 diff evaluates Conditions per condition, so one secret-fed or unbindable parameter no longer turns every Fn::If in the stack FALSE (issue #4470) -- src/cli/commands/diff-recursive.ts. The diff used to skip the WHOLE Conditions section when any template parameter failed to bind or any one condition read a parameter fed a secret {{resolve:...}} reference, so every Fn::If resolved to its FALSE branch, even on a condition that reads no parameter at all: a condition-true property diffed as a perpetual UPDATE and cdkd diff --fail exited 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. An Fn::If on 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: an AWS::IAM::ManagedPolicy (Roles / Users / Groups), an AWS::IAM::InstanceProfile's role, a user's Groups membership, a UserToGroupAddition's members, and an inline AWS::IAM::Policy / UserPolicy / GroupPolicy on 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 with LimitExceeded, and an IAM user's update no longer re-sends an unchanged LoginProfile, 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's AWS::CloudFormation::Stack row diffed only TemplateURL / Parameters, so with no template change it was NO_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 key cdkd: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 diff shows 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::If selecting an Fn::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 on tests/integration/nested-stack-secret/ (HandoffSpans / HandoffSpansIf, their own spans JSON key). The #2320 arm aligned the template against the resolved leaf, so it refused a leaf like postgres:// + {Ref: User} + : + {Ref: A} + @ + an attribute, and never read an Fn::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 parameter Ref landed on an Fn::Sub / Fn::Join output, through nested parts and an Fn::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-selected Fn::If such as CDK's Fn.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 (ResourceUpdateNotSupportedError under --replace, or Cloud Control's UnsupportedAction), 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 stayed NO_CHANGE and 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 each NO_CHANGE child 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's BucketPolicy, a function's Permission). 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.json beside state.json, folds a nonce into those tokens. It is deleted before the state record (failing the command if it cannot be), and rotated by cdkd orphan and 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.ts and 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::SecurityGroupIngress keeps its new sgr- 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 export and a later Fn::GetAtt <rule>.Id read 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. A NoEcho declaration 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::Pipe has 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 mutable SourceParameters member 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 calls UpdatePipe, 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 recorded provisionedBy: cc-api by an earlier cdkd returns to the SDK provider on its next in-place update under the same name (a cc-broken sticky exemption). Removing TargetParameters or EnrichmentParameters from the template is reported, not applied. Tests: tests/unit/provisioning/providers/pipes-provider.test.ts; integ: cc-write-only-create-only (a BatchSize change on a cc-api-seeded record), eventbridge-pipes.
  • A SecurityGroupIngress duplicate 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, a cdkd rollback that failed on another operation, an unsettled nested-stack segment, or a cdkd destroy that failed partway. If someone then added the identical rule, the next deploy adopted it and cdkd destroy revoked 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-dependency phase 1e.
  • cdkd drift reports a resource deleted outside cdkd as deleted and exits 1, instead of as an uncovered type with exit 0 (issue #4283) -- src/types/resource.ts (new RESOURCE_NOT_FOUND sentinel), 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 under src/provisioning/providers/, docs/cli-{drift,state,reference}.md, the drift-revert and ecs-fargate integ fixtures. A resource AWS reports gone read as drift unknown ("provider does not support drift detection yet") with exit 0, so a CI gate on cdkd drift stayed green over a missing cluster, bucket or role. A read now returns RESOURCE_NOT_FOUND on AWS's own not-found answer (a not-found error name, an empty describe list for the id, or a terminal status such as ECS INACTIVE); access-denied, throttled or message-only answers never do. Drift reports such a resource under a new deleted outcome: a - <id> (<type>) — DELETED outside cdkd line in the drift block and exit 1. --json shape change: such a resource moves from notSupported to a new deleted array. Each stack is now read through its own region's clients, so --stack-region / --all cannot misread another region's resources as deleted. --revert and --accept refuse 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-observed names 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 with GetNamespace, so s3tables:GetNamespace is 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 recorded Name and the last segment matches the recorded Type. An id that does not match is read as CloudFormation's own physical id, the record name, as before. Ref returns the whole record name. Drift reads, renames and cdkd import find the record under Route 53's \174 spelling 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.ts and src/synthesis/failed-stages.ts (stageLoadError), with the selection-time handling removed from src/cli/stack-matcher.ts and src/cli/commands/deploy.ts, destroy.ts, diff.ts, publish-assets.ts, scrub.ts, import.ts, export.ts, orphan.ts, list.ts and synth.ts; unit tests in tests/unit/synthesis/assembly-stage-refusal.test.ts and beside each command, and tests/integration/stage-assets Phases 2b and 2g. A Stage whose own manifest.json could 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 destroy no longer falls back to state for an exact name in that case, and its error names cdkd state destroy '<stack>', which needs no app. cdkd scrub refuses with exit 2 (SCRUB_STAGE_LOAD_FAILED), replacing the SCRUB_ALL_PARTIAL_APP / SCRUB_AUTO_PICK_PARTIAL_APP codes. The exported SynthesisResult.failedStages and AssemblyContents.failedStages fields are removed. Separately, cdkd destroy now 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 diff no longer reports a change on every run for an Fn::Base64 over a secret reference, so cdkd diff --fail can 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 plain ssm one to a SecureString) 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 EC2 UserData shape) always showed as changed, and the deploy then skipped it. The diff now compares such an Fn::Base64 as ***, the value the deploy stores, unless its input also holds a NoEcho value 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 same Fn::Base64 is 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 on tests/integration/nested-stack-secret/ (HandoffMixed, its own mixed JSON key). The resource's secret bag holds one expression per plaintext -- whichever Ref resolved 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 an Fn::Sub / Fn::Join over the child's parameters (a placeholder a 2-arg Fn::Sub map binds to a Ref, and a nested Fn::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 deploy counts 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's Skipped (not deleted) / of which left an orphaned predecessor rows stayed at 0, RunCounts.skipped was unset, the stack reported Deployment completed successfully, and the run exited 0 (the #1960 exit code). The child's deleteSkipped / updatePartial now 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-unaddressed opts 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 and AWS_ACCOUNT_ID is 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 and cdkd import. A failed sts:GetCallerIdentity used to answer 123456789012 with only a warning, so Ref: AWS::AccountId and AWS::StackId served it silently and a custom resource's StackId carried 000000000000. Now they refuse with Cannot determine the AWS account id: ..., withholding AWS-authored message text from the STS failure, naming the aws sts get-caller-identity check and the AWS_ACCOUNT_ID escape hatch, which must be 12 digits. AWS::Region, AWS::Partition, Fn::GetAZs and any Fn::GetAtt whose 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 import leaves 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 FSx ClientRequestToken, EFS CreationToken and OAI CallerReference came 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 its cdkd destroy then 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 response Date), 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 FSx FileSystemId or 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: an AWS::Cognito::ManagedLoginBranding (ClientId is write-only and create-only), a Kinesis- / DynamoDB- / Kafka- / MQ-source AWS::Pipes::Pipe (write-only SourceParameters holding a create-only StartingPosition / topic / queue), an AWS::CodePipeline::CustomActionType (create-only Settings / ConfigurationProperties holding 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's SourceParameters is still re-added. A CHANGED one still fails: Cloud Control cannot express that update at all (a Kinesis-source pipe's BatchSize, 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 the AWS::Lambda::Permission reading 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 Lambda Permission, Version, Alias or EventInvokeConfig, an SNS Subscription, TopicPolicy or TopicInlinePolicy, an SQS QueuePolicy or QueueInlinePolicy, an S3 BucketPolicy, a Logs LogStream, MetricFilter or SubscriptionFilter, an IAM RolePolicy or role-attached Policy -- 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.ts marks 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, SNS CreateTopic, EventBridge PutRule, which overwrites). When a later step of the same create failed (a load balancer's active wait 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, GetTopicAttributes on the ARN built from STS for SNS, DescribeRule on 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-protection now 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 as provisionedBy: cc-api is 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's DeletionProtection and its instances' DisableApiTermination off 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-provider target'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 (an AWS::EC2::SecurityGroup, an AWS::ApiGateway::RestApi), a Ref / Fn::GetAtt reader 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-run include 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-recreation is 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 deploy now 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, disagreeing oneOf / anyOf arms, legacy types whose schema CloudFormation does not validate against (no handlers), unknown types and paths, and every intrinsic (Ref, Fn::*) pass. The per-path table is generated offline from the committed schema fixtures (new propertyShapes section, 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::SecurityGroupIngress create 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. When AuthorizeSecurityGroupIngress answered InvalidPermission.Duplicate, the create treated it as success and recorded the existing rule as the stack's, so cdkd destroy later 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 existing sgr- 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 and cdkd rollback --revert-failed never 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 recorded sgr- 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-dependency phase 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 old Path) 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 through withDerivedNameMasks, 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 EADDRNOTAVAIL is now re-polled instead of abandoning the wait (issue #4331) -- src/provisioning/cloud-control-provider.ts, src/deployment/retryable-errors.ts. EADDRNOTAVAIL is 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 and cdkd destroy (observed with an EKS cluster). The code now joins the other socket codes, matched on the error's code or, 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 its read form 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's VpcLatticeConfigurations[].AdvancedConfiguration now reaches AWS instead of being silently dropped (PR #4324) -- The ECS SDK provider forwards VpcLatticeConfigurations through a generic key converter, and @aws-sdk/client-ecs 3.1140.0's VpcLatticeConfiguration had no advancedConfiguration member, 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-protection puts 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 worded not 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-timeout still 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, while update() 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's FilterName that 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 fixture secret-derived-immutable-names gains 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 while update() receives the resolved value, so an ACM certificate whose DomainName, SubjectAlternativeNames, DomainValidationOptions, CertificateAuthorityArn or KeyAlgorithm came from a secret was REPLACED on every update (a new certificate requested and the old one deleted), and a MicroVM image whose Name did was refused on every update. Both guards now use unchangedBehindSecretReference'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). An AWS::IAM::Policy rename 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 own Policies took, 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 an AWS::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 a Policies entry. cdkd rollback shares 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 deploy refuses an Export.Name holding any NoEcho: true parameter'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 the Export.Name of 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 LATER Export.Name recorded (an Fn::Base64 encoding), and a name reaching this stack's value through an attribute were published as keys of state.outputs, exportNames and the exports index, in plaintext. Now the verdict is seeded with every NoEcho value the stack holds (on a nested child, each parent NoEcho value a child parameter carries), and every Export.Name is resolved before any alias is decided. A Number counts as the operator wrote it too (0x1F2A), and a refusal caused only by a NoEcho value 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 short NoEcho value is now refused too, the same bound secrets already take. cdkd diff previews the same verdict. Still published: another stack's value through Fn::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 a Ref (which cdkd diff refuses), 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 (new isReplayedNameCollisionFrom), src/provisioning/auxiliary-failure.ts (RETRY_AUXILIARY_OWNER, isAuxiliaryMarkOf), src/deployment/retry.ts. Since #3978, a collision withRetry raises 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 or cdkd destroy removes 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, then cdkd import it 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 import resolves either by its template Name (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 in tests/integration/replacement-rename-onto-holder/. CreateLoadBalancer and CreateTargetGroup return 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 later cdkd destroy to delete; a plain create with an explicit Name adopted 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-derived Name is masked in the spelling the provider sends. Before, the provider's import() reported every ELBv2 load balancer and target group without a --resource override as not found, though docs/import.md listed both as resolved by their template name; cdkd import looks up the unprefixed name, and a lookup that fails is a failed row.
  • --all is 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.ts and publish-assets.ts, with unit tests in tests/unit/cli/stack-matcher.test.ts, deploy-unaddressed-exit.test.ts, destroy.test.ts, diff-failed-stage-selection.test.ts and publish-assets.test.ts. A Stage whose manifest could not be read drops every stack under it from the synthesized app, so cdkd deploy --all, cdkd destroy --all, cdkd diff --all and cdkd publish-assets --all acted 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 --all when 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.ts and orphan.ts, with unit tests beside each command's existing failed-Stage cases and the bare arm of tests/integration/stage-assets Phase 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 bare cdkd deploy (or destroy, 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 --fail then reported the app clean and exited 0. Each now refuses before any stack's state is read and before any lock or write, with With no stack named, cdkd would <verb> only part of this app; refusing. Synthesized: <stacks>, the failed-Stage note, and the way through -- the sentence --all gained in #4387. A bare destroy whose surviving stack had no state record printed No stacks found in state and exited 0; it refuses the same way, before the state bucket is listed. scrub exits 2 (SCRUB_AUTO_PICK_PARTIAL_APP), including a bare run where no stack synthesized at all (which exited 1, the code --fail uses for plaintext found); the rest exit 1. cdkd orphan has 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 export names a CDK Stage that failed to load when stack selection comes back empty (issue #3507) -- src/cli/commands/export.ts, with unit tests in tests/unit/cli/export-stack-selection.test.ts. A stack under a Stage whose manifest could not be read is dropped from the synthesized app, so cdkd export MyStage/MyStack answered Stack 'MyStage/MyStack' not found in synthesized app, and an app whose only stacks sit under such a Stage answered Multiple stacks found: . with no argument. export now raises the message deploy, diff, list, publish-assets, scrub, import and synth print: No stacks matching <arg> found in assembly. Available: <stacks> (or The assembly has no stacks, or No stacks found in assembly with no argument), plus the failed-Stage note naming the targeted Stage or listing every failed Stage behind Possibly unrelated:. The Multiple stacks found list 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 --all is refused when a CDK Stage failed to load beside surviving stacks (issue #3507) -- src/cli/commands/scrub.ts, through the shared renderAllWithFailedStages in src/cli/stack-matcher.ts, with unit tests in tests/unit/cli/commands/scrub-stack-selection.test.ts and the scrub arm of tests/integration/stage-assets Phase 2g. A Stage whose manifest could not be read drops every stack under it from the synthesized app, so cdkd scrub --all scrubbed 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 refusal deploy, destroy, diff and publish-assets gained in #4387. --all in 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 --all when every Stage loaded.
  • AWS::DynamoDB::Table refuses a PointInTimeRecoverySpecification.RecoveryPeriodInDays CloudFormation rejects, before any AWS call (issue #3255) -- src/provisioning/providers/dynamodb-table-provider.ts. A period spelled " 7 ", "0x9", "1e1", null or an unresolved intrinsic used to be dropped with a warning: UpdateContinuousBackups then succeeded at DynamoDB's default period while cdkd state kept the declared value, so cdkd drift reported a difference on every run and cdkd drift --revert re-sent the same call forever. create() and update() 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-object PointInTimeRecoverySpecification, which was read as "PITR disabled", is refused the same way. The AWS CDK CLI cannot deploy these templates either. A rollback replay or drift --revert keeps 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-provider now recreate a target whose template did not change (issue #2651) -- new src/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 (a Condition now false), or one it has no row for, is not recreated and is warned about by name. Also documents (issue #2666) that --force-stateful-recreation reaches 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; the cc-to-sdk-reroute integ gains an unchanged-template recreate phase.
  • The deploy engine's stateful-replacement refusals now say when --force-stateful-recreation cannot 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 of src/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 --replace on one arm) even when cdkd's record of the resource carries a deletion / termination protection flag. cdkd deploy has 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" in docs/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 the cdkd destroy confirm prompt counts with, and a target under UpdateReplacePolicy: Retain gets 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 destroy now records deployment events (issue #2423) -- src/cli/commands/state.ts, src/cli/commands/destroy-runner.ts. cdkd state destroy ran the same destroy as cdkd destroy but opened no event recorder, so it wrote no deployments/{runId}.jsonl at all: its per-resource deletes, skips and suppressed safety checks left nothing for cdkd events to read once the run ended. It now records one run per destroyed stack and region, under command: destroy, with the same RUN_FINISHED result and counts cdkd destroy records (FAILED on a resource error or skip). cdkd state destroy still takes no --purge-events; cdkd events prune '<stack>' --all drops 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; integ s3-lifecycle phase 0c-OK.
  • cdkd destroy --remove-protection restores DisableApiTermination on the EC2 instances an Auto Scaling group launched when the group's delete then fails terminally (issue #2204) -- src/provisioning/providers/asg-provider.ts and the shared settleProtectionFlip in src/provisioning/providers/deletion-protection-compensation.ts, with unit tests in tests/unit/provisioning/providers/asg-instance-remove-protection-compensate.test.ts and a live arm in the remove-protection-compensation integ (the destroy runs under a role denied UpdateAutoScalingGroup on the group, so its prevent-all-deletion refuses 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-flip DescribeInstanceAttribute saw on, and this run turned off, is turned back on before the failure is reported, under the same rules as every other --remove-protection site: 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 pasteable aws ec2 modify-instance-attribute command. The flip now also needs ec2: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's debug / info / warn / error line reached no engine masking, so a {{resolve:...}} secret (or a NoEcho parameter value) it interpolated printed in plaintext unless that one call site threaded the masker, and about 256 such sites had not. ConsoleLogger now 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), and delete() 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 from apigateway-orphan-report.ts, now shared), docs/troubleshooting.md. RunJobFlow, AddInstanceFleet and AddInstanceGroups mint 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 needs elasticmapreduce: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 only src/provisioning/providers; it now defaults to src/. 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: normalizeAwsError now chains the original SDK error under the message it rewrites, so an S3 Unknown HTTP 503 on the state bucket reads as transient and keeps its request id; and the cdkd local ECR 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 export imports an HttpApi's AWS::ApiGatewayV2::Stage in place instead of deleting it and letting CloudFormation re-create it (issue #1772) -- src/cli/commands/export.ts, docs/cli-export.md, the export integ fixture and the export unit tests. The stage sat in IMPORT_UNSUPPORTED_RECREATABLE_TYPES because CloudFormation once refused it in IMPORT changesets, so every export of a stack with an HttpApi (CDK auto-emits a $default stage) 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 type FULLY_MUTABLE with a read handler 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 joins COMPOSITE_ID_SPLITTERS: the bare StageName the SDK provider records is paired with the recorded ApiId, and Cloud Control's <apiId>|<stageName> is split. Neither field is read-only, so the whole identifier map is overlaid. --no-recreate-import-unsupported no longer applies to the stage, and the by-hand recovery after a failed pre-delete no longer prints aws apigatewayv2 delete-stage.
  • A multi-replica AWS::DynamoDB::GlobalTable whose template declares no StreamSpecification now 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()'s needsStream arm, update()'s stream arm and its end, a new canonicalizeDesiredProperties), a new unit file and the dynamodb-globaltable-implied-stream integ fixture. create() enables a NEW_AND_OLD_IMAGES stream where the template is silent, but state recorded none. That was not phantom drift, but declaring the same stream later diffed as an UPDATE, and update() sent StreamEnabled: true for a live stream (a stream change the CloudFormation docs allow only on a single-replica table). Now create() 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 diff NO_CHANGE too. 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, no MultiRegionConsistency declared) 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: removing CertificateTransparencyLoggingPreference from the template now resets it to ENABLED (issue #1160) -- src/provisioning/providers/acm-certificate-provider.ts. Before, the removal was silently dropped: UpdateCertificateOptions keeps an absent option, so a certificate opted out of transparency logging stayed opted out while cdkd diff reported no changes. CloudFormation documents the removal as the same as declaring ENABLED, so the provider now declares that reset through removalDefaults. CertificateExport cannot 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 own update() now does the same instead of sending an Export ACM rejects or dropping the removal. The type is also audited into removalHandledInUpdate, 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. Dropping Auth, RequireTLS, IdleClientTimeout, DebugLogging or VpcSecurityGroupIds from an AWS::RDS::DBProxy reported a successful update while the proxy kept its old value, because ModifyDBProxy keeps 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 empty ModifyDBProxy call. cdkd drift --revert warns 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's StepConcurrencyLevel, or a fleet's ResizeSpecifications or InstanceTypeConfigs, 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 declare removalHandledInUpdate, 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. UpdateFileSystem keeps a field it is not sent, and cdkd sends no reset for these removals. Before, removing StorageCapacity, StorageType, FileSystemTypeVersion, NetworkType or a mutable <Variant>Configuration sub-property (Lustre DataCompressionType / AutoImportPolicy, a Windows FsrmConfiguration, ...) reported a successful update while AWS kept the old value, and when the removal was the only change cdkd sent an UpdateFileSystem carrying 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 declares removalHandledInUpdate, so the shared caller adds no second line). A removed RouteTableIds is still applied as a RemoveRouteTableIds delta, and a removed Tags still 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 declares removalDefaults (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. Lambda RecursiveLoop. drift --revert injects 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). On AWS::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::Stream is not in STICKY_CC_MIGRATION_EXEMPT, so an existing SDK-provisioned resource of that type whose template gains one moves to Cloud Control and pins provisionedBy: 'cc-api' ONE-WAY. The snapshot marks none of them create-only, so createOnlyDrops and 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 aws commands 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 after CreateKey), these warnings name the possibly-orphaned resource and give describe / get / delete commands 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 its get-user-pool-mfa-config command. 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, and pollOperation masks its ErrorMessage before raising Operation failed for <id>: ... (#2063). That error carried no cause, so when a recorded secret spelled part of a retryable phrase (for example does not exist), the mask cut the phrase out and withRetry read the failure as terminal, where the same failure without the secret is retried. The raw ErrorMessage now rides as the unmasked cause and the error goes through wrapMaskedAwsError, 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-after remedies in src/cli/options.ts, src/utils/error-handler.ts and 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-api and --older-than remedies in cdkd drift, orphan, gc, export, the cdkd local commands, synthesis and macro expansion. Bare, --resource-timeout <duration> read stdin from a file named duration and > 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-wait lines, 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 while update() receives the resolved value, so the GraphQLApi Name, DataSource ApiId / Name, Resolver ApiId / TypeName / FieldName, ApiKey ApiId, AutoScalingGroup AutoScalingGroupName and LoadBalancer Name / Type / Scheme guards refused every in-place update, and a managed policy with a secret-derived Path or Description was REPLACED on every update (a create under the same name and path, which IAM refuses). Each guard now uses unchangedBehindSecretReference: the value the physical id carries (group name, load balancer ARN, policy ARN path, AppSync composite id), the GraphQL API's live name from GetGraphqlApi (a failed lookup refuses as a retryable ProvisioningError), or, for Type / 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.ts and 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 while update() 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-derived GroupName stays 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 --resource remedy (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 --resource fragment. 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 drift no longer compares a deleted ECS Cluster, Service or TaskDefinition that ECS still describes as INACTIVE (issue #4272) -- src/provisioning/providers/ecs-provider.ts. ECS keeps describing a deleted resource for a while under a terminal status, and readCurrentState returned its properties whenever the name came back, so cdkd drift compared a resource deleted outside cdkd as if it still existed. The read now finds nothing for an INACTIVE cluster (the rule importCluster already applied), an INACTIVE service, and an INACTIVE or DELETE_IN_PROGRESS task definition revision; every other status (a DRAINING service, a PROVISIONING cluster, 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 definition import() applies the same two statuses, so cdkd import with 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-secure reference 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 in tests/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 in requestParameters. Both services are secret by spelling, so the token is now refused before the lookup, on the template route only. A plain ssm reference is still looked up first, to learn its parameter type. Behaviour change: such a token whose lookup would have failed now reports the masked Refusing to resolve refusal 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 events and 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 in src/provisioning/providers/iam-{policy,user-group}-provider.ts. The DeletionPolicy: Snapshot refusals printed a state record's logical id and type raw beside aws rds create-db-snapshot and --skip-final-snapshot. The "cdkd did not confirm ... was deleted" sentence printed the id, physical id and skip reason beside --replace, --revert-failed or a cdkd deploy remedy. 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 events printed a recorded rollback refusal folded onto one line, joining its provider text to its To 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 their Failed to create/update ... message (#2050, #2063). When a recorded secret spelled part of a retryable phrase (for example does not exist), the mask cut that phrase out and withRetry read the failure as terminal, so the deploy failed where it used to retry. The six ELBv2 wraps (LoadBalancer create, the update() outer wrap, TargetGroup and Listener create/update) and the two log group wraps now go through wrapMaskedAwsError (the #4244 helper), which stamps markRedactedCause when 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.ts and tests/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 their Failed to create/update ... message (#2050, #2063, #2177). When a recorded secret, or a secret-derived Stage name, spelled part of a retryable phrase (for example does not exist), the mask cut that phrase out and withRetry read the failure as terminal, so the deploy failed where it used to retry. Each wrap now goes through wrapMaskedAwsError (the #4244 helper), which stamps markRedactedCause when 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.ts and the issue #4259 block in tests/unit/provisioning/apigateway-provider-masked-log.test.ts.
  • A backing Lambda that never becomes ready no longer puts its GetFunction response 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 into Lambda backing custom resource ... did not reach a ready state for Invoke: ..., and @smithy/util-waiter keys its observedResponses by the serialized response whenever one carries no HTTP status, or by the raw body on a deserialization failure: the function's Environment.Variables in plaintext and its presigned Code.Location could reach the default-verbosity error line and the persisted deployments/*.jsonl store. 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 fatal util.inspect prints 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's State* / LastUpdateStatus* fields, <status>: OK keys, and each rejected poll as describeAwsFailure summarizes it (403: AccessDeniedException in the error, AWS's account / role / session sentence at --verbose; a transport failure such as socket hang up keeps 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 their Failed 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 and withRetry read the failure as terminal, so the deploy failed where it used to retry. Each wrap now goes through the new wrapMaskedAwsError, which stamps markRedactedCause when 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: the issue #4244 blocks in tests/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.ts and tests/unit/deployment/retryable-errors.test.ts.
  • Lines that put cdkd's own -> before a template-, state- or AWS-chosen value now say from <a> to <b>, then, resolved to or with 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, a Type, a Kinesis StreamMode, a LogGroupClass) could pick a file to truncate. The DAG edge lines, the DAG and ECS DependsOn cycle refusals (which also no longer join two cycles with a ;), the diff's UPDATE (Type change) and requires replacement lines, the local start-api route table, the nested-template cycle refusal, the --no-prefix-user-supplied-names rename list, state migrate's prompt and summary, the AgentCore fromS3 bucket and soft-reload lines, ACM's DNS validation row, the Kinesis update lines, the LogGroupClass refusal (which now prints a class that is not a plain identifier as a class value that is not a plain identifier instead of inside cdkd's own quotes, which a ' in the value closed) and the provider rename / replacement lines are reworded; the same spelling in deploy-engine.ts, rollback-executor.ts, rollback.ts, drift.ts, events.ts and the scheduler provider remains. Tests: tests/unit/utils/arrow-paste-sites-4239.test.ts and tests/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 Scheduler GroupName refusal say from <a> to <b> instead of ->, and cdkd rollback describes 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 a reverse-replace / failed-Type-change row, or from the GroupName addresses the schedule refusal, 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 names cdkd deploy --replace. The rollback plan header, the confirmation prompt, the State for ... removed and Rollback of ... complete lines and the nested-stack plan preview printed a journal-key stack name with its interior spaces; in a run whose failed-persist warning prints a Re-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 to cdkd state list --long under 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 import of 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 with file:// or - (for example file://x or -p:1), the refusal withholds the aws 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.ts and the route53, s3-bucket-policy, sns-topic-policy and sqs-queue-policy providers. These lines showed a logical id raw or inside cdkd's own '...', and some also a resource type, a supplied physical id, a template Bucket / Topics / Queues / HostedZoneName value (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 a cdkd command or a --resource fragment. A ', ; or $( ) in the value ran when the line was pasted. Each such value is now shown only when plain and described otherwise (for example a logical id that is not a plain identifier, (not shown: it is not a plain identifier)), a --resource fragment 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 getAttribute errors name the logical id in their logicalId field (issue #4222) -- src/provisioning/providers/scheduler-schedule-provider.ts. The last two ProvisioningErrors from #4222 (an unknown attribute, and a failed GetSchedule lookup of Arn for cdkd orphan) carried the schedule's name, which can be built from a secret, in their logicalId field. They now carry the logical id (the fourth getAttribute argument, #4228), with the schedule name in physicalId. The source fence in tests/unit/provisioning/logical-id-slot-4222.test.ts now admits no exception. maskSecretsInError still masks logicalId. Tests: tests/unit/provisioning/scheduler-schedule-provider.test.ts.
  • cdkd rollback messages no longer show a journal or state value on a line that also names a command (issue #4214) -- src/deployment/rollback-executor.ts and src/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 beside cdkd deploy, cdkd rollback, --orphan, --revert-failed, --role-arn or --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 example a 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 own Collision diagnosis: line, and both collision refusals print the provider's text inside JSON quotes on the Underlying collision: line. The per-op Rollback failed for line, 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 pasteable aws command's template- or state-borne values through (renderDisableCommand and the pasteableAwsCommand tag) admitted a value starting with file:// / 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-wait re-associate hint also withholds an instance profile holding ,, which inside Arn=<value> shorthand starts another key. The EventBridge partial-create cleanup hint passes the listed target ids to --ids as one JSON word instead of through jq | xargs, where a target id starting with - became an option (--region, --profile), and says that a remove-targets error 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, the replacement-rename-onto-holder integ (phases B5 / C5), a new unit file and docs/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 later cdkd destroy deleted it. The create arm now looks the name up through the provider's import() first, as the replacement path has since #3937, and fails with NAMED_CREATE_COLLISION and 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 with cdkd 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-secure reference is no longer persisted in state.json inside that reference (issue #4166) -- src/deployment/intrinsic-function-resolver.ts, src/deployment/secret-redaction.ts, docs/troubleshooting.md; cases in tests/unit/deployment/intrinsic-resolver-secret-assembled-reference.test.ts. A token such as {{resolve:ssm:/app/${Name}}} with Name a 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-retryable Refusing to resolve once 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 ***), and cdkd import warns 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 scrub leaves 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 --verbose Resolved … 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 for Ref, Fn::GetAtt, Fn::FindInMap, Fn::Select, Fn::Split, Fn::Base64, Fn::GetAZs and 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 -> bucket truncated ./bucket). They now read resolved to (normalized to on the legacy Fn::GetAtt attribute line); a script grepping the old spelling needs the new one. The values and names on those lines, and on the Fn::Join, Fn::Sub, Fn::If and AWS::NoValue lines, were bare too, so a template or state value such as x>OWNED still 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 reads a value that cannot be shown safely here. A JSON render (a list, an object, a Fn::Select / Fn::Split result) prints while every string in it is inert, and the Fn::Split delimiter 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 the secrets-dynamic-ref, nested-stack-secret, nested-stack-3level and noecho-parameter-masking integ fixtures.
  • A replacement renaming an ECS cluster onto a name an ACTIVE cluster already holds is refused before anything changes, and cdkd import no 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 cluster import()), docs/cli-deploy-safety.md, unit cases and an ECS arm in tests/integration/replacement-rename-onto-holder/. CreateCluster returns 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 later cdkd destroy to delete. The SDK route now looks the new ClusterName up 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 cluster import(), which returned a cluster DescribeClusters still lists as INACTIVE after 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 than MISSING now 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. The Scrub with: and Then re-run: lines of the SCRUB_CROSS_STACK_PRODUCER_PLAINTEXT refusal 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 to cdkd state list --long. The ResourceTimeoutError message (Resource X (TYPE) ... timed out, printed whole by cdkd 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, including Custom::*, 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. The SCRUB_CROSS_STACK_PRODUCER_PLAINTEXT refusal ends in Scrub with: and Then 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 as Prod<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 export names 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 a cdkd import refused to "deploy a change to each one", which does not clear an unverifiable-parameter refusal, 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 same refusedBaselineRemedy text cdkd state show and cdkd drift print, 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 a NoEcho Custom Resource value and a mask copied from another record as its only causes; it now also names the Fn::Base64 encoding 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-observed and cdkd drift name 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 a cdkd import refused told the user to deploy a change to it. That is false for an unverifiable-parameter refusal, 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. The state show row, the drift report row, the --accept / --revert refusals 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; the refresh-observed warning counts the refusals a deploy may not clear and points at state 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 export no longer prints a template- or CLI-derived name raw, and refuses a record whose properties map 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 the properties refusal are described the same way. A recorded value beside a recovery command (the AWS::EC2::Route refusal and warning, the pre-delete row and failure) is described unless inert, so a $default Stage id reads "(not shown ...)" there; elsewhere a value holding $, | or * ($LATEST) is JSON-quoted. The by-hand phase-2 create-change-set and the next-steps cdk diff / cdk deploy commands go through the shared command gate, so such a value becomes a quoted hole; an aws --stack-name the AWS CLI itself would act on (file://, @=) is a hole too. A properties map that is not an object is refused by name at the load and over every nested child, under --dry-run too. The unreadable-resources refusal 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 / --accept name 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 --revert refusal for a position whose baseline holds only the redaction mask named two causes, a NoEcho custom-resource value and a readback position cdkd could not certify. It now also names the Fn::Base64 encoding of a secret value (issues #2759 / #3119), which no custom resource is behind, and gives each non-NoEcho cause its own remedy: a deploy that changes the resource sends a Fn::Base64 encoding again, and re-captures the baseline for an uncertified position. The --accept refusal of the same mask now scopes its re-capture remedy to the uncertified position, and says that no deploy replaces a mask standing for a NoEcho value or a Fn::Base64 encoding, where it used to suggest that a custom-resource update would. The --accept / --revert plan 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 --accept refusal's remedy no longer spells a backticked cdkd deploy, which a pasted clause of the plan's SKIPPED row ran as a command substitution. The export, rollback and deploy messages that omit the Fn::Base64 cause 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 deploy and cdkd orphan --force name the Fn::Base64 encoding 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 the Fn::Base64 encoding of a {{resolve:...}} secret (EC2 UserData, for example), but the rollback refusal on such a baseline and the deploy refusal on a resource reading it back from state (an AWS::SSM::Parameter's Value attribute, for example) named only a NoEcho Custom Resource and a mask cdkd import or cdkd orphan wrote, 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. The cdkd orphan --force warning 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-events and cdkd events prune --all no longer report removing deployment-event history that never existed (issue #2624) -- src/state/deployment-events-store.ts. A purge always reported the deployments/index.json key as deleted, because S3's DeleteObjects also succeeds for a key that does not exist. As a result, cdkd destroy --purge-events printed Purged deployment-event history for <stack> for a stack with no recorded events, and cdkd events prune <stack> --all --stack-region <region> printed Removed the empty deployment-event index when 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-than prune 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-protection restores 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 in tests/unit/provisioning/providers/ec2-instance-remove-protection-compensate.test.ts and tests/unit/provisioning/cloud-control-remove-protection-compensate.test.ts. Before, the DisableApiTermination flip (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-protection sites follow: only a guard a pre-flip read saw ON (DescribeInstanceAttribute, or a Cloud Control GetResource holding the registry's new onValue) 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 as NotStabilized; cdkd then warns with the check and restore commands), and a failed re-enable is its own log line with a pasteable aws ec2 modify-instance-attribute --disable-api-termination or aws cloudcontrol update-resource command. 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-protection restores termination protection on an EMR cluster whose terminate then failed terminally (issue #2204, partial) -- src/provisioning/providers/emr-cluster-provider.ts, with unit tests in tests/unit/provisioning/providers/emr-cluster-remove-protection-compensate.test.ts. Before, the SetTerminationProtection(false) flip was never undone, so a failed destroy left the cluster live with its guard silently stripped. Now TerminationProtected is turned back on before the failure is reported, under the rules the other --remove-protection sites follow: only a guard the delete's pre-check DescribeCluster saw ON and this run turned off, only on a terminal failure, never once AWS has accepted the TerminateJobFlows, and a failed re-enable is its own log line with a pasteable aws emr modify-cluster-attributes --termination-protected command. 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 in tests/unit/provisioning/apigateway-provider-masked-log.test.ts. create() and update() of AWS::ApiGateway::Account, Authorizer, Resource, Deployment, Stage and Method used 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's RestApiId / ResourceId / HttpMethod and a Resource's old and new PathPart (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 pasteable delete-method command), 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 previous StageName in 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. The delete() 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 in tests/unit/provisioning/apigatewayv2-provider-masked-log.test.ts. create() and update() of AWS::ApiGatewayV2::Api, Stage, Integration, Route and Authorizer used to write their debug and warning lines through a logger no deploy-engine mask reaches, and only the Integration ResponseParameters warning 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), the TlsConfig warning and the ExecuteApiArn warnings. 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 recorded StageName is 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. The delete() 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 in tests/unit/provisioning/dynamodb-globaltable-provider-masked-log.test.ts. create() and update() of AWS::DynamoDB::GlobalTable still printed the table name unmasked on several lines no deploy-engine mask reaches: the CreateTable initiated, ACTIVE-wait status and Successfully created debug lines, and the auto-scaling Upserted auto-scaling policy, Deregistered auto-scaling target and Could not probe existing auto-scaling targets lines. 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-derived TableName is 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 its aws dynamodb update-table command 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). The delete() 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 in tests/unit/provisioning/ecs-provider-masked-log.test.ts. create() and update() of AWS::ECS::Cluster, TaskDefinition and Service used 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 or Cluster value 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 the aws ecs wait services-stable / list-tasks / delete-service commands 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-wait cleanup 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. The delete() 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 in tests/unit/provisioning/lambda-family-providers-masked-log.test.ts. Their create() / 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::Permission and AWS::Lambda::EventInvokeConfig update() 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 in tests/unit/provisioning/lambda-function-provider-masked-log.test.ts. create() / update() of AWS::Lambda::Function logged through a logger no deploy-engine mask reaches; only the RuntimeManagementConfig line was masked. Every line now goes through the deploy's masker: the function name, RecursiveLoop, ReservedConcurrentExecutions and CodeSigningConfigArn are 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 whole GetFunction response 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 Gateway CreateAuthorizer and CreateDeployment, and API Gateway v2 CreateApi, CreateIntegration and CreateAuthorizer, 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 needs apigateway: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, CreateUserPool and CreateGraphqlApi have no idempotency token, so a retry re-ran them from the top. A failed EnableKeyRotation / DisableKey after CreateKey left 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 needs kms:ListKeys + kms:DescribeKey, cognito-idp:ListUserPools, or appsync: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 logicalId field, 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. 18 ProvisioningErrors (the Firehose destination-update failures and the getAttribute failures of the other six providers) carried the resource's physical id, which can be built from a secret, in their logicalId field, 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 in physicalId; getAttribute takes the logical id as a fourth argument, which cdkd orphan passes. An auxiliary-failure mark is now recognised by its read-only shape as well as its /auxiliary suffix, so a physical id ending in /auxiliary is no longer read as one, and withRetry (src/deployment/retry.ts) marks a replayed failure with the fixed owner withRetry instead of its label, so a label holding a table or policy name never becomes the mark maskSecretsInError copies unmasked. The two sites in scheduler-schedule-provider.ts remain. Tests: tests/unit/provisioning/logical-id-slot-4222.test.ts (including a source fence over every construction under src/) and the per-provider suites.
  • cdkd drift on 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, but cdkd drift classified 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 --revert wrote 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's in 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. Now pasteableCommand and the cdkd force-unlock recovery flags use the same measured predicate #4198 introduced for the aws ... remedies, moved to src/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 naming It's Legacy exactly in the cdkd orphan refusals (#3359 / #3523). The same predicate now also gates the cdkd orphan refusals' State for ... prose and legacy Object key: line (a non-inert name is described, and the key holds '<stack>'), the Scheduler's manual delete-schedule hint, and the orphaned-name-collision advice, whose cdkd import moves to its own Adopt with: line. Prose in deploy-engine.ts, asset-storage.ts and export.ts that 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. withDerivedNameMasks replaced 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-db recorded, and prod-db owner hunter2x as 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 reads Description '***' 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. maskSecretsInError masked each link's message and stack and 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's Error: { Type, Code, Message }, or the modeled Message many 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's Fatal 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 a cause that is not an Error. 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 a logicalId that is an auxiliary-failure mark ending in /auxiliary) are copied unchanged; any other logicalId is 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 IAM Policy / UserToGroupAddition delete 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 orphan substitutes a sibling's Fn::GetAtt with the orphan's recorded attribute over a live read's answer (issue #4186) -- src/analyzer/orphan-rewriter.ts (servableRecordedAttribute), with unit cases in tests/unit/analyzer/orphan-rewriter.test.ts and the real-AWS arm tests/integration/orphan-getatt-takeover/. The rewrite took every Fn::GetAtt value from a live provider.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 case cdkd orphan is the documented way out of -- the dependents' state received the newcomer's attribute (another role's RoleId, another repository's KmsKeyId), 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 own Fn::GetAtt resolution 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's Ipv6CidrBlocks. 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::AccessKey SecretAccessKey), an AppSync API key's ApiKey, 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 pasteable aws ... commands even without --profile (issue #4178) -- src/utils/pasteable-aws-profile.ts. Before, cdkd destroy --role-arn <arn> with no --profile printed bare commands such as aws 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-arn or CDKD_ROLE_ARN) prints aws --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 --profile prints exactly what it did before.
  • Every pasteable cdkd ... hint now carries the run's typed --profile, --state-bucket and --state-prefix (issue #4177) -- new src/utils/pasteable-run-context.ts, src/utils/pasteable-command.ts (pasteableCommand appends them), src/cli/program.ts (the preAction hook 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.ts and src/synthesis/macro-expander.ts. Before, a hint such as cdkd state orphan MyStack --stack-region us-east-1 or Retry with: cdkd destroy MyStack, printed after cdkd 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 reads cdkd 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-arn prints --role-arn '<role-arn>', which cdkd refuses until filled in. --state-prefix now refuses a value holding < or >, so a pasted '<prefix>' placeholder fails instead of deploying into an empty key space; cdkd events / cdkd events prune now apply --role-arn, which they declared and silently ignored; the Ctrl-C cdkd force-unlock line and a stack-less cdkd rollback --orphan line 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 --profile and 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-provider confirmation 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.ts and src/deployment/recreate-targets.ts (the target and refusal rows), with paste cases in tests/unit/cli/commands/recreate-downstream-consumers-paste.test.ts and recreate-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 its state.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 now a resource type that is not a plain identifier. No refusal Fix: line carries a <...> hole any more: the old remove … <id> for these resources, pasted, redirected into a file named for.
  • cdkd diff no 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) and src/deployment/read-only-attribute-healer.ts, with cases in tests/unit/deployment/stale-attribute-heal.test.ts. Since #3456, cdkd diff re-reads a state record that lacks an attribute a Fn::GetAtt asks 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 marked readOnly, and the remedy reads "This preview re-read the attributes from AWS, but ...", then says that cdkd deploy issues the same read and records the attribute once the read returns it. That covers the warn line of a non-*Arn physical-id fallback and the refusal for an *Arn / *Url attribute. A not-attempted outcome 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 grant cloudformation:DescribeType to the credentials the preview runs with and run the diff again, not to the deploy role. cdkd deploy's wording is unchanged. Documented in docs/troubleshooting.md; docs/cli-diff.md now 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 every delete() caller: src/cli/commands/destroy-runner.ts, src/deployment/deploy-engine.ts, src/deployment/rollback-executor.ts. An update that did not rename, and delete(), 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, and cdkd destroy deleted it. Both now read the repository first and compare its id with the recorded RepositoryId: a mismatch is refused, non-retryable, with nothing sent to it and the record kept, naming cdkd orphan to 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 denied GetRepository, an unavailable KMS key, an unexpected error) proceeds with the delete, warns and reports a delete-recorded-identity guard. A record from before RepositoryId was 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 a RepositoryName no 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). An AWS::IAM::Policy update 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 own Policies now 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 own Policies. 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 --revert and cdkd destroy remove 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::Policy in place no longer leaves the old-named inline policy on the principals it stays attached to (src/provisioning/providers/iam-policy-provider.ts update(), 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 recorded PolicyName that 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. Two AWS::IAM::Policy resources 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 destroy now delete an AWS::IAM::Policy or AWS::IAM::UserToGroupAddition whose 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.ts delete(), destroy-runner.ts, go-to-k/cdkd#4150). State keeps such a Roles / Groups / Users entry 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 needs iam: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, then cdkd orphan or cdkd 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 its imports / 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 --revert no 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.mode on an ALB, or an attribute AWS released later), --revert read the key as a removal and sent Value: '' or a documented default. AWS rejects '' for an enum or boolean key, which failed the whole Modify*Attributes call, 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 to cdkd drift --accept. cdkd drift still reports such a key, because one read cannot tell a key AWS started returning from a value an operator set. cdkd deploy and rollback still reset an attribute the template removed.
  • cdkd drift no 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.mode on an ALB), cdkd drift reported the whole attribute list as drifted, and --revert sent 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. --revert sends 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 diff no longer previews a literal Export.Name alias 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 spelling secretsmanager / ssm-secure, so it missed a plain-ssm SecureString (which keeps its token through the preview's resolution) and a secretsmanager reference spelled only in an intrinsic Export.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 after function: / 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 as production in 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 Lambda EventInvokeConfig whose FunctionName a 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; integ lambda-event-invoke-config-update phases 2c/2d.
  • AWS::RDS::DBProxyTargetGroup Tags now follow the shared tag-list rules, so a number or boolean tag Value deploys 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 scalar Value that CloudFormation coerces to a string, and on update it could send RemoveTagsFromResource a recorded key that holds a dynamic reference or its mask, which names nothing AWS holds. It now reads both sides through src/provisioning/tag-list.ts, as the sibling DBProxy / DBProxyEndpoint providers do. A scalar Value is sent as a string, Value may 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 scrub now drops a stored output key the template no longer declares, instead of leaving its value beside the references it writes (src/cli/commands/scrub.ts planUnnamedOutputDrop, 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 diff reads 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 a Dropped / Would drop line without its value. --dry-run --fail exits 1 until 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 whose Export.Name this run could not reproduce; and one another stack's recorded Fn::ImportValue / Fn::GetStackOutput still reads, or may read (a record older than schema v4 / v8). The last two are findings (--fail exits 1) while the rest of the record is still scrubbed. A dropped alias also leaves the record's exportNames. 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 with SCRUB_DROPPED_OUTPUT_READERS_UNVERIFIED (exit 2). Tests: tests/unit/cli/commands/scrub-drop-unnamed-output.test.ts, tests/unit/cli/commands/scrub-export-index.test.ts.
  • --recreate-via-cc-api now 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-provider is 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's FunctionName as the same function (a name ↔ ARN change) no longer deletes the async-invoke configuration (src/analyzer/replacement-rules.ts, go-to-k/cdkd#4118). FunctionName was 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. FunctionName now 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 through PutFunctionEventInvokeConfig. Two ARNs must also agree on partition, region and account. Before a name ↔ ARN Put, the update resolves both spellings with GetFunction and refuses, changing nothing, when they are different functions (this uses lambda:GetFunction, which the Lambda function provider already needs). A qualified ARN and an unresolved value still replace, Qualifier still 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, and cdkd drift compares FunctionName by name. Tests: tests/unit/analyzer/replacement-rules-lambda-event-invoke-config.test.ts; integ lambda-event-invoke-config-update phase 2b.
  • A lock.json owner 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, the Locked 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 as x, expired 3h ago made a live lock read as expired and invited a force-unlock. They now go through the same rule cdkd state show uses: the value when it is a plain identifier, a description otherwise. A comma is refused too, and an empty owner reads an unnamed holder everywhere, as the contention refusal already said. A genuine user@host:pid owner 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-repo are validated before any AWS call, but a name read from the marker's body was sent to S3 / ECR as-is, and anyone with s3:PutObject on the state bucket chooses that body. The marker's names are now held to the same rules. cdkd bootstrap warns 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 gc and cdkd bootstrap --destroy refuse it with the malformed-marker error, and never send or delete by the invalid name; cdkd state info skips 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 ResourcePolicy or Tags that 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 its observedProperties baseline. 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 drift then reported StreamSpecification.ResourcePolicy or .Tags as drift until the next deploy. The capture after cdkd's own write now keeps the declared member in that case. A cdkd drift read 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: Snapshot delete 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 an AWS::RDS::DBInstance, AWS::RDS::DBCluster, AWS::DocDB::DBCluster, AWS::Neptune::DBCluster or AWS::ElastiCache::CacheCluster under a Snapshot policy (in cdkd destroy, a rollback or a deploy's delete) printed with 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 reads Deleting <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-protection flip failure, the already-gone skip, the wait-for-deleted status poll) and the Timed out waiting for ... to be deleted error. The snapshot is still taken under the same identifier; docs/cli-destroy.md spells out how the identifier is derived from the physical id, so the snapshot can be found by its prefix. The --remove-protection guard 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 in tests/unit/provisioning/composite-id-logical-id-paste.test.ts. Both message heads printed the logical id unquoted. On cdkd destroy it is a state.json resource key, which nothing validates, so whoever can write the state bucket chose it: with X$(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 as a logical id that is not a plain identifier. The resource type in the refusal head is held to the CloudFormation type shape, so AWS::Glue::Table and Custom::MyThing print unchanged.
  • cdkd diff --recursive resolves 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's Parameters were resolved only against the values handed to the parent (none at the top-level stack). A Ref to a parent template parameter bound from its Default or an SSM-typed lookup stayed unresolved, and an Fn::If in the row took its false branch, so the child showed a phantom UPDATE on every run and --fail never exited 0. The row now resolves against the parent's bound parameters and evaluated conditions, as cdkd deploy does. 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; the nested-stack-3level fixture gains a Default-bound root parameter and restores its MidPinIf row.
  • An AWS::IAM::Policy or AWS::IAM::UserToGroupAddition whose 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.ts withUnchangedSecretPrincipalLists, go-to-k/cdkd#4064). State keeps a secret-derived Roles / Groups / Users entry 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 when GroupName / PolicyName changed, is itself secret-derived, or, for PolicyName, 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 NoEcho parameter's value no longer prints: an Fn::Split of 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::Split over a NoEcho value printed each piece on the --verbose Resolved Fn::Split line, and a resource consuming a piece had it in the clear in its provider's warn and retry lines, the deploy's error, the deployments/*.jsonl event and the cdkd diff row. A nested child declaring a CommaDelimitedList (or other list) parameter fed the parent's NoEcho string 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 piece cdkd diff shows 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. An Fn::Split by an empty delimiter prints its pieces as *** on its own line, but an Fn::Join over them still prints the characters. Nothing cdkd persists changes: state.json is byte-identical with or without NoEcho. One publication verdict reads these pieces too: an Export.Name equal 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 deploy refuses an Export.Name that equals or embeds a NoEcho: true parameter'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 of state.outputs, exportNames and 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's Fn::ImportValue on that name stops resolving, a deliberate CloudFormation-parity divergence. The value counts when an output value, or the Export.Name of the same or an earlier-declared output, reads it through a Ref or Fn::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 diff previews the same verdict, so the refused alias is no phantom addition. Still published: a value only a resource or a later Export.Name reads, one reaching the name through an attribute, an import or an Fn::Select fragment, 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 of state.json and of the exports index keep a previously published value; rotate it.
  • An Export.Name spelling 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 in tests/unit/deployment/secret-detection-space-4001.test.ts. A name like pre-hunter2 followed by a full-width pass reads as the recorded secret hunter2pass, but no form the containment test used held it, so the name was written into state.json and the exports index, and cdkd scrub --dry-run --fail called 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 diff and cdkd scrub alike. 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) -- new src/utils/pasteable-aws-profile.ts, src/cli/program.ts (the preAction hook records --profile), src/provisioning/replacement-protection-advice.ts (pasteableAwsCommand and renderDisableCommand insert it after every aws command word), and the hand-built commands in cloud-control-provider.ts, scheduler-schedule-provider.ts, efs-provider.ts, dynamodb-globaltable-provider.ts, state/s3-replication-purge-gap.ts, cli/commands/import.ts and cli/commands/export.ts. Before, a remediation command such as the --remove-protection compensation's aws rds modify-db-cluster ... --deletion-protection line never named a profile, so after cdkd destroy --profile prod a 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 reads aws --profile prod rds modify-db-cluster .... Only an explicit --profile is rendered; an inherited AWS_PROFILE is 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_ARN prints '<role-profile>', since the base profile's principal is not the one cdkd ran as. The dynamodb-globaltable-provider.ts cleanup, live-only-index and auto-scaling commands now go through pasteableAwsCommand, 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 by pasteableAwsCommand and renderDisableCommand, and its new PASTE_ARG_UNSAFE set) and src/provisioning/providers/dynamodb-globaltable-provider.ts, with paste cases in replacement-remedy-preconditions.test.ts, logs-loggroup-provider-class-guard.test.ts and pasteable-aws-command.test.ts under tests/unit/provisioning/. These commands are printed inside markdown backticks (protectedReplacementAdvice, the LogGroupClass refusal) and in sentences carrying cdkd'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 on PASTE_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#blue or an IAM name with = or , keeps its command. Under zsh's EXTENDED_GLOB a mid-word #, ^ or ~ becomes a glob operator, but cdkd always shell-quotes those characters, and a cdkd's quote 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 own Delete={RegionName=...} text in the command, single-quoted around the region, so it is still printed for every table and, in a normal paste, zsh's BRACE_CCL cannot 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.ts and src/provisioning/composite-id.ts. These lines showed a stack name, logical id, resource type or ARN beside a cdkd / aws command or a --flag, and shell in the value ran when the line was pasted: a ; under bash on the cdkd import lines, 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 example a stack name that is not a plain identifier in cdkd rollback and state destroy, (logical id not shown: it is not a plain identifier) in cdkd import's composite-id refusals, a resource whose logical id is not a plain CloudFormation logical id in a rollback replay's refusals). The lock-contention refusal and the SSM ARN-adopt refusal withhold their cdkd force-unlock / aws ssm get-parameter command for such a value. cdkd import's Failed to import line 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 own Underlying collision: line. cdkd events and 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 new tests/integration/replacement-rename-onto-holder/ fixture. The UPDATE-not-supported fallback and the --recreate-via-cc-api / --recreate-via-sdk-provider recreate 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 with NAMED_REPLACEMENT_COLLISION and 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 later cdkd destroy to delete. On the SDK route the new name (a rule's new bus, or a Type change 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 (--replace after a dropped name) was closed by #4028's holder proof.
  • The malformed-record refusals from cdkd destroy, cdkd import, cdkd orphan, cdkd rollback and cdkd state refresh-observed now print commands that include the run's --profile, --state-bucket and 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.ts and tests/unit/state/malformed-account-flags.test.ts. Before this change, cdkd destroy --state-bucket X refused an unreadable record and then pointed at cdkd state list --json and cdkd state show ... with no flags. Pasted as printed, those commands read the default profile's bucket, which does not hold the record. Now the cdkd state show line and the cdkd state orphan template include the flags. When the refusal cannot name the record, the listing is printed on its own Find 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 for cdkd orphan's drop, listing and inspect lines too, which used to quote such a value. A run with no --profile or non-default --state-prefix still carries the resolved --state-bucket on every printed command.
  • Refusals that send you to cdkd state list --json for 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) and src/cli/commands/export.ts, with cases in tests/unit/cli/state-list.test.ts and the message suites. The shared pointer said the listing "writes each name raw", and every message filling a quoted identity hole from it -- the cdkd destroy withhold arms, the other refusals and warnings in that module, and cdkd 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 --json output path: a padded name survives beside its trimmed sibling, and an escaped name decodes back to the stored one. docs/cli-destroy.md now says that --json is the withhold arm's pointer while the exact arm keeps --long, that the region printed is the key's (#3328), and to leave --stack-region out for a record listed with "region": null. docs/cli-diff.md now says the properties refusal comes when the deploy computes its diff, not when it loads the record.
  • cdkd diff now previews a Fn::GetAtt over a state record that lacks the attribute (or holds a pre-#1681 placeholder ARN) as the value cdkd deploy resolves (issue #3456) -- src/cli/commands/diff.ts, src/cli/commands/diff-recursive.ts, new src/deployment/read-only-attribute-healer.ts, src/deployment/stale-attribute-heal.ts, and new unit tests tests/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 deploy re-reads such a record from AWS through its provider's import() (#1852); cdkd diff did not, so it previewed the raw intrinsic or the physical id and could print No changes detected for an update the deploy then made -- measured live on an AWS::Lambda::Url imported with --migrate-from-cloudformation, whose dependent AWS::SSM::Parameter the 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's Parameters). 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's ClientSecret) is now printed by the diff for such a record, in its rows, --json and --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 in docs/cli-diff.md.
  • cdkd export refuses a stack record whose resources map it cannot read by name, root or nested child, instead of failing on a bare TypeError or reporting every resource as not deployed (issue #3188) -- src/cli/commands/export.ts (exportCommand at the state load, runPerStackImportLoop over every tree node before its pre-flight), with cases in tests/unit/cli/export-malformed-child-bag.test.ts. A record whose resources was a string, a number, a boolean, a list, null or absent was refused only by accident: buildImportPlan read every template row against it, so a null or absent bag threw TypeError: 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 shared STATE_RESOURCES_MALFORMED refusal (the one cdkd import, cdkd orphan and cdkd rollback raise, 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-run too. A readable record is unaffected.
  • cdkd destroy --remove-protection restores 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 in tests/unit/provisioning/providers/elbv2-asg-remove-protection-compensate.test.ts and a live arm in the remove-protection integ (step 4b: an out-of-band VPC endpoint service makes an NLB's DeleteLoadBalancer refuse). 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-flip DescribeLoadBalancerAttributes / DescribeAutoScalingGroups saw 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-deletion or prevent-all-deletion). The other --remove-protection types 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 in tests/unit/provisioning/efs-provider-masked-log.test.ts. create() and update() of AWS::EFS::FileSystem, AWS::EFS::MountTarget and AWS::EFS::AccessPoint used 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 BackupPolicy Status and ReplicationOverwriteProtection values (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. The delete() 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 in tests/unit/provisioning/secretsmanager-secret-provider-masked-log.test.ts. An AWS::SecretsManager::Secret whose Name was 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() and update() 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. On update() the recorded ARN is masked as well when the previous Name 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. This covers message text only: the error's physicalId field and the engine's own lines still carry the raw name or ARN (issue #3869), and the delete() path still has no masker (issue #2007).

Recently Implemented (2026-09-29):

  • A bootstrap-marker name, or a lock.json owner / operation in cdkd 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" by displayIdent returning the value unchanged, and a value that IS displayIdent'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's Lock: row described nothing, so an owner such as x (operation: deploy), expired 3h ago made 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 through displayIdent, so a real name reads as before. The name-collision refusal now reads asks for a name (AlarmName) that is not a plain identifier instead of a 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 diff no longer prints a deleted output's SecureString plaintext when the stored record also holds a correctly redacted secretsmanager reference (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 a secretsmanager / ssm-secure expression proves only a write after the GHSA fix, and a cdkd before issue #1901 stored a SecureString parameter's plaintext beside it. Only a stored plain ssm reference, which only a cdkd after #1901 writes, now exonerates the record, and cdkd 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 is secretsmanager, 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 EventInvokeConfig recorded provisionedBy: '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 empty DestinationConfig.OnSuccess / OnFailure objects that its own model then rejects, so every update of a resource pinned there (a record from before the SDK provider existed) failed with required key [Destination] not found. The type is now a cc-broken sticky exemption: such a record moves to the SDK provider on its next deploy with its physical id (<FunctionName>|<Qualifier>) unchanged. cdkd drift on such a record no longer reports the empty DestinationConfig.OnSuccess / OnFailure members 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: integ lambda-event-invoke-config-update (seeded cc-api record, in-place flip, Cloud Control identifier parity).
  • An AWS::RDS::DBProxyTargetGroup no longer ends up on Cloud Control, whose delete always fails for the type, and its Tags are 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 derive DBProxyName from the TargetGroupArn identifier and sends it null (go-to-k/cdkd#385, re-measured 2026-09-29), yet the type was not in STICKY_CC_MIGRATION_EXEMPT, so a key the SDK provider did not write (such as the Tags AWS just published) routed the resource through Cloud Control, recorded provisionedBy: 'cc-api', and made the next cdkd destroy fail on it. The type is now a cc-broken exemption: a record already pinned to cc-api returns to the SDK provider, destroy always runs there, and an unrecognized key keeps the SDK route with a warning. Tags is now handled: create tags the target group with AddTagsToResource after registering its targets (deregistering them again if tagging fails), update applies the tag difference with RemoveTagsFromResource / AddTagsToResource, and cdkd drift reads them with ListTagsForResource. A malformed Tags list 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; integ rds-aurora.
  • cdkd drift no 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 no observedProperties compares its template name (my-role), while a stack deployed with --prefix-user-supplied-names holds MyStack-my-role on AWS, so an IAM Role / User / Group / InstanceProfile / ManagedPolicy or an ELBv2 LoadBalancer / TargetGroup reported its name as drifted on every run and cdkd drift exited 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; integ drift-revert step 6f.
  • An Fn::GetAtt cdkd cannot build is now refused instead of resolving to nothing, and an Output whose Value evaluates to AWS::NoValue is refused before provisioning (issue #4077) -- src/deployment/intrinsic-function-resolver.ts (refuseUnconstructibleAttribute, refuseUndefinedAttribute) and src/deployment/output-value-preflight.ts, wired in DeployEngine, with unit tests in tests/unit/deployment/output-value-preflight.test.ts, intrinsic-functions.test.ts, intrinsic-heal-before-constructing.test.ts and deploy-engine-stale-attribute-heal.test.ts. DynamoDB StreamArn and IAM Role RoleId (when the state record lacks them and the re-read does not supply them), a Cloud Map DNS namespace HostedZoneId whose GetNamespace read fails or reports none, and AWS::IAM::Policy PolicyId / AWS::ServiceDiscovery::HttpNamespace HostedZoneId (attributes CloudFormation does not define) used to resolve to undefined. A resource property then dropped the value silently, an Fn::Join or Fn::Sub sent the text undefined to 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 to AWS::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 Tags no longer untags a CloudWatch Logs log group or a Glue resource (issue #4073) -- src/provisioning/providers/logs-loggroup-provider.ts, glue-provider.ts, now reading Tags through src/provisioning/tag-list.ts as #3994 did for the other providers. The AWS::Logs::LogGroup update untagged every recorded key and re-tagged the raw desired value on any Tags difference, so a malformed desired Tags (reachable on a rollback or cdkd 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 :*. The AWS::Glue::Job / Crawler / Trigger diffs read a non-object Tags as none and dropped or coerced malformed entries, then untagged every recorded key the short desired side lacked; a null list entry threw a TypeError. For every Glue type that declares Tags (a key -> value map; a {Key, Value} list is read too), and for the log group, a malformed desired Tags is refused before any AWS call on create and update, naming the property but never its content; a malformed recorded Tags is applied ADD-only with a warning; a recorded key holding a dynamic reference or its mask is never untagged. Separately, AWS::Glue::Workflow now applies a Tags change 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 its Tags change again: run cdkd drift --revert to 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-protection no 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 in tests/unit/provisioning/providers/cognito-remove-protection-rmw.test.ts. Before, the flip-off and the #2204 re-enable sent UpdateUserPool with DeletionProtection alone, and UpdateUserPool resets members a call omits: measured live, LambdaConfig triggers, UserPoolAddOns, AutoVerifiedAttributes, VerificationMessageTemplate, DeviceConfiguration and AllowAdminCreateUserOnly (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-protection could not delete it. Both writes now send back, from the pool's own DescribeUserPool, the settings a call would reset (and nothing measured to survive omission, except a DEVELOPER SES 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-protection on a user pool now needs cognito-idp:DescribeUserPool. A throttle or server error on the read or the flip retries the whole delete before anything is deleted.
  • cdkd diff no longer previews an export alias cdkd deploy refuses when an intrinsic Export.Name carries a plain {{resolve:ssm:...}} to a SecureString parameter, 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 a secretsmanager: / ssm-secure: spelling, but its skipDynamicReferences pass also keeps a plain ssm token for a SecureString, so the alias the deploy refuses was an [export] ADD on every run and cdkd diff --fail never 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 a SecureString's plaintext is withheld instead of printed as a MODIFY row's old: 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 by cdkd deploy, rollback, scrub and export'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 to force-unlock a 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 a lock.json whose 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 says No lock could be read after the last failed attempt and asks for a re-run, keeping the force-unlock command for a repeat. Tests: tests/unit/state/lock-acquire-released-race.test.ts.
  • cdkd diff no longer prints a NoEcho template parameter's value (issue #4049) -- src/cli/commands/diff-recursive.ts, and unit cases. Before, the diff resolved a Ref to a NoEcho parameter to its plaintext and printed it in a property row's old: / new: lines, in --json's propertyChanges and outputChanges, in an Outputs row, and on --verbose's requires 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: every NoEcho parameter's value, what the diff resolver records while it resolves (for example an Fn::Base64 of 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. A NoEcho parameter fed a SECRET {{resolve:...}} reference still prints as that expression, as every secret reference does in cdkd diff. The limits are listed in docs/cli-diff.md. The change is still reported, so --fail is unchanged, and nothing is written: cdkd diff persists nothing, and the state record it reads is left byte-identical. Tests: tests/unit/cli/diff-noecho-render-4049.test.ts.
  • A NoEcho template parameter's value no longer prints in cdkd deploy --verbose's requires 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 a Ref to a NoEcho parameter to its plaintext and printed it on the replacement line of a create-only property, and an Export.Name built from one printed it in the collision warning. The diff calculator now takes a printing masker over the diff pass's own bag, every NoEcho parameter'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 and cdkd scrub collision warnings, and cdkd 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.json is byte-identical with or without NoEcho, and whether an Export.Name embedding 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 drift and cdkd list --json output no longer carries terminal-active characters raw (src/cli/commands/{events,diff,drift,list}.ts, go-to-k/cdkd#4045). These payloads called JSON.stringify directly, which escapes only C0, ", \ and lone surrogates, so a stored event reason, run field, state property value, drift value or stack name reached the terminal with DEL, the C1 range (U+009B is a live CSI), U+2028 / U+2029, the bidi controls and the zero-width characters verbatim. They now go through stringifyJsonPayload, like cdkd state since go-to-k/cdkd#3163: every control, format, line-separator and paragraph-separator character is written as a \uXXXX escape, the output parses back to exactly the same values, and a plain payload is byte-identical. cdkd list --json values 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 the update() calls in src/deployment/deploy-engine.ts, src/deployment/rollback-executor.ts (both revert arms) and src/cli/commands/drift.ts (--revert), with unit tests in tests/unit/provisioning/providers/codecommit-repository-provider-rename-probe-4042.test.ts and each caller's suite. When the rename call found the recorded repository gone, cdkd treated whatever repository held the desired RepositoryName as its own and sent it the description, key, tag and trigger writes, recording it as this resource so that a later cdkd destroy deleted it. Every update() 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 the RepositoryId cdkd 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 no RepositoryId is refused too, with the cdkd import ... --force command 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.com sent as MyStack-alice-example-com, in Rollback: 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, the UpdateReplacePolicy: Retain re-adopt line and survivor warning, a DeletionPolicy: Snapshot rollback'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 / ...Identifier property (plus an allow-listed name spelled otherwise, such as ElastiCache's ReplicationGroupId or an ECS Family), is secret-derived and the names a rewriting provider derives from it under both prefix settings. An event's physicalId field 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 into cdkd state show (src/cli/commands/export.ts reportDriftBaselineGaps, go-to-k/cdkd#4036). When the command it prints carried a quoted '<stack>' hole, the sentence before it said to fill the hole from cdkd state list --json whatever the name was, so a record named --all or --state-bucket=x led to a pasted command whose positional Commander parsed as an option. A stack name beginning with -, keyed on the raw - as inspectClause is (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 withheld cdkd state refresh-observed sentence 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 an AWS::RDS::DBInstance without DBClusterIdentifier, that declares no DeletionPolicy now 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 default DeletionPolicy is Snapshot for these two shapes and Delete for everything else. cdkd used to treat a missing policy as Delete everywhere, so destroying an L1 CfnDBCluster or standalone CfnDBInstance without 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 explicit Snapshot. Pass --skip-final-snapshot to 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 L2 DatabaseCluster / DatabaseInstance always write a DeletionPolicy, and a cluster-member instance keeps the plain delete, so neither changes. UpdateReplacePolicy defaults to Delete for every type, so replacement deletes are unchanged. The recorded policy is not rewritten, so cdkd diff shows no change. The rds-dbinstance-backfill and rds-aurora integration fixtures now assert the snapshot of their policy-less L1 instance and cluster, then delete it.
  • A Cloud Control-routed AWS::Neptune::DBCluster, and an AWS::RDS::DBCluster in 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 the DeletionPolicy, 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 with SkipFinalSnapshot, including one with no DeletionPolicy, whose CloudFormation default is Delete. An RDS cluster in a global cluster is now detached from it with RemoveFromGlobalCluster, 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 recorded GlobalClusterIdentifier is not a plain name. The new cc-final-snapshot-handlers fixture fails if any of the three types leaves a snapshot.
  • A replacement or rollback delete of a Cloud Control-routed AWS::RDS::DBCluster or AWS::RDS::DBInstance no 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 is Delete (#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 a rds-snapshot-<random> snapshot that nothing recorded. Each of these deletes now passes its governing policy: UpdateReplacePolicy for the replacement deletes, whose CloudFormation default is Delete for every type, and the DeletionPolicy the rollback classified for a rolled-back creation. The rds-full-stack integration 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::CacheCluster no 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 with Timed out waiting for CacheCluster ... to be deleted, and every retry failed again because AWS refuses a second delete of a cluster that is already deleting. The wait now uses the same 60-minute limit as the Cloud Control route, and a cluster that is already deleting is waited on instead of failing. Found by the new cc-final-snapshot-handlers integration fixture.
  • cdkd deploy warns 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-names stack 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 the rollback-explicit-name-rewrite integ 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 in tests/unit/deployment/rollback-recreate-original-name.test.ts and a new phase in the rollback-explicit-name-rewrite integ. cdkd rollback and 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 (a instead 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 --revert no 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 inside update() 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 (live MyStack-my-role, template my-role) a revert created a new role my-role and 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 drifted Path (or policy Description), 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; integ drift-revert step 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 in tests/unit/provisioning/asg-provider-metrics-all-4021.test.ts and an arm in the drift-revert-vpc integration fixture. A MetricsCollection entry that lists no metrics (CDK GroupMetrics.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 and cdkd drift reported 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 --revert re-enables them by enabling ALL (no metric list) and disables nothing. On create and update, a MetricsCollection entry 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 rollback re-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 in tests/unit/cli/commands/rollback-skip-prefix-4018.test.ts and a new rollback-explicit-name-rewrite integ. cdkd deploy derives the name of an explicitly named IAM Role / User / Group / InstanceProfile / ManagedPolicy or ELBv2 LoadBalancer / TargetGroup under its user-supplied-name prefix setting, but cdkd rollback replayed 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, no journalVersion bump; 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 to CDKD_PREFIX_USER_SUPPLIED_NAMES / cdk.json / the default (no prefix) with a warning. Follow-ups: #4023 (drift --revert scope), #4024 (a flag flipped between deploys).
  • cdkd diff no longer prints a recorded secret held in a stored Outputs key (issue #4015) -- src/cli/commands/diff-recursive.ts (renderOutputChangeLines, diffTreeToJson) and src/analyzer/outputs-diff.ts (computeOutputsDiff, secretSpanInStoredKey), with unit tests in tests/unit/cli/diff-output-key-secret-4015.test.ts. A binary from before issue #1919 could publish an export alias whose Export.Name resolved 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 --json name, now prints only the verdict of the secret test cdkd scrub applies to such keys: the name as is, masked (app-***), or <name withheld: contains a secret>; --json adds nameRedacted: true when 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-bearing Export.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 lists exportNames, 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 a secretsmanager / ssm-secure spelling. A stale alias sharing a secret-bearing name's literal ends is masked too.
  • An Auto Scaling group with several GroupMetrics now updates and drift-checks all of them (issue #4013) -- src/provisioning/providers/asg-provider.ts (foldMetricsCollection, the MetricsCollection update diff, canonicalizeDriftProperties), with unit tests in tests/unit/provisioning/asg-provider-metrics-granularity-4013.test.ts and an update + drift arm in the launchtemplate-asg-inplace integration fixture. CDK renders one MetricsCollection entry per GroupMetrics, all at 1Minute, 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 in cdkd drift however 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), findResolvedSecretPaths in src/provisioning/custom-resource-secure-references.ts, unit cases, a new custom-resource-resolved-secret integ fixture, docs/troubleshooting.md and .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 / update now 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-failed replays a failed update's attempted properties as OldResourceProperties); the error names property paths only. A NoEcho value 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 plain ssm reference, 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's cdkd state orphan remedy withholds a stack name or region that is not a plain identifier (issue #3997) -- src/cli/commands/export.ts, with unit tests in tests/unit/cli/export.test.ts and export-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 the cdkd state and cdkd destroy remedies 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 ServiceToken recorded as a {{resolve:...}} dynamic reference names a region-scoped cdkd state orphan too (issue #3996) -- src/provisioning/providers/custom-resource-provider.ts, with the pin in tests/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 with cdkd state orphan <stack>, which without --stack-region removes that stack name's record in every region. It now reads cdkd state orphan <stack> --stack-region <region> and says the command drops every record for the stack in that region.
  • Every cdkd state orphan remedy 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 in tests/unit/provisioning/* and tests/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 with cdkd state orphan <stack>: without --stack-region that 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-resource cdkd orphan <StackPath>/<Path/To/Bucket> instead. cdkd import's unresolved-intrinsic warning now names the per-resource cdkd orphan <StackPath>/<Path/To/Resource> instead. The three custom-resource ServiceToken refusals 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 in tests/unit/provisioning/asg-provider-create-metrics-notifications-3995.test.ts and a first-deploy assertion in the launchtemplate-asg-inplace integration fixture. MetricsCollection and NotificationConfigurations are not CreateAutoScalingGroup members, and cdkd sent their dedicated APIs only from the update path, so a CDK group with groupMetrics or notifications was created without either, silently, until some later change triggered an update. create() now sends one EnableMetricsCollection per MetricsCollection entry and one PutNotificationConfiguration per NotificationConfigurations entry 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-set DeletionProtection), 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 Tags value no longer untags every key a resource holds (issue #3994) -- src/provisioning/tag-list.ts (new), read by most SDK providers that diff Tags. Each Tags diff read a present-but-malformed value as empty or short (a string walked by character, a non-list read as {}, an entry without Key or Value dropped), and several untagged every recorded key before re-tagging, so on a rollback or cdkd drift --revert, which replay a recorded bag, a malformed Tags stripped the resource's tags. undefined / null still reads as no tags; anything else that is not a list of {Key: non-empty string, Value: scalar} (a number or boolean Value reads as its string, as CloudFormation coerces it; an omitted one as '' on AWS::RDS::DBProxy / DBProxyEndpoint; for AWS::SSM::Parameter, a map of keys to scalars) is malformed. A malformed desired Tags, 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, null or object Value is no longer coerced to '' (except an omitted one on DBProxy / DBProxyEndpoint). A malformed recorded Tags is 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::SubnetGroup now sends its Tags on create and on update applies the missing ones, untagging only recorded keys it holds, and AWS::ServiceDiscovery::Service diffs its Tags on 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::DBCluster or AWS::RDS::DBInstance no 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 manual rds-snapshot-<random> snapshot behind, even under DeletionPolicy: Delete. Under an explicit DeletionPolicy: Delete, cdkd destroy, cdkd state destroy and a deploy's removal delete now hand both types to the SDK RDSProvider, bound to the Cloud Control client's region, which deletes with SkipFinalSnapshot=true and waits up to 60 minutes for the resource to be gone. With no DeletionPolicy the Cloud Control delete and its snapshot stay, since CloudFormation's default for these types is Snapshot (issue #4030); replacement and rollback deletes also stay on Cloud Control (issue #4029). It also brings --remove-protection to these resources. A DeleteAutomatedBackups value in the template is sent with the delete, as CloudFormation sends it. DeletionPolicy: Snapshot on a Cloud Control-routed resource is still refused as before. A cluster that sets GlobalClusterIdentifier is 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. The rds-aurora and rds-full-stack integration fixtures now fail when a run leaves a manual snapshot of their Cloud Control-routed cluster or instance.
  • A malformed Targets, NotificationsWithSubscribers / ResourceTags or Triggers / Tags list 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.ts and codecommit-repository-provider.ts, with unit tests in tests/unit/provisioning/elbv2-targetgroup-malformed-targets-3989.test.ts, budgets-budget-provider-malformed-lists-3989.test.ts and providers/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 or cdkd drift --revert, which replay a recorded bag -- deregistered every target, deleted every budget notification or untagged every key, and PutRepositoryTriggers cleared 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 a cdkd import left) 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 / null still 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 as a***b is read as an ordinary name there too.
  • A nested cdkd export that 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 in tests/unit/cli/export-nested-failure-render.test.ts and export-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 leave IMPORT_COMPLETE before 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 wraps cdkd export in backticks, which ran it when pasted, and the Phase 1B failure no longer calls an adopted stack standalone.
  • cdkd deploy --replace deletes 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 in tests/unit/deployment/deploy-engine-replace-name-holder.test.ts and tests/unit/deployment/replacement-name-holder.test.ts. When a replacement's create-first attempt collided on a name, --replace deleted 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 with NAMED_REPLACEMENT_COLLISION and deletes nothing. The same check runs without --replace and under UpdateReplacePolicy: Retain, so their refusals no longer advise a --replace that would refuse. A Type change 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) and src/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-running cdkd rollback proceeds. The cdkd deploy --replace path without an explicit name is left for a follow-up (#3979).
  • cdkd deploy refuses a custom resource whose properties hold a secure dynamic reference, which CloudFormation does not support (issue #3976) -- new src/provisioning/custom-resource-secure-references.ts, wired into ProviderRegistry.validateResourceProperties (validateCustomResourceSecureReferences), tests/unit/provisioning/custom-resource-secure-references.test.ts, the custom-resource-masked-service-token integ fixture and docs/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 across Fn::Join parts around the secret's Ref -- 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 destroy does 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 in tests/unit/utils/active-imports-prose-quotes.test.ts. The refusal printed Cannot destroy stack '<producer>' and imports 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 its state.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-role parse and --assume-role-auto refusals 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 in tests/unit/cli/assume-role-parse-prose-quotes.test.ts. The three refusals printed the value, its logical id and its ARN as "${displayIdent(...)}", and displayIdent'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 through shellBoundedDisplay: shown as its JSON render inside single quotes, as the resolver shows a refused Fn::GetStackOutput RoleArn, 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-auto refusal (normalizeStartApiAssumeRole) printed the global ARN through bare displayIdent, and isIamRoleArn's role/[!-~]+ admits $, ( and a backtick, so pasting from but --assume-role onward 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.ts drops options.ts from its allow-list.
  • The composite-id refusal and malformed-id messages, the replacement name-collision refusal and the S3 UnknownError rewrites 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 in tests/unit/provisioning/composite-id-prose-quotes.test.ts, tests/unit/deployment/replacement-name-holder-prose-quotes.test.ts and tests/unit/utils/error-handler.test.ts. The composite-id messages and the bucket rewrites quoted the value RAW, and the collision refusal quoted a displaySafe render, 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). A replacement-name-holder.ts entry no longer sits in record-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 in tests/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 through safeSegment, 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.ts drops export.ts from its allow-list.
  • The cdkd local --assume-role messages, 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 in tests/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.ts and intrinsic-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 name cdkd or a flag. A refused Fn::GetStackOutput RoleArn shows JSON-quoted instead, as the operator needs it to fix the template. local start-api drops the --assume-role <id>=<arn> example for a non-plain logical id. The record-value-prose-quotes-fence.test.ts fence now also refuses a hand-quoted safe* variable. The export.ts, options.ts, replacement-name-holder.ts and composite-id.ts sites 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 in tests/unit/deployment/resolver-prose-quotes.test.ts. 27 lines wrapped a displayMasked / displayLeaf render in a hand-written '...' or "...", 12 more wrapped a logged* binding of one, and the placeholder-ARN refusal wrapped a stringifyValue render: the Fn::FindInMap refusals, the Fn::ImportValue and Fn::GetStackOutput messages and origin labels, the dynamic-reference secret, key and SSM parameter refusals, the Fn::GetAZs refusals, 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.ts drops the resolver from its allow-list and now also refuses a hand-quoted logged* 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 in tests/unit/cli/commands/rollback.test.ts, tests/unit/cli/events-display-safe.test.ts, tests/unit/provisioning/cc-import-identifier.test.ts and tests/unit/provisioning/pasteable-command-hand-quoted-ids.test.ts, and a src/** fence in tests/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 of Roll back 'MyStack' (us-east-1)?) and any other JSON-quoted. cdkd events' multiple-regions error ends in a --stack-region remedy, 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 --resource remedy prints a <logicalId> placeholder for such a logical id. The export.ts, state.ts, cdkd local, intrinsic-function-resolver.ts, composite-id.ts and scheduler-schedule-provider.ts sites, and the double-quoted ones in options.ts and replacement-name-holder.ts, stay open on #3950.
  • An AWS::RDS::DBProxyTargetGroup whose DBClusterIdentifiers / DBInstanceIdentifiers is 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 to string[], so a rollback revert or cdkd drift --revert replaying a recorded "prod-cluster" as the desired side deregistered the real target and registered p, 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 recorded TargetGroupName other than default is skipped too unless its proxy is gone. create(), update(), delete() and cdkd drift read a non-string DBProxyName as missing, and delete() and cdkd drift read a non-string TargetGroupName as default. cdkd drift reads every page of targets, no longer reports a registered cluster's member instances as DBInstanceIdentifiers, and reports a case-only match in the recorded spelling. The create and update debug lines mask identifiers. The missing-DBProxyName remedy prints its command through pasteableAwsCommand and only ever names the default group (part of go-to-k/cdkd#3136). Tests: tests/unit/provisioning/rds-dbproxy-targetgroup-provider.test.ts.
  • A nested cdkd export hands 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 in tests/unit/cli/export.test.ts and tests/unit/cli/export-nested-failure-render.test.ts, and an arm in the export-nested-stack integ. When a parent passed one of its AWS::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 with ssm: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's Ref (so Fn::Join / Fn::Select over it resolve) and is handed over comma-joined, and only a parameter the child's row mentions is read. cdkd export therefore needs ssm:GetParameter on such a parameter: an unreadable one, or one whose type is not String or StringList (such as a SecureString, which CloudFormation does not accept for an SSM-typed Parameter), refuses before any nested stack's lock or any changeset; --dry-run warns instead and plans without the nested stacks' Parameter values.
  • An AWS::IAM::Policy whose recorded Roles / Groups / Users is 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 the update() refusal and the delete() 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 with cdkd 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 when cdkd state orphan clears 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 or cdkd 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 in tests/unit/provisioning/auxiliary-failure-3826.test.ts. A failure after CreateTable returns (the replica add's UpdateTable, UpdateTimeToLive) now passes through markAuxiliaryFailure in 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 --replace deleted the live old table and a replacement rollback deleted the live new one. The error text is unchanged, and a collision on CreateTable itself still classifies.
  • cdkd diff and cdkd drift no longer report a state entry keyed (resources map) as if the whole resources map 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. --json shape change (cdkd diff): unreadable now holds only the ids of dropped resources entries; a new unreadableContainers lists "resources" / "orphans" for a container unreadable as a whole, and a new unreadableOrphans lists each dropped rollback-orphan record by logicalId, null for 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 new notCompared cause, unreadableMap (exit 2, as before), with its own reason text; a dropped entry keeps unreadableRecord. 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 drift no longer reports a resource with an unreadable properties map as clean, and --accept / --revert refuse it (issue #3315) -- src/cli/commands/drift.ts and src/state/malformed-resources-bag.ts, with unit tests in tests/unit/cli/drift.test.ts and tests/unit/state/malformed-resources-bag.test.ts. A resource record whose properties map 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. Plain cdkd drift now warns naming those records, leaves them out of the comparison and reports each as not compared with the cause unreadableRecord, so the run exits 2 (or 1 when something else drifted). --accept and --revert refuse the record before any readback, lock or write, naming the records and ending on the cdkd state show command 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::Sub part, in a used Fn::Sub variable, or inside an Fn::If around 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 in tests/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 nested Fn::Join / Fn::Sub / Fn::If part's record, and a used Fn::Sub variable's raw text (or, for an intrinsic variable, its record), so the token reaches the record raw beside the replacement that resolved it. An Fn::If records 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 on recordNestedStackParameterExpressions): a public or multiple replacement, a token in a part with no record of its own (an Fn::Select element), 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}}} whose Name is 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 state renders 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 in tests/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 of state resources / show / list --long / orphan / destroy / refresh-observed / info raw, 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 through displayIdent: a plain value prints unchanged, anything else JSON-quoted. The state orphan and state refresh-observed prompts, printed beside a labelled Destroy 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 a state orphan run 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 / --force the 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-region remedy quotes its '<region>' placeholder; cdkd rollback <stack> shares both refusals.
  • --strict-getatt now 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 in tests/unit/deployment/deploy-engine-skipped-outputs.test.ts and deploy-engine-stale-attribute-heal.test.ts (the real resolver on Fn::GetAtt [Role, RoleId]). Some Fn::GetAtt attributes cdkd cannot construct (AWS::IAM::Role RoleId, AWS::DynamoDB::Table StreamArn, AWS::IAM::Policy PolicyId, a failed Cloud Map HostedZoneId lookup) 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 an Export.Name a consumer's Fn::ImportValue could not find. Under the flag it now takes the same failure path as a thrown resolution: the deploy fails with Failed 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 --json output no longer carries terminal-active characters raw, and state list --long --json rows gain stateReadErrorKind (src/utils/display-safe.ts stringifyJsonPayload, src/cli/commands/state.ts, go-to-k/cdkd#3163). JSON.stringify escapes only C0, ", \ and lone surrogates, so a stack name, region or record value planted by anyone who can write the state bucket reached state list --json / --long --json / --tree --json, state resources --json, state show --json (with or without --show-nested) and state info --json with DEL, the C1 range (U+009B is 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 \uXXXX escape: the output is still valid JSON, it parses back to exactly the same values, and a plain payload is byte-identical. --long --json rows also carry stateReadErrorKind (read-failed / resources-malformed / null), so a consumer can tell a possibly transient read failure from a persistent malformed record without matching the stateReadError text. The other commands' --json payloads 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> --force re-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 (new reimportedAttributes, called from buildStackState), src/provisioning/cloud-control-provider.ts (the two unreadable-model warnings), docs/import.md, a new unit file and a new import-reimport-masked-attrs integ fixture. CloudControlProvider.import masks 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 next Fn::GetAtt of 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 unreadable GetResource model, 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 an Fn::GetAtt falls back to the physical id when a same-physical-id record's attributes are kept.
  • An Export.Name holding 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.ts and src/state/export-index-store.ts, with unit tests in tests/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 into state.json and 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 in cdkd scrub alike. 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 (cafe with 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 (allSettledKeepingFirstRejection and its two call sites), .claude/rules/layout-deployment-secrets.md and unit cases. Since the drain of #2563, a list nested inside an Fn::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-protection restores 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 in tests/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-flip DescribeLogGroups / DescribeUserPool saw 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 live DeletionProtection even when the recorded properties say ACTIVE, and skips the UpdateUserPool flip when the pool is already INACTIVE. The other --remove-protection types 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 in tests/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() and update() 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, a CatalogId move, an undecodable table id) and the pre-update read failure. On update() 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 shared src/provisioning/masked-retry-logger.ts helper, so the IAM and S3 bucket providers' update() lines gain it too. The delete() path still has no masker (issue #2007).
  • The skip messages of cdkd destroy, cdkd state destroy and 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 cdkd could not address the resource, that no 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 reads cdkd 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 the skipped (...) 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 with cdkd 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 NoEcho template parameter's value no longer prints in a provider's warn line, in the deploy's error text or in the deployments/*.jsonl event (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 --verbose parameter lines honoured NoEcho. A Ref or Fn::Sub variable serving a NoEcho: true parameter 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. The Fn::Base64 encoding of such a value is masked in the log too. Nothing cdkd PERSISTS changes: the value is not a recorded secret, state.json and every redaction decision are byte-identical with or without NoEcho, and an Export.Name embedding it is still published. Bounds: an Fn::Split fragment of the value is not recorded. The persistence half (cdkd rollback, cdkd drift, export names) is #4043; cdkd diff, the --verbose replacement 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.ts and src/deployment/retryable-errors.ts (isAmbiguousOutcomeError, markReplayMayCollide), with unit tests in tests/unit/deployment/retry-ambiguous-create-replay-3978.test.ts and retry-ambiguous-cc-create-3978.test.ts. When a main create call (a Kinesis CreateStream, or a Cloud Control CreateResource) 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 --replace deleted 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 its Throttling and GeneralServiceException, 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's UnsupportedActionException instead 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.ts and src/provisioning/auxiliary-failure.ts (isAuxiliaryFailure), with unit tests in tests/unit/deployment/retry-replayed-create-collision-3972.test.ts. When a call made after the main create failed with a retryable error (a Kinesis AddTagsToStream throttled with Rate exceeded, say), the retry wrapper ran the whole create() 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 --replace deleted 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 export that fails adopting its nested children (Phase 1B) prints region-bearing, per-stack cdkd state orphan commands (issue #3967) -- src/cli/commands/export.ts, with unit tests in tests/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 gated cdkd 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 scrub repairs a nested-stack parent row's Outputs.<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's attributes['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 --fail reported 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), printing Scrubbed 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 ServiceToken holds 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.ts and a new arm of the custom-resource-masked-service-token integ 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 fails GetFunction'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 reports outcome: 'skipped' before any AWS call, like the #3938 masked-token arm: the record is kept, cdkd destroy exits 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 and cdkd 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-task and local start-api resolve 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 from src/cli/cdk-path.ts; unit tests in tests/unit/local/{lambda,ecs-task}-resolver.test.ts and tests/unit/cli/local-start-api-pick-target-stacks.test.ts, and a new local-only fixture tests/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 a Stage -- whose display path is MyStage/Api, or Outer/Inner/Api nested -- was unreachable: cdkd local invoke MyStage/Api/Fn answered Stack 'MyStage' not found, and local start-api MyStage/Api/HttpApi selected no stack. The stack is now the one whose display path (or physical name) followed by / is the longest prefix of the target -- the rule cdkd orphan adopted in #3943 -- so a nested Stage resolves and MyStage/Api never claims MyStage/ApiV2/.... local run-task picks the stack whose state feeds --from-state / --from-cfn-stack by the same rule, and local start-api selects 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 the Stack:LogicalId form keeps the stack it names. local start-service, start-alb, start-cloudfront and 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, and tests/unit/provisioning/redacted-delete-address.test.ts. State stores the *** mask of a NoEcho value 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 reports outcome: 'skipped' before any AWS call: the record is kept and cdkd destroy exits 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 / --revert no longer print a state- or readback-derived value raw in their plan (issue #3949) -- src/cli/commands/drift.ts (printAcceptPlan, printRevertPlan, new reportPath / reportPlanChangeLine), new tests/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.ts exports the character set safeMsg alters (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.ts and src/provisioning/providers/firehose-provider.ts, with unit tests in tests/unit/provisioning/asg-provider-malformed-lists-3948.test.ts and tests/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 or cdkd drift --revert, where the desired side is a recorded bag, a hand-edited TargetGroupARNs: {} or LoadBalancerNames: "my-elb" detached every attachment and took the group out of service. The same held for Tags, MetricsCollection, LifecycleHookSpecificationList, TrafficSources and NotificationConfigurations, and for a Firehose delivery stream's Tags. A malformed desired list is now refused before any AWS call, as is one in create(). A malformed recorded entry list is refused too, with a state.json repair (or set 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 recorded LoadBalancerNames / TargetGroupARNs holding any malformed value (for example a dynamic reference, or an unresolved intrinsic cdkd import recorded) 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 orphan resolves a construct path in a stack under a CDK Stage (issue #3943) -- src/cli/commands/orphan.ts, with unit tests in tests/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 a Stage -- whose display path is MyStage/Api, or Outer/Inner/Api for a nested Stage -- was unaddressable: cdkd orphan MyStage/Api/Bucket answered stack '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 and MyStage/Api never claims MyStage/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 with does 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 diff no 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-timeout timer 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 the cdkd force-unlock hint and exits 70 (EX_SOFTWARE), now listed in the exit-code table of docs/cli-reference.md.
  • A custom resource whose recorded ServiceToken is 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 new tests/unit/provisioning/custom-resource-masked-service-token.test.ts and a new tests/integration/custom-resource-masked-service-token/ fixture. A dependent custom resource that reads a NoEcho value equal to, or contained in, its own ServiceToken (a handler echoing event.ServiceToken into Data, read by a custom resource on the same provider) persists ServiceToken: "***". The delete path guarded only an absent or non-string token, so the mask reached the backing-Lambda GetFunction pre-check and the Delete invoke, failing with an AWS error that said nothing about the mask. It now reports outcome: 'skipped' like the no-ServiceToken arm: no AWS call, the state record is kept, cdkd destroy exits 2, and the warning names the mask and the remedies (tear the resource down by hand and cdkd state orphan <stack>, or restore ServiceToken in state.json while the handler still exists).
  • A Ref to 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 in tests/unit/deployment/resolver-ref-parameter-precedence.test.ts and tests/unit/cli/export-nested-failure-render.test.ts. The resolver answered a Ref from 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: on cdkd deploy for every property the parameter fed, and on nested cdkd export for 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: Ref returns the parameter (or pseudo-parameter) value, and Fn::GetAtt on such a name fails as Resource <name> not found for Fn::GetAtt, while a ${Name.Attr} placeholder in Fn::Sub on such a name warns and keeps the placeholder, as it does when no record exists. A Ref / Fn::GetAtt to a resource still reads its record, and a record whose name no parameter uses resolves as before.
  • cdkd export checks each AWS::IAM::Policy pre-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 in tests/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 prints cdkd diff then cdkd deploy on 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 or PolicyName cdkd cannot resolve, or a missing PolicyName, is marked in the plan. Every recorded principal is listed, not 20 per kind. A legacy policyName:roleName record 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 / 1B Cause: 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 a cdkd state orphan for 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 in tests/unit/provisioning/iam-principal-list-shape.test.ts. AWS::IAM::ManagedPolicy Groups / Roles / Users, AWS::IAM::InstanceProfile Roles, AWS::IAM::User Groups and AWS::IAM::UserToGroupAddition Users were cast and iterated, so a string was walked by character. A rollback revert or cdkd 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 reader AWS::IAM::Policy uses. 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 a state.json edit.
  • EFS access-point adoption also compares PosixUser and RootDirectory before adopting a conflicting access point (issue #3894) -- src/provisioning/providers/efs-provider.ts (createOrAdoptAccessPoint, samePosixUser, sameRootDirectory), docs/provider-rules.md, with unit tests in tests/unit/provisioning/efs-access-point-idempotency-token.test.ts. When a retried CreateAccessPoint is refused with AccessPointAlreadyExists, cdkd reads the named access point back and adopts it. The adoption used to be confirmed only on the ClientToken and the FileSystemId, 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 or null, an omitted root as / -- so a default-root access point is still adopted. SecondaryGids order and a 755 versus 0755 mode 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 resolves Ref as 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 in tests/unit/provisioning/, tests/unit/deployment/intrinsic-{functions,ref-state-mask}.test.ts and tests/unit/analyzer/orphan-rewriter.test.ts; assertion 12 of the glue-update-hardening fixture. Glue accepts | in a database name (probed through that fixture), but cdkd deploy and cdkd import refused a table in such a database. The table's id stays <databaseName>|<tableName>, and every reader places the table name by the recorded DatabaseName, which a create always records; cdkd import also records it as an attribute, so Ref can 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's DatabaseName rather than edit a correct id. A masked DatabaseName refuses a Ref that 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 (table db|orders in database my, table orders in database my|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 whose DatabaseName both prefix the id and differ as two tables (equalIdNamesDifferentResources in src/deployment/type-change-guard.ts), so such a replacement completes and rolls back instead of stranding the new table.
  • --recreate-via-cc-api refuses 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 in tests/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 as NON_PROVISIONABLE, such as AWS::CodeBuild::Project, AWS::IAM::Policy or AWS::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_FAILED no 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 treated DELETING_FAILED as terminal on its very first status read, and a stream still in that status when the re-run's DeleteDeliveryStream is 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 sends AllowForceDelete, so a forced delete has to be done outside cdkd.
  • cdkd destroy no longer removes an inline policy from principals a malformed AWS::IAM::Policy record 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 in tests/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.ts and tests/unit/cli/export-predelete-listing.test.ts. The delete cast the record's Roles / Groups / Users to a list and iterated it. A string was walked character by character, so Roles: "AdminRole" removed a same-named inline policy from any one-letter role and never from AdminRole. 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. null is still read as absent. cdkd export's IAM::Policy pre-delete reads the lists through the same module, so it now also reads a null list 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.ts and dynamodb-table-provider.ts, with rows in tests/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 through markAuxiliaryFailure: the hosted zone's AssociateVPCWithHostedZone and UpdateHostedZoneFeatures; the stream's AddTagsToStream, retention change, StartStreamEncryption and EnableEnhancedMonitoring; and the table's UpdateContinuousBackups, UpdateTimeToLive, EnableKinesisStreamingDestination, UpdateContributorInsights and the stream-arn TagResource / PutResourcePolicy. Before this change, such a failure relaying AWS's "already exists" was treated as a collision on the resource's own name, so --replace deleted 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::GlobalTable remains 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 in tests/unit/deployment/deploy-engine-type-change-routing.test.ts. The engine's interrupt error reached its outer catch wrapped in a ProvisioningError when 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-rollback that 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, an interrupted journal 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 with cdkd 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 in tests/unit/provisioning/non-provisionable-unrecognized-route-3871.test.ts and tests/unit/provisioning/property-coverage-cc-fallback-binding.test.ts. AWS reports AWS::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::TopicPolicy and AWS::SQS::QueuePolicy as NON_PROVISIONABLE. Even so, a template key cdkd's schema snapshot does not know routed the resource to Cloud Control, which failed with an opaque UnsupportedActionException. 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 as AWS::EC2::NetworkAcl or AWS::Glue::Database, keep their Cloud Control route. For the eight such types that already refused, a refusal now names ProvisioningType: NON_PROVISIONABLE as the reason.
  • BREAKING: cdkd state destroy --all is removed (issue #3865) -- src/cli/commands/state.ts, src/utils/interrupt-signals.ts, with unit tests in tests/unit/cli/state-destroy.test.ts and tests/unit/cli/state-destroy-command-sigint.test.ts. --all destroyed 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 for cdkd destroy. Passing --all exits 1 with STATE_DESTROY_ALL_REMOVED before 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 list shows them), or run cdkd destroy --all from 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 and isPromptAbortError that 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 --all and wildcard patterns are refused when the app cannot be synthesized (issue #3839) -- src/cli/commands/destroy.ts, with unit tests in tests/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 as Caused 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. The Could not determine which stacks belong to this app refusal no longer advises --all after 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 --replace no longer deletes the managed resource there (issue #3808) -- new src/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 advised cdkd 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: --replace deleted the managed resource and then hit the same collision. The deploy now fails with NAMED_REPLACEMENT_COLLISION saying the name is held by another resource, under every flag and UpdateReplacePolicy, and deletes nothing. The UPDATE-not-supported fallback's Retain refusal, which advised removing Retain so cdkd deletes the old resource first, takes the same verdict. The --recreate-via-cc-api replacement also now refuses a type Cloud Control cannot create before deleting anything, inside the engine as well as at pre-flight (issue #3899).
  • cdkd drift no longer compares an AWS::DynamoDB::Table WarmThroughput member cdkd never sent (issue #3777) -- src/provisioning/providers/dynamodb-table-provider.ts (readCurrentState, reverseMapSecondaryIndex, new canonicalizeDriftPair), with unit tests in tests/unit/provisioning/dynamodb-table-provider-warm-throughput-per-member.test.ts and a dynamodb-gsi-update integ phase. A declared WarmThroughput with 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::GlobalTable no longer treats a key-order-only difference as a change (issue #3775) -- src/provisioning/providers/dynamodb-globaltable-provider.ts.
    • update() compared values with JSON.stringify, so key order mattered. aws-cdk-lib writes a GSI Projection as {NonKeyAttributes, ProjectionType}, but a readback has the reverse order. On cdkd drift --revert, an unchanged INCLUDE index was therefore classified as modified. cdkd then warned that KeySchema / Projection changed 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 / LocalSecondaryIndexes immutability 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-agentcore now deliver an env var named __proto__, and the local 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) ran Object.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 in start-service / start-alb / start-agentcore / start-cloudfront.
    • start-service / start-alb / start-agentcore / start-cloudfront now 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 one cdkd local run-task / invoke already apply, so docs/local-run-task.md's "Secret names that are not forwarded" now holds for every cdkd local command.
  • cdkd diff's repaired-outputs warning now says cdkd deploy refuses the record, and the destroy refusals that cannot print a record's name point at cdkd 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 in tests/unit/state/malformed-resources-bag.test.ts and tests/unit/state/state-key-region-authority.test.ts. The warning cdkd diff and cdkd scrub --dry-run print over an unreadable outputs map described only the repair, although cdkd deploy refuses that record when it loads it. On a nested child, where cdkd diff raises no exit 3, nothing said so. The warning now does, in its own sentence about the outputs map rather than the properties warning's "Do NOT run 'cdkd deploy'". Separately, the withhold arms of the cdkd destroy refusals for an unreadable resources map, an unreadable orphans list and a record whose region disagrees with its key sent the reader to cdkd state list --long for the exact name. That view trims, so a record keyed 'prod-api ' listed as the healthy prod-api. They now name cdkd 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 in cdkd rollback, cdkd state, the destroy runner and the shared pasteable-command clause is unchanged.
  • cdkd import names a CDK Stage that failed to load when stack selection comes back empty (issue #3507) -- src/cli/commands/import.ts, with unit tests in tests/unit/cli/import.test.ts. A stack under a Stage whose manifest could not be read is dropped from the synthesized app, so cdkd import MyStage/MyStack answered Stack 'MyStage/MyStack' not found in synthesized app, and an app whose only stacks sit under such a Stage answered Multiple stacks found: . with no argument. import now raises the message deploy, diff, list, publish-assets, scrub and synth print: No stacks matching <arg> found in assembly. Available: <stacks> (or The assembly has no stacks, or No stacks found in assembly with no argument), plus the failed-Stage note naming the targeted Stage or listing every failed Stage behind Possibly unrelated:. The Multiple stacks found list 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 scrub names a CDK Stage that failed to load when stack selection comes back empty (issue #3507) -- src/cli/commands/scrub.ts, with unit tests in tests/unit/cli/commands/scrub-stack-selection.test.ts. A stack argument matching nothing answered a bare No 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, so cdkd scrub MyStage/MyStack could not select them. scrub now raises the same message deploy, diff, list, publish-assets and synth print: No stacks matching <patterns> found in assembly. Available: <stacks>, plus the failed-Stage note naming the targeted Stage or listing every failed Stage behind Possibly unrelated:. An app that synthesized no stacks is refused with No stacks found in assembly and the same note, where it answered Multiple stacks found: . with no argument and No stacks matched. under --all. The exit code is unchanged. --all over 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 show line holds a withheld value now says why, before the command, and cdkd 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 shared inspectCommand holes 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 in cdkd 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 on Inspect 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's cdkd state orphan recovery commands build through the shared pasteable-command gate, and cdkd 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. orphanCommandFor kept its own copy of the gate; it now builds through pasteableCommand and 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-region takes 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 through displayStackName, not inside '...', which a name x'$(touch OWNED) # closed when the sentence was pasted.
  • The malformed-record refusals behind cdkd orphan, and the orphans-list refusals cdkd deploy, import, rollback, scrub and destroy raise, build their Drop the record: and Inspect 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 shared orphanRefusal remedy kept its own copy of the gate (rendersExactly), which admitted --all (it renders exactly, and shellQuote leaves it bare), so the refusal printed cdkd 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 from pasteableCommand, 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 no cdkd state list --json line is printed unless the region is withheld too. The legacy arm's object path keeps rendersExactly, since such a name is still its key. plainIdent is 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-arn no 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) and local-invoke-agentcore.ts (resolveStackAccountId), plus tests/unit/cli/local-stack-account-source.test.ts. Each resolver asked STS as the CALLER. Under --from-state with --role-arn the state record is read as the ROLE, and ExpectedBucketOwner normally pins that bucket to the reader's own account, so an emulated function resolved ARNs, ECR image URIs and the bare --assume-task-role ARN for an account its stack does not live in. Under --from-state the 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-arn or --profile is unchanged. The container's own credentials are not affected. The invoke / start-api STS-failure warnings now name --from-cfn-stack rather than --from-state when that is the source in use.
  • A NoEcho custom-resource value EMBEDDED in a longer string no longer reaches state.json (issue #2453) -- redactSecretsForState, carriesFreshNoEchoValue, freshNoEchoLeafPositions and mergeResolvedPairs in src/deployment/secret-redaction.ts, the Fn::GetAtt and Fn::Base64 registrations in src/deployment/intrinsic-function-resolver.ts, unit tests in tests/unit/deployment/, and the custom-resource-getatt-data fixture, 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 the Fn::Base64 encoding 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 -- so cdkd drift --revert / --accept and 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, its ServiceToken or one of that ARN's :-separated parts, such as the account id) is not matched inside a longer leaf, so a handler echoing it under NoEcho does 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 scrub now 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, and cdkd scrub --dry-run --fail exited 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 the Parameters block 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 with SCRUB_NESTED_CHILD_UNRESOLVABLE (exit 2), never reported clean; a cyclic or escaping nested template tree refuses its stack with SCRUB_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-protection restores DeletionProtection on 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 in tests/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-flip Describe* saw 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 modify-db-* command. The mechanism moved out of the DynamoDB module into a shared one; DynamoDB's wording is unchanged. The other --remove-protection types 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 in tests/unit/provisioning/s3-bucket-provider-mask-threading.test.ts. A BucketName resolved from a {{resolve:secretsmanager:...}} reference used to reach most of the provider's own debug and warning lines in plaintext (every Applied ... to bucket, Deleted ... on bucket and region-guard line), since those lines pass through no deploy-engine mask. create() and update() 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. On update() the recorded bucket name is masked as well when the previous BucketName in state is a secret reference, which covers a rotated secret whose old plaintext is in no bag of this deploy. The delete() path still has no masker (issue #2007).
  • A Glue table whose name contains | now deploys, updates, destroys and resolves Ref as in CloudFormation (issue #1672) -- src/provisioning/composite-id.ts, src/provisioning/providers/glue-provider.ts, src/deployment/intrinsic-function-resolver.ts; unit tests in tests/unit/provisioning/composite-id*.test.ts, tests/unit/provisioning/providers/glue-provider.test.ts and tests/unit/deployment/intrinsic-{functions,ref-state-mask}.test.ts; assertion 11 of the glue-update-hardening fixture. AWS accepts a table named a|b and CloudFormation manages it, but cdkd deploy refused it, because the recorded id <databaseName>|a|b was read back by its last |: Ref would have returned b. Ref now returns everything after the recorded DatabaseName, falling back to everything after the first |, and the create no longer refuses the table name. cdkd import adopts such a table when it is paired with the template's DatabaseName, including CloudFormation's bare a|b id; 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, new src/provisioning/delete-gone-wait.ts, and unit tests. Both services keep a stream's NAME while it is DELETING and refuse a create of that name with the same ResourceInUseException ... already exists text 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 moment DeleteStream / DeleteDeliveryStream was accepted; it now polls DescribeStreamSummary / DescribeDeliveryStream until ResourceNotFoundException, like CloudFormation. The wait is capped (5 min Kinesis, 10 min Firehose; half an explicit --resource-timeout when 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 enters DELETING_FAILED fails 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::DBCluster and AWS::DocDB::DBInstance refuse 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 in tests/unit/provisioning/non-provisionable-cc-route-refusal-3866.test.ts. AWS reports the three types as NON_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 CodeBuild Triggers block (CDK's webhook: true) or a DocDB cluster's EnableCloudwatchLogsExports, was routed to Cloud Control and failed there with an opaque UnsupportedActionException, after other resources had already been created. The deploy now stops before provisioning anything, naming each property and the --prefer-sdk-route escape 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-rollback failure no longer suggests a stack-less cdkd rollback that targets the top-level stack (issue #3864) -- src/deployment/deploy-engine.ts (recoveryHint), with unit tests in tests/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-rollback failure. With no stack argument, cdkd rollback resolves 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 --orphan command (issue #3859) -- src/deployment/rollback-executor.ts (orphanRemedy, RollbackExecutorContext.nestedChildStack), src/deployment/deploy-engine.ts, with unit tests in tests/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.ts and tests/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 under UpdateReplacePolicy: Retain), the refusal ended on To 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 --orphan for the child's ops. For a failed nested UPDATE row the parent's rollback refuses without --revert-failed and, 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 reads cdkd 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 export names the roles, users and groups an AWS::IAM::Policy pre-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 in tests/unit/cli/export-predelete-listing.test.ts and tests/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 a removes 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 recorded Roles / Users / Groups is 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::EventSourceMapping created alongside its function's new role is retried instead of failing the deploy (issue #3853) -- src/deployment/retryable-errors.ts, with unit tests in tests/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::EventSourceMapping applies a changed SelfManagedKafkaEventSourceConfig / AmazonManagedKafkaEventSourceConfig on update (issue #3851) -- src/provisioning/providers/lambda-eventsource-provider.ts (kafkaConfigForUpdate), with unit tests in tests/unit/provisioning/lambda-eventsource-provider-kafka-update.test.ts and a schema-registry add and removal phase in tests/integration/lambda-esm-self-managed-kafka/. update() never sent either block, so adding, changing or removing a SchemaRegistryConfig on 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 removed SchemaRegistryConfig is sent as { SchemaRegistryConfig: {} }, the shape Lambda clears it with; an empty block leaves it in place. An unchanged ConsumerGroupId is 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 or cdkd drift --revert never sends it, since no change to it can have been applied. State an older cdkd wrote after such an update records the new SchemaRegistryConfig although AWS never received it, so the next deploy sees no change: compare with aws lambda get-event-source-mapping, and re-deploy after toggling the block if they differ.
  • AWS::Lambda::EventSourceMapping refuses SelfManagedKafkaEventSourceConfig.ConsumptionMode instead of dropping it silently (issue #3848) -- src/provisioning/providers/lambda-eventsource-provider.ts, with unit tests in tests/unit/provisioning/lambda-eventsource-provider-consumption-mode.test.ts and tests/unit/provisioning/sdk-pending-members.test.ts. CloudFormation added the member (Stream | Queue), but @aws-sdk/client-lambda does 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 a cdkd drift --revert still 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 --orphan command that cannot reach it (issue #3845) -- src/deployment/rollback-executor.ts (orphanRemedy, RollbackExecutorContext.nestedChildRevert), src/deployment/nested-child-journal.ts, with unit tests in tests/unit/deployment/rollback-executor-retain-new-resource.test.ts and tests/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 under UpdateReplacePolicy: Retain) can fire inside that replay. They ended on To orphan it: cdkd rollback --orphan <id>, but --orphan feeds 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 the create() 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 in tests/unit/provisioning/auxiliary-failure-3826.test.ts. A provider's create() 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: --replace deleted 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 export renders 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 in tests/unit/cli/export-record-value-display.test.ts and tests/unit/cli/export-plan-record-display.test.ts. A record's physicalId and its properties / attributes values are not validated when read back. Several messages printed them raw or inside cdkd's own '...': the import plan and pre-delete listing, the ✓ deleted lines, 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. The cdkd state orphan fallback and the drift-baseline hole now point at cdkd state list --json instead 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 STS RoleArn maximum), is enforced immediately before every AssumeRoleCommand cdkd sends. A malformed --role-arn / CDKD_ROLE_ARN, --ecr-role-arn, --assume-task-role or task-definition TaskRoleArn, and a cross-account Fn::GetStackOutput RoleArn, 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-role on cdkd local start-api used a start-anchored pattern, so a value with a newline, an escape sequence or any length after role/ passed. It is now rejected, as are an empty role name and a partition containing a space. The bare form is trimmed, as the LogicalId=<arn> form already was.
    • A malformed --assume-role <arn> on cdkd local invoke and local invoke-agentcore is now an error. It used to fail at STS and fall back to the developer's shell credentials. --ecr-role-arn, --assume-task-role and --layer-role-arn are checked at startup too. A bare --assume-role ARN containing = now parses.
    • A role ARN read from deployed state, a sibling role's cached Arn, or a template Role / RoleArn literal used to be accepted on startsWith('arn:'). A malformed one now stops local start-api --assume-role-auto at startup. local invoke and invoke-agentcore warn and fall back, as they did when STS rejected it.
  • 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 builder createMaskedLogSinks in src/provisioning/masked-retry-logger.ts; unit tests in tests/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 the aws iam ... cleanup commands a failed create prints. Every create() / 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 the create() they run. The delete() path still has no masker (issue #2007), and a derived name is masked only inside the operation that derived it (issue #3869).
  • cdkd import resolves a name built from the account or region before the provider looks it up (issue #1897) -- src/cli/commands/import.ts (resolvePseudoParameterIntrinsics, called from importOne), with unit tests in tests/unit/cli/import-prepass-pseudo-parameters.test.ts and tests/unit/cli/import.test.ts, the route53 integ arm in tests/integration/route53/verify.sh, and docs/import.md. On an env-agnostic stack a name such as `cdkd-test-${this.account}.internal` synthesizes to an Fn::Join over AWS::AccountId, and import.ts pre-substituted only {Ref: <logicalId>} before calling a provider, so the provider saw the intrinsic, its typeof name === 'string' guard declined, and the documented name route (for example AWS::Route53::HostedZone via ListHostedZonesByName) answered skipped-not-found. Every intrinsic built only from string literals, Fn::Join, Fn::Sub and AWS::AccountId / AWS::Region / AWS::Partition / AWS::URLSuffix is now evaluated for the resource's region before provider.import(), with the pseudo-parameter values a deploy uses. Anything else in the intrinsic (a resource or parameter Ref, 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, substituteOverrideRefs now 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 in tests/unit/deployment/retryable-errors.test.ts and tests/unit/deployment/rollback-executor-name-collision-route.test.ts and shared fixtures in tests/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 --replace to 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 …AlreadyExists code only as a whole token — a logical id such as UserAlreadyExistsHandler…, 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 its AlreadyExists handler code rather than its text. A thrown string, a non-Error value, a custom resource's FAILED reason, a Cloud Control failure under any other code, and S3's BucketAlreadyExists (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 --recursive previews a nested stack whose Condition turned false as a delete of its whole subtree (issue #3815) -- src/cli/commands/diff-recursive.ts (computeStackDiff now returns its condition-pruned effectiveTemplate; buildDiffTree walks nested children and collects the [via CC API] annotations from it), with unit tests in tests/unit/cli/diff-recursive.test.ts. The walk read the raw template, so a nested row whose Condition evaluated 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 resource Type change into or out of AWS::CloudFormation::Stack, then counted toward exit 3 over 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 what cdkd deploy does. A nested-template cycle that closes only through such a row is still refused, as cdkd deploy refuses it: the root node also walks the unpruned nested-template tree.
  • cdkd destroy no 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 in tests/unit/utils/live-renderer.test.ts, tests/unit/cli/destroy-runner-empty-state-lock.test.ts, tests/unit/cli/destroy-runner-sigint.test.ts and tests/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, the Acquiring 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 through displaySafe at draw time, covering the deploy-side spinner as well; the logical ids and types in those lines take displaySafe, a region takes displaySafe restricted to ASCII, and every stack name above renders through displayStackName. 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_FALLBACK removed), with unit tests in tests/unit/deployment/retryable-errors.test.ts and tests/unit/deployment/deploy-engine-replace.test.ts and a shared fixture in tests/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-numeric DeltaSyncTableTTL, 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: the UnsupportedActionException name down the cause chain, and the async ccErrorCode for 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::Sub refuses 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 to Fn::Join. This applies to a List<...> or CommaDelimitedList parameter, a list-valued Fn::GetAtt, and AWS::NotificationARNs, which used to render as ''.
    • A stack Output that holds one is skipped with a warning, and a Condition evaluates 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 CreateStack with "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::NotificationARNs now resolves to an empty list rather than '', so Fn::Join over it renders '' as CloudFormation does. It used to fail with "resolved to string". A Condition built as Fn::Equals over that Fn::Join and "" now evaluates to true, where it used to evaluate to false.
    • A bare Ref to a list parameter and Fn::Join over one are unchanged.
  • 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.MasterUsername or AWS::IAM::ManagedPolicy.Description, a moved value in a create-only property was sent to an in-place update(). 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 needs cloudformation: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 NoEcho value AWS confirms). It replaces the reader when the value did move, and a stateful type stops at STATEFUL_REPLACE_BLOCKED.
    • cdkd diff now shows such a ceiling as [may require replacement].
    • Deploy: a replacement kept because AWS could not confirm a NoEcho value 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.
  • A malformed AWS::AppSync::GraphQLApi EnvironmentVariables value 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-path cdkd deploy update BEFORE any write, when the map changed. It used to throw after UpdateGraphqlApi had already applied the other changed fields.
    • The rollback revert arms (replayingState) and cdkd drift --revert (desiredFromAwsReadback) now warn instead of failing. They send no PutGraphqlApiEnvironmentVariables, 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 malformed EnvironmentVariables container 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.
  • cdkd rollback's Re-run with: line and cdkd 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, or cdkd destroy's progress lines (issue #3773) -- src/cli/commands/rollback.ts (a shared rerunRollback for 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 rollback can 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 as Re-run with: cdkd destroy --all --force #. cdkd events names 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 rule cdkd drift, cdkd state and cdkd destroy already 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>) deleted line and the could not address <id> (<physical id>) skipped-delete message now fold control characters in their identifiers and skip reason onto one line, and so do cdkd destroy's progress, skip, --remove-protection bypass and --purge-events lines for the stack name, region and AWS error text.
  • cdkd rollback and cdkd events no longer print a padded stack name, region or run field in the output that ends in their labelled Re-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 as Re-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 covers cdkd rollback's multi-journal candidate list (with a pointer to cdkd state list --long) and its lock-release warning, and the Deployment runs for header of cdkd events. The failed-persist, failed-strip and lock-release warnings of cdkd rollback no 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. Each cdkd events run-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 the nested-stack-rollback integ fixture. When a parent deploy failed after a nested child had deployed, the rollback reverted the AWS::CloudFormation::Stack row 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. Standalone cdkd rollback of 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) -- glueTableNameChanged and an AWS::Glue::Table rule in src/analyzer/replacement-rules.ts; unit tests in tests/unit/analyzer/replacement-rules-glue-nested-name.test.ts; phase 2c of the glue-update-hardening fixture. A TableInput.Name change diffed as an in-place update that the provider then refused, asking for cdkd deploy --replace, so cdkd diff forecast 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. A ConnectionInput.Name change was already a replacement through the schema's nested createOnly path; it is now pinned by tests, which also pin that an unrelated ConnectionInput edit is not. A DatabaseInput.Name change 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::RecordSet no longer leaves the old record live and untracked (issue #3741) -- src/provisioning/providers/route53-provider.ts.
    • A change to a record's Name, Type or SetIdentifier used to send a single UPSERT built from the new properties. Route 53 created the new record beside the old one, and the old record kept resolving. cdkd destroy could not remove it. A rename whose new record conflicts with the old one was refused outright, for example a new SetIdentifier on a geoproximity record or an A -> CNAME swap on one name.
    • Such a change now sends one atomic ChangeBatch: a DELETE of the old record, read back live because Route 53 requires an exact match, plus a CREATE of 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 by UPSERT as before.
    • A rename also needs route53:ListResourceRecordSets (to read the old record), beside route53: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 --revert never renames.
    • On rollback, a SetIdentifier-only rename is renamed back the same way, and a Name / Type rename is reversed as a replacement. Either way the rollback leaves no record behind.
    • cdkd drift now reads a weighted, latency, failover or geo record by its recorded SetIdentifier. It used to read whichever sibling with the same name and type sorted first.
  • A template-path update now refuses a malformed Glue DatabaseInput block it used to deploy with a warning (issue #3740) -- src/provisioning/providers/glue-provider.ts, docs/provider-rules.md, phase 2e of the glue-update-hardening fixture.
    • Refused on cdkd deploy, before any Glue call: an AWS::Glue::Database update whose DatabaseInput.TargetDatabase, FederatedDatabase or CreateTableDefaultPermissions is malformed (a wrong shape, an unresolved intrinsic, an empty or partly unreadable block, or a permission entry cdkd cannot read, including a null one, which used to fail with a raw TypeError on every path), with the same message create() gives plus "Nothing was applied". It is refused even when the block did not change, because UpdateDatabase replaces DatabaseInput wholesale and sends every block on every update.
    • The rollback revert arms (replayingState) and cdkd drift --revert (desiredFromAwsReadback) keep the warning and still retain the previously applied block.
    • Unchanged: the AWS::Glue::Table OpenTableFormatInput.IcebergInput warning on update, since nothing is sent for it on update and no template edit makes it apply in place.
  • A create-only reader of a NoEcho custom-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 at STATEFUL_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 NoEcho value used as a resource NAME is stored in the clear as the physical id, as CloudFormation shows it.
    • Residual: issue #3803.
  • An Output, ECS env var, FSx FileSystemType or drift baseline key named after an Object.prototype member (constructor, toString) is no longer mistaken for one that is present (issue #3515, its in-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 scrub treated a condition-suppressed Output named constructor as written, because in checks 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-stack Environment entry named constructor, which left the container without it.
    • An FSx create with FileSystemType: constructor passed the "supported type" guard instead of getting cdkd's named refusal.
    • cdkd drift compared an undeclared, captured-empty baseline key of that name instead of skipping it: phantom drift that --revert would act on. Only a hand-edited state.json can carry such a key.
    • All four sites use Object.hasOwn now.
    • cdkd local run-task also 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-vars override writes all ran Object.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-task handed each resolved secret to docker run by assigning it onto a plain object. For a secret NAMED __proto__, that ran Object.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, and partitionSensitiveEnv / dockerSpawnEnvWithSensitive in the helper cdkd local invoke shares) are own-key defines now. The value still travels only through the spawn env, never the argv, which a test pins. cdkd local invoke could 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-key named 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 destroy names a CDK Stage that failed to load when stack selection comes back empty (issue #3507) -- src/cli/commands/destroy.ts, with unit tests in tests/unit/cli/destroy.test.ts. A stack under a Stage whose manifest could not be read is dropped from the synthesized app, so cdkd destroy MyStage/MyStack answered No matching stacks found in state for a stack that was still in state. The two empty-selection messages (No matching stacks found in state, No stacks found in state) and the Could not determine which stacks belong to this app refusal now append the same failed-Stage note deploy, diff, list, publish-assets and synth print, naming the targeted Stage or listing every failed Stage behind Possibly unrelated:. Those exit codes are unchanged, and when synthesis itself fails the messages are unchanged. cdkd destroy --all or 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 --all there. cdkd scrub is not covered yet.
  • cdkd diff now agrees with cdkd deploy on 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 --recursive now refuses a nested-template cycle spelled through a symbolically linked directory (d -> . with a row naming d/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, and cdkd import --migrate-from-cloudformation now finds a grandchild's template the same way. A row the deploy replaces (a [requires replacement] property change or a Type change) 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. A Type change into or out of AWS::CloudFormation::Stack is now listed under Blocking (cdkd deploy will refuse): and exits 3, 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) -- new pasteableAwsCommand / WITHHELD_AWS_COMMAND in src/provisioning/replacement-protection-advice.ts, sharing renderDisableCommand's gate; call sites in the dynamodb-delete-budget, ecs, rds, route53, elbv2, acm-certificate, iam-managed-policy, sns-topic, cloudfront-distribution, cognito, eventbridge-rule and ec2 providers; unit tests in tests/unit/provisioning/pasteable-aws-command.test.ts and 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 (and deployments/*.jsonl lines, for the thrown Route 53, ACM and ECS messages). Each value is now checked with displaySafe(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-wait warning renders its template InstanceId display-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 in tests/unit/provisioning/apigateway-provider.test.ts, tests/unit/provisioning/servicediscovery-provider-service-attributes.test.ts and tests/unit/provisioning/providers/glue-provider.test.ts. The aws apigateway delete-method command (template RestApiId, ResourceId, HttpMethod) and both aws servicediscovery delete-service commands (the interrupt handle and the cleanup-failure warning) now render through pasteableAwsCommand: 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-CatalogId skip 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-unlock renders 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 in tests/unit/cli/force-unlock-display.test.ts. Without --stack-region, the command unlocks every region listStacks() finds for the name, and it printed each region -- an S3 key segment anyone able to write the state bucket chooses -- raw in Force-unlocking stack:, Lock released for stack:, No lock found for stack: and Failed to unlock stack, and again as a row of the multi-line Failed 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 in tests/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, and saveState / 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, so Lost the lock for stack 'MyStack' (us-east-1) now reads Lost the lock for stack MyStack (us-east-1). The raw S3 keys listRawObjects and deleteRawObjects report, which embed a stack name, take the same guard.
  • cdkd local invoke and cdkd local invoke-agentcore no longer put the container's own stdout ahead of the response payload (issue #2419) -- src/local/docker-runner.ts, doc comments in src/cli/commands/local-invoke.ts, src/cli/commands/local-invoke-agentcore.ts and src/utils/logger.ts, docs/local-invoke.md, docs/local-invoke-agentcore.md, docs/cli-reference.md, the local-invoke integ fixture, and unit tests. The Lambda runtime emulator puts START / END / REPORT and every handler log line, console.error included, on the container's stdout, and streamLogs piped that straight into cdkd's stdout, so cdkd local invoke MyStack/Fn | jq failed on any handler that printed. The container's stdout now follows the same stdout reservation that already routes cdkd's own logger and docker pull progress: on these two commands it joins the container's stderr on cdkd's stderr, moved rather than dropped. cdkd local start-api, local run-task and local start-service reserve 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 prints Building container image (platform=...) on stdout, so | tail -1 is still needed there (#2429).
  • cdkd local run-task recognises a FIPS, dual-stack (on.aws) or upper-cased ECR host as a cdkd asset image in a region bootstrapped with cdkd 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 local cdk.out build, and was pulled from ECR instead. The test is now hasEcrRegistryHostLabels, built from ECR_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; parseEcrRegistryHost still decides which hosts get a docker 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's Inspect it with:), new plainOrDescribed / quotedOrDescribed in src/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 as Retry 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 like Prod* or Stage/Prod now also gets a hole in Destroy 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 --replace when the refusal's message happens to contain "does not support UPDATE" (issue #3757) -- src/deployment/retryable-errors.ts (isUpdateUnsupportedError), with unit tests in tests/unit/deployment/retryable-errors.test.ts and tests/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 typed ResourceUpdateNotSupportedError interpolates template-chosen text (the Glue rename refusal quotes both names), so a name carrying the phrase skipped the --replace opt-in. The classifier now refuses a typed rejection outright; it replaces only under --replace, as documented.

  • A Glue CatalogId change is refused instead of updating the same-named resource in another Data Catalog (issue #3756) -- refuseCatalogMove in src/provisioning/providers/glue-provider.ts, called from GlueProvider.updateDatabase / updateTable and GlueConnectionProvider.update; unit tests in tests/unit/provisioning/providers/glue-provider.test.ts; phase 2d of the glue-update-hardening fixture. Each update sent the template's CatalogId while the state record names only the resource, so a changed catalog addressed the same-named database, table or connection in the other catalog. CatalogId is not createOnly on AWS::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 a ResourceUpdateNotSupportedError naming both catalogs, and cdkd deploy --replace (plus --force-stateful-recreation for a table or database) moves the resource. An absent CatalogId, the AWS::AccountId pseudo parameter and the caller's own account id count as one catalog, so switching a database between them still updates in place (one sts:GetCallerIdentity call). readCurrentState now carries the recorded CatalogId back, so cdkd drift --revert updates 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 malformed AWS::IAM::AccessKey Status or AWS::Lambda::Url AuthType; a changed, malformed AWS::AppSync::GraphQLApi UserPoolConfig / OpenIDConnectConfig / LambdaAuthorizerConfig / AdditionalAuthenticationProviders / EnhancedMetricsConfig / EnvironmentVariables block; a changed, malformed BillingMode on AWS::DynamoDB::Table and AWS::DynamoDB::GlobalTable, and StreamSpecification or a non-array GlobalSecondaryIndexes on the GlobalTable; a changed, malformed AWS::SecretsManager::Secret GenerateSecretString; and a malformed AWS::S3::Bucket lifecycle, notification, replication, object-lock, metrics, analytics, intelligent-tiering or inventory configuration that the update would Put.
    • The rollback revert arms (replayingState) and cdkd 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::DBProxyTargetGroup TargetGroupName and AWS::Lambda::EventInvokeConfig Qualifier, both create-only, so the only template fix is a replacement. The Glue DatabaseInput arm is not part of this change.
  • 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 (groupBlockedReasons with blockedRowId / blockedRowType; buildImportPlan's redaction-mask refusal now routed through importRepairCommand), scripts/check-pasteable-command-shapes.ts (its one exemption deleted, a CDKD_PASTEABLE_EXEMPTIONS test seam honoured only with --root=), and unit tests in tests/unit/cli/export-{identifier-overlay,composite-identifier}.test.ts and tests/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 a Repair with: line, so a template key carrying a newline, or padding that wraps on screen, could print a counterfeit Repair 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's A::B / A::B::C shape (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 one Repair 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 its cdkd import ... --resource <id>=<physicalId> --force remedy inside a quoted sentence with the logical id interpolated, where a pasted id ran. It now prints Repair 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-index ProvisionedThroughput of {0, 0} on an AWS::DynamoDB::Table whose index capacity changes or is new; a malformed AWS::Cognito::UserPool MfaConfiguration; a changed, malformed AWS::S3::Bucket VersioningConfiguration.Status or LoggingConfiguration member; an AWS::Route53::RecordSet update that renames the record into a name containing |; and an AWS::EC2::Route declaring more than one destination.
    • Each used to warn and continue on every update, because update() could not tell a template deploy from a rollback or cdkd drift --revert replaying a state record. The rollback revert arms (replayingState) and drift --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 an AWS::Route53::RecordSet whose recorded name already contains it, and on an AWS::EC2::Route (both segments are create-only). The only template fix there is a different resource.
    • The Glue DatabaseInput arm is not part of this change.
  • A Glue TableInput.Name rename is refused instead of written onto another table (issue #3724) -- GlueProvider.updateTable / updateDatabase and GlueConnectionProvider.update in src/provisioning/providers/glue-provider.ts, unit tests in tests/unit/provisioning/providers/glue-provider.test.ts, and phase 2c of the glue-update-hardening fixture. 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, UpdateTable addresses the table by TableInput.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 a ResourceUpdateNotSupportedError naming both names, and cdkd deploy --replace (plus --force-stateful-recreation for 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 diff prefetches 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 generated src/provisioning/create-only-snapshot.generated.ts (from scripts/gen-property-coverage.ts), scripts/refresh-cfn-schemas.mjs and a new createOnlyPropertyPaths field in every tests/fixtures/cfn-schemas/*.json, src/cli/commands/diff-recursive.ts, src/deployment/deploy-engine.ts, with unit tests. cdkd diff used to run one cloudformation:DescribeType lookup 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. All DescribeType calls 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 (extractGetAttRefs now records Ref reads; promoteInPlaceAttributeDependents gains the custom-resource Ref case and a fresh-parameter arm), DeployEngine.freshNoEchoParameters, carryFreshNoEchoMark in src/deployment/secret-redaction.ts and its call in the resolver's inherited-parameter recording, unit tests in tests/unit/deployment/ and tests/unit/analyzer/, and a new stack plus phase 6 in the custom-resource-noecho-nested fixture. A resource reading a custom resource with Ref kept the old physical id when the handler answered an Update with a new PhysicalResourceId, because the diff promoted Ref readers only for a predicted replacement; Ref readers 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 a NoEcho value 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 a NoEcho value 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 runtime DescribeType. A misspelled key on a new resource, or one newly added, now fails at Cloud Control with Unsupported 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.

  • Ref to an AWS::EC2::VPCCidrBlock returns 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's Ref returns the association id (vpc-cidr-assoc-…). A hand-written Ref to one resolved to the raw composite. The type now joins REF_RETURNS_SEGMENT_BEFORE_FIRST_PIPE; a pipe-free id passes through unchanged. CDK's own dual-stack Vpc only DependsOn the resource, so generated templates were not affected.
    • The cdkd export splitter'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.
  • cdkd state's legacy-record refusals and state 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 by state resources / state show; refresh-observed's legacy refusal; state orphan's Destroy with: line), withheldTargetClause's docblock in src/utils/pasteable-command.ts, and unit tests in tests/unit/cli/state-{refresh-observed,resources,show,orphan}.test.ts. The Migrate with: cdkd deploy ... and Destroy 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 as Migrate with: cdkd destroy --all --force #. Each now also requires a plain identifier (a letter or digit, then letters, digits, ~, _, . or -, the rule cdkd drift already applies). Any other name prints a quoted '<stack>' hole with a sentence saying why, and refresh-observed, whose name comes from an S3 key, no longer shows such a name in its refusal message either; cdkd state list --long shows 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 targeted GetAuthorizationToken's proxyEndpoint, 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 with no basic auth credentials. The push URI and the docker login endpoint are now built from one registry host, the rule cdkd 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-protection now 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, plus destroy-runner-count-protected.test.ts and remove-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 LoadBalancerAttributes entry 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 resourceType naming an Object.prototype key no longer throws before the prompt.
    • The --remove-protection table in docs/cli-destroy.md gains the AWS::DynamoDB::GlobalTable and AWS::EMR::Cluster rows.
  • cdkd import adopts Cloud-Control-routed types whose identifier is composite, such as AWS::EC2::VPCCidrBlock (issue #3672) -- src/provisioning/cc-import-identifier.ts (new), CloudControlProvider.import(), tests/unit/provisioning/cc-import-identifier.test.ts, and the tests/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 bare vpc-cidr-assoc-… id, not <Id>|<VpcId>. --migrate-from-cloudformation passed CloudFormation's id to GetResource unchanged, so the lookup failed and the import did not adopt the resource. The provider now reads the type's primaryIdentifier from the registry schema. It fills every other field from the template's literal values, after Ref substitution, and records the completed identifier. When the template cannot supply exactly the other fields, the import fails for that resource and names the composite --resource value 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-agentcore now 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 with no basic auth credentials: the plain form still logged in to GetAuthorizationToken's proxyEndpoint, which names the CALLER's registry. docker login now always targets the host docker pull uses. Three host shapes AWS does not serve are now refused instead of receiving a login: a dual-stack on.aws host outside the aws / aws-us-gov partitions, a FIPS host outside those partitions (AWS lists dkr.ecr-fips endpoints 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 future nz-north-1, the way il- / mx- arrived) is now refused too, until cdkd learns the prefix: local run-task pulls such an image anonymously as a public image and logs at debug level that the host looked like ECR, and local invoke / start-api refuse it as an ECR URI cdkd cannot authenticate against. Upstream counterpart: go-to-k/cdk-local#766.

  • A consumer of a NoEcho custom-resource value is updated when the handler returns a new value (issue #3662) -- DeployEngine's UPDATE arm, carriesFreshNoEchoValue in src/deployment/secret-redaction.ts, DiffCalculator.promoteInPlaceAttributeDependents, unit tests in tests/unit/deployment/ and tests/unit/analyzer/, and the custom-resource-noecho-nested fixture, which drops its Description workarounds and gains a phase. The post-resolution no-change check compared the redacted bag with the record, and a NoEcho value 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 a NoEcho value 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's Data, 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's Outputs.<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-cloudformation now 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 new tests/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 resource and retired the CloudFormation stack with them still running. The next cdkd deploy created the route tables a second time and failed on the conflicting associations, and cdkd destroy could 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 attributes create() 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 CDK ec2.Vpc with a NAT gateway, a custom network ACL and an instance imports as 30 imported, 0 not found, and the following deploy changes nothing. Before this fix the same stack imported as 10 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 in tests/unit/deployment/resolver-value-cache-identity.test.ts. A library caller that installed AwsClients for one account, resolved, then installed AwsClients with 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, and Fn::GetAZs lists 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-agentcore now 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.aws or an ISO / EUSC plain host was treated as a public image and pulled anonymously, which failed with no basic auth credentials. The bundled engine now recognizes every form AWS serves (FIPS and dual-stack only in the commercial and GovCloud partitions), always runs docker login against the host docker pull uses, 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): a docker 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 of source.executable, display-safe, in its (...), and a docker or executable spawn error reads Failed 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 logicalId are 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's logicalId be a string, so two healthy records carrying the same one (the empty string included) both passed and then collapsed: the rollback save merges orphans by logicalId and 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 whose logicalId another record also carries is now treated like any other unusable record -- cdkd deploy, cdkd destroy (both reads), cdkd rollback, cdkd import, cdkd orphan and a real cdkd scrub refuse the record naming each of them (STATE_RESOURCES_MALFORMED); cdkd scrub --dry-run and cdkd diff drop them and warn by name, and cdkd diff --fail counts them. Repair by keeping one record per logicalId.

  • 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 notices cdkd deploy prints, the refusal it throws, the Adopting ... line and its debug lines -- and cdkd diff --json carried the same refusals in its blocking field unsanitized (its terminal render already stripped them). state.json is 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 import and cdkd diff --recursive render 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 scrub printed 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 --fail CI gate prints. cdkd import --migrate-from-cloudformation's nested-stack refusals and parameter-dependence warnings, and cdkd diff --recursive's nested-row refusals, carried the same shape. Each name now renders through displayIdent (a stack name through displayStackName, an aws:asset:path through displayAssemblyPath): 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 the tests/integration/custom-resource-noecho-nested/ fixture. The parent's diff runs before the child deploys, so a resource reading Fn::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 an AWS::CloudFormation::Stack now promotes every reader of its Outputs.* 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 diff lists 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 form Fn::GetAtt: "Child.Outputs.Key" is recognised as a reference too. A reader with any read of a value stored as the *** mask (a NoEcho custom resource's) is not promoted this way, since the deploy would refuse that read; lifting that goes with issue #3662.

  • cdkd deploy now heals DynamoDB StreamArn, IAM Role RoleId and path-bearing IAM Arn attributes 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 new tests/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 StreamArn and IAM Role RoleId arms returned undefined;
    • the IAM Role / User / Group / InstanceProfile Arn arms 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.

  • cdkd import now records the attributes create() 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 new tests/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's name|id|scope physical 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 to GetVectorBucket as 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 with GetWebACL. WebACLSummary has no such member, so the value was always absent.
    • A state machine now records its StateMachineRevisionId from DescribeStateMachine. Before, it recorded stateMachineVersionArn, a version ARN that is null unless the machine is published. A machine that was never updated reports INITIAL, as CloudFormation does (measured live).
  • cdkd import now records the attributes create() 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 new tests/integration/import-attribute-readback-misc/ fixture. After cdkd import, including --migrate-from-cloudformation, a sibling's Fn::GetAtt got these values:

    • SSM parameter Type / Value: the parameter name.
    • IAM instance profile / user / group Arn under 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 calls GetEvaluator, as its id form already did, and returns not-found for a missing evaluator. Importing an evaluator by ARN therefore now needs bedrock-agentcore:GetEvaluator. An SSM parameter's Value is recorded only when the template declares a plain literal. A {{resolve:...}} dynamic reference or a Ref (for example, to a NoEcho parameter) would put a plaintext into state that no redaction on the import or heal path covers.

  • 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 through displayIdent (a stack name through displayStackName, a command path through displayAssemblyPath): 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 on cdkd diff), a stack template and a stack metadata file no longer repeat the path inside Node's own open '...' text, which is shown as open '<path>' since the message already names the path, bounded; a parse failure there reads invalid JSON rather than echoing a window of the file's bytes.

  • A cdkd deploy that 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 --accept refusal wording), docs/cli-drift.md, docs/cli-state.md, docs/import.md, and the secrets-array-nested integ. Where cdkd could not pair a secret reference with the value AWS reported, the recorded baseline holds the mask *** there (issue #2852), and cdkd drift reports 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. A NoEcho custom-resource mask is unchanged, and so is cdkd state refresh-observed.

  • The synthesizer's account lookups and the cdkd local ECR puller now use explicitly supplied AwsClients credentials (issue #3588) -- src/synthesis/synthesizer.ts, src/local/ecr-puller.ts; tests in tests/unit/synthesis/synthesizer-macro-integration.test.ts, tests/unit/local/ecr-puller.test.ts and tests/unit/utils/ambient-client-defaults-fence.test.ts. A library caller that installed AwsClients with an explicit credentials object had the synthesizer's two GetCallerIdentity calls (the synth-time account and the macro-expansion default state bucket) and the ECR puller's GetCallerIdentity, AssumeRole and ECR login sign with the SDK's default credential chain instead. They now go through ambientClientDefaults() / clientDefaultsFor(), with an assumed --ecr-role-arn role 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 AwsClients credentials (issue #3588) -- src/provisioning/providers/s3-tables-provider.ts; tests in tests/unit/utils/ambient-client-defaults.test.ts and tests/unit/utils/ambient-client-defaults-fence.test.ts. A library caller that installed AwsClients with an explicit credentials object had every AWS::S3Tables::TableBucket / Namespace / Table call sign with the SDK's default credential chain instead. Its client now goes through ambientClientDefaults(), 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 and export.ts's two lookups) are CLI-only, reached by no library entry point, and stay as they are.

  • cdkd drift now reads back the local replica of a multi-region AWS::DynamoDB::GlobalTable (issue #3573) -- src/provisioning/providers/dynamodb-globaltable-provider.ts, src/cli/commands/drift.ts, src/types/resource.ts; tests in tests/unit/provisioning/dynamodb-globaltable-provider-local-replica-readback.test.ts and tests/unit/cli/drift.test.ts. On a table with a cross-region replica, DescribeTable in the deploy region lists only the other regions, so the readback left the deploy-region entry out of Replicas. Drift on that replica's tags, point-in-time recovery, contributor insights and read ceilings was invisible, and after a reverse-replacement rollback cdkd drift reported Replicas on every run. The readback now appends the local entry, and Replicas is 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 --revert applies the same completion to the bag it sends, so reverting another replica leaves the local table's tags and read auto-scaling alone, and --accept stores 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.prototype member (constructor, toString) is removed on update (issue #3515, its in-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 newMap of a plain object, which answers true for 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 Control remove patch op, a Cloud Map service attribute, a V2 stage variable / integration request parameter / route request model, and a Method requestParameters entry. CloudFront skipped the removed top-level member instead of warning about it. A NEW key of that name was sent as replace instead of add, both on a Method map and in the Cloud Control patch. Every site uses Object.hasOwn now.
    • Three deep-equality checks compared an own __proto__: {} key against another key as EQUAL: the diff's nested-map compare (NO_CHANGE where it is an UPDATE), the Cloud Control patch (no replace), and the S3 lifecycle-rule fold (the unfolded Filter kept). 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 a Ref named constructor that names no resource.
  • cdkd diff now exits 3 over a record whose DROPPED container cdkd deploy refuses, instead of 0 (issue #3512) — an unreadable resources map, a resources entry that is not a resource record (a null row, or an object with no resource type, torn properties map or not), an orphans field that is not a list, and an orphans record the adoption preview cannot read. Each was already named in the preview, in --json's unreadable and in the --fail count, so plain cdkd diff exited 0 and --fail exited 1 while the next deploy refused the record. Each now also puts one reason per container on the node's blocking, the rule #3335 set for the properties map and outputs bag: countBlocking raises DeployRefusalPreviewError (exit 3), ranked above --fail. The unreadable rows are unchanged, so --json consumers see the same unreadable list and a new blocking entry. TOP-LEVEL only, as for #3335: a nested child keeps its warning and its unreadable row 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 aborts cdkd diff with a bare RangeError. 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) -- crossStackReadsForPartialSave and the stale-attribute heal memo in src/deployment/deploy-engine.ts, recordRecoverableMaskedOutput / recoverMaskedOutput in src/deployment/secret-redaction.ts and their callers in the resolver and NestedStackProvider, 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 from state.imports, the list cdkd destroy refuses a destroy on. The keys are now an injective encoding, as the other composite keys became in #3501. The in-run recovery store for a NoEcho output masked in the same process now carries the credential identity as well, so a library caller that switches AwsClients between 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 state messages 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, new scripts/check-raw-log-interpolation.ts + tests/raw-log-interpolation-baseline.json. ConsoleLogger sanitized 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 by safeMsg, a tagged template that keeps the template's newlines and flattens each value; cdkd state's messages are the first to use it, and its destroy --all confirmation list, written to stdout directly, renders each name through displayIdent. 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-unlock recovery 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. buildForceUnlockCommand kept 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=attacker rendered exactly and came out as cdkd 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 through pasteableCommand and 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, including changed 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 in displayStackName'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 cdkd commands leave their own gates, the SSM import refusal's --resource fragment 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. The buildImportPlan half landed in #3763. gc.ts, export.ts's cdkd state show line and malformed-resources-bag.ts's inspectCommand build through the shared gate. At the last two, with plainIdent on 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 new maxCodePoints; an absent region still omits --stack-region, and inspectCommand's no-name arm still returns its two-hole template. gc.ts keeps its outer isPasteableIdent gate and the default cap, withholding the whole line. cdkd export's drift-gap report prints cdkd state refresh-observed only 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 when isPasteableIdent admits it, quotes the placeholder and bounds the displayed id at 1152. cdkd rollback's two --orphan remedies move onto a labelled To orphan it: LAST line of their own (kept per line on the Rollback failed for log 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 scrub no 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 stored imports[].exportName, outputReads[].sourceStack or outputReads[].outputName written 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 printed No plaintext secrets found and --fail exited 0 with the plaintext still in state.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 --fail exits 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 rollback now refuses a state record whose region field disagrees with its key while it still lists resources (issue #3370) -- src/cli/commands/rollback.ts, docs/cli-rollback.md, docs/state-management.md, plus rollback-divergent-region.test.ts.

    • getState reported the divergence and cdkd rollback dropped 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 destroy refused 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 deploy no 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, plus diff-calculator-malformed-properties.test.ts, deploy-engine-malformed-entries-refusal.test.ts and cases in malformed-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 a null (or any non-object) entry in resources, 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 no resourceType was planned as a type-change replacement. cdkd deploy now refuses such rows by name (STATE_RESOURCES_MALFORMED, exit 1) at its state load, before any resource is created, updated or deleted and before any state is written, under --dry-run too; calculateDiff refuses them as well. A default deploy used to die before the diff on a bare TypeError (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 diff still 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 and reportValue), new tests/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 through safeMsg at 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 detected row 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. --json is 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 resources ROW 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 in src/state/malformed-resources-bag.ts. cdkd destroy / cdkd state destroy refuse such a row before the prompt and the lock, and again on the record re-read under the lock (a null row died on a bare TypeError in 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 on undefined). cdkd deploy's pre-lock --recreate-via-* check refuses the bag and the rows before validating, so a null named row is no longer reported as missing from state. cdkd scrub refuses on a real run (exit 2) and, under --dry-run, drops the rows, warns, and reports a malformedResourceRows finding. A selective cdkd import refuses pre-flight only the unreadable rows it would NOT re-import -- a row named by --resource is that row's repair -- and refuses again on the assembled map when a listed row's import did not succeed; whole-stack --force and --migrate-from-cloudformation replace the map. Every cdkd local * --from-state load 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-route accepted-drop warning no longer tells a type with no Cloud Control route to remove the override (issue #2792) -- src/provisioning/provider-registry.ts (reportSilentDropDecisions, new ccRouteUnavailableReason), tests/unit/provisioning/provider-registry-unroutable-drop-warn.test.ts. For every accepted silent drop the warning said "Remove the override for to 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, and Fn::Sub refuses 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, new tests/unit/deployment/rollback-executor-proto-key.test.ts, tests/unit/deployment/intrinsic-prototype-chain-reads.test.ts.

    • resolveReplayProps rebuilt each replayed bag onto a {} literal, so a property literally named __proto__ (an own key after JSON.parse) ran the inherited setter and was silently absent from what provider.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::Sub template (first element, or the scalar form) died at template.matchAll is not a function. It is now refused as Fn::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 use Object.hasOwn. No behavior change: the maps have had a null prototype since #2739, so ${constructor} already fell through to Ref; the rendered value (the kept placeholder) is now pinned. The drift --revert half of the issue shipped in #3124.
    • Swept from review: an AWS::ApiGateway::Stage update tested key in variables / key in overrides to decide removals, so dropping a stage variable (or canary override) named constructor / toString emitted no remove op and the variable stayed live. Both use Object.hasOwn now; the rest of that in population is a row on #3515.
  • The --remove-protection help now names AWS::EMR::Cluster and AWS::DynamoDB::GlobalTable (issue #2660) -- src/provisioning/remove-protection-types.ts, src/cli/options.ts, src/cli/commands/state.ts, plus remove-protection-types.test.ts and cc-protection-doc-coverage.test.ts. Both providers flip their protection off under the flag, but cdkd destroy --help and cdkd state destroy --help each 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_TYPES followed by the Cloud Control protection registry. A unit test binds that list to the provider files that read removeProtection, in both directions. The docs/cli-destroy.md type 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, plus stateful-types.test.ts and diff-calculator-createonly.test.ts.

    • AWS::CloudFormation::Stack joins the stateful guard. Its StackName is immutable in the registry schema, so a StackName edit deployed with --prefer-sdk-route AWS::CloudFormation::Stack:StackName diffed 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-recreation is passed.
    • AWS::EC2::Instance and AWS::SQS::Queue deliberately stay off the guard: the instance's root volume is ephemeral by design (persistent data belongs on the guarded AWS::EC2::Volume) and a queue's backlog is transient.
  • Renaming an AWS::S3Tables::Namespace no 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 DeleteNamespace cascades 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::TableBucket and AWS::S3Tables::Table stay guarded.
  • cdkd local start-api / local invoke bracket 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 :: printed Server listening on http://:::3000, which no URL parser accepts, and the per-API route-table header had the same defect. An IPv6 --container-host on either command also broke the RIE readiness probe and the Lambda invoke, because fetch('http://::1:<port>/...') throws Invalid URL before sending. Every one of these sites now composes its authority through formatAuthority, 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; the start-alb / start-service / start-cloudfront / start-agentcore banners already carry that fix through the cdk-local dependency.

  • A container-image Lambda function (PackageType: Image) declaring CodeSigningConfigArn or RuntimeManagementConfig is now refused by cdkd before any Lambda call (issue #1894) -- src/provisioning/providers/lambda-function-provider.ts (refuseImageFunctionUnsupported, called from create and update), tests/unit/provisioning/lambda-image-function-refusal.test.ts, tests/unit/provisioning/lambda-function-props-609.test.ts, and a new phase in tests/integration/docker-image-asset/. AWS rejects both properties on an image function with InvalidParameterValueException: 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 -- for RuntimeManagementConfig on 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 matching Get* 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 runs cdkd drift on 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 as arn:aws:glue:...: the AWS::Glue::Workflow ARN behind its drift read's GetTags, and the shared builder behind the Job / Crawler / Trigger drift reads' GetTags and their TagResource / UntagResource tag updates. In aws-cn / aws-us-gov that 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 said update() takes no context or cannot tell a rollback replay from a template deploy (issue #1999); comment-only, no behavior change.

  • cdkd drift no longer reports a phantom GlobalSecondaryIndexes / LocalSecondaryIndexes difference for an AWS::DynamoDB::Table whose baseline an older cdkd captured, and a key-order-only Projection difference 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 DescribeTable description (IndexStatus, ItemCount, IndexArn, the on-demand {0, 0} capacity, a computed WarmThroughput with Status, ...), and every cdkd drift reported the index list as drifted until the next deploy or --accept. canonicalizeDriftProperties now 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 desired KeySchema / Projection with JSON.stringify, so a template-ordered {NonKeyAttributes, ProjectionType} against a readback-ordered one (reachable via cdkd drift --revert) threw "changed its KeySchema or Projection". The compare now ignores object key order; KeySchema element order stays significant.
  • A Cloud Control-routed S3 bucket records its Arn in the right partition (issue #1794) -- src/provisioning/cloud-control-provider.ts, its unit tests, docs/provider-rules.md. When Cloud Control did not report an AWS::S3::Bucket Arn, cdkd filled it in as arn:aws:s3:::<bucket> in every region, so a CC-routed bucket in aws-cn or aws-us-gov recorded (and served to Fn::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. An AWS::Glue::Table id 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, so cdkd destroy deleted a different table, update targeted it, and cdkd drift read it. The three decode sites now anchor on the DatabaseName recorded in state and take the rest of the id as the table name. Without a usable DatabaseName, 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, and createTable / import still refuse a | in either name, because the Ref resolver still returns the segment after the last |.

Recently Implemented (2026-09-24):

  • cdkd deploy --verbose no longer prints a NoEcho custom-resource value on its Resolved 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 new tests/integration/custom-resource-noecho-nested/ fixture, whose --verbose log scan found it on real AWS. A value a handler declared sensitive with NoEcho: true was already masked in state.json, but each Fn::GetAtt branch 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 53 NameServers normalization 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 the NoEcho channel relies on (issue #2460): nested-stack outputs and Fn::ImportValue in one cdkd deploy --all.

  • cdkd local Docker 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 deleted src/cli/commands/emulator-docker-context.ts, and src/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 of local invoke / start-api / invoke-agentcore / start-service / start-alb. An escaping source.directory is still refused before anything is built, and the refusal now reads Docker image asset has source.directory=../victim which resolves to ... instead of cdkd's Refusing to build the container image: .... start-service / start-alb check 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's source.executable warning now says cdkd runs it. Separately, displayAssemblyPath escapes a default-ignorable mark (U+034F, U+17B4, U+180B, the variation selectors) even directly after a letter, so /home/me/.ss\u034fh no longer prints like /home/me/.ssh.

  • Fn::GetAtt of 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 new tests/unit/provisioning/docdb-neptune-getatt-names.test.ts, and tests/integration/docdb-neptune/. Both providers recorded only RDS-style dotted keys (Endpoint.Address, Endpoint.Port, ReadEndpoint.Address). CloudFormation names these attributes Endpoint, Port and ReadEndpoint for DocDB and Neptune. So on a normal cdkd deploy, Fn::GetAtt [Cluster, Endpoint] warned Unknown attribute and resolved to the cluster identifier. That is what CDK's clusterEndpoint reads, so an application got a connection host that is not a hostname. The providers now record the CloudFormation names in create(), update() and import(), and keep the dotted keys. A record written before the fix heals on the next cdkd deploy: the miss triggers the stale-attribute re-read (#1852), which now reports these names. The cluster import() also returns these attributes, where it returned none (#3627).

  • cdkd import now records the template's DeletionPolicy / UpdateReplacePolicy on each imported record (issue #3645) -- src/cli/commands/import.ts (buildStackState), tests/unit/cli/import-build-state-policies.test.ts, and the new tests/integration/import-retain-destroy/ fixture. cdkd destroy reads DeletionPolicy from state only, and an imported record carried none. So a cdkd destroy run after cdkd import and before the first cdkd deploy deleted resources the template declares DeletionPolicy: Retain, and deleted Snapshot resources 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-cloudformation then destroy --force deleted a Retain SSM 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 next cdkd deploy, or by re-running cdkd import for those resources. Until then, do not run cdkd destroy on it.

  • AWS::EMR::InstanceGroupConfig now records InstanceGroupId as well as Id in its attributes (issue #3632) -- src/provisioning/providers/emr-instance-group-config-provider.ts, plus tests. The CFn schema renamed the read-only attribute from Id to InstanceGroupId (#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, and cdkd orphan could not rewrite it. An aws-cdk-lib regenerated from that schema emits the new name as attrInstanceGroupId. create, update and getAttribute now serve both names as the instance group id, which is also the physical id. A group whose state was written before this change keeps only Id until its next update or replacement. Until then, InstanceGroupId still resolves through the fallback.

  • cdkd import now records the attributes create() records for AWS::SNS::Topic, AWS::DynamoDB::Table / GlobalTable, AWS::IAM::Role and AWS::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 new tests/integration/import-attribute-readback/ fixture. These types' import() returned no attributes, and the resolver could not produce them from the physical id. After cdkd import, a sibling's Fn::GetAtt got 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 Role RoleId: undefined.
    • IAM Role Arn: an ARN with a non-/ Path dropped.
    • 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 next cdkd deploy for 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 DynamoDB StreamArn, IAM RoleId or path-bearing IAM Arn rows: 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 needs lambda:GetEventSourceMapping.

  • cdkd import now records the attributes create() 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 new tests/integration/import-attribute-readback-network/ fixture. After cdkd import, including --migrate-from-cloudformation, a sibling's Fn::GetAtt got these values:

    Resource Attributes Value before the fix
    ELBv2 load balancer DNSName, CanonicalHostedZoneID, LoadBalancerFullName, LoadBalancerName the load balancer ARN
    ELBv2 target group TargetGroupFullName, TargetGroupName the target group ARN
    EC2 subnet AvailabilityZone the subnet id
    Cloud Map service Name the srv- id
    EFS access point Arn refused
    CloudFront OAI S3CanonicalUserId the OAI id

    Each import() now reads these back, and the #1852 deploy-time heal serves them for records imported earlier. The OAI import() now verifies the id exists and needs cloudfront:GetCloudFrontOriginAccessIdentity. Separately, ELBv2 TargetGroupFullName is now CloudFormation's targetgroup/<name>/<id>. create() / update() dropped the targetgroup/ prefix, which broke a CloudWatch TargetGroup dimension built from it. A target group created before this fix keeps the old value until its next update or re-import.

  • cdkd import now records the FunctionUrl / FunctionArn attributes of an AWS::Lambda::Url (issue #3624) -- src/provisioning/providers/lambda-url-provider.ts, tests/unit/provisioning/lambda-url-provider.test.ts, and the new tests/integration/import-lambda-url-attributes/ fixture. LambdaUrlProvider.import() returned an empty attribute map, and neither attribute can be derived from the physical id. So after cdkd import (including --migrate-from-cloudformation), a sibling's Fn::GetAtt [<Url>, FunctionUrl], such as a CloudFront FunctionUrlOrigin, was written to its record as the raw intrinsic, and every later cdkd diff / cdkd deploy warned Unknown attribute FunctionArn. import() now reads both attributes from GetFunctionUrlConfig, and returns not-found when no URL config exists. Importing a function URL now needs lambda: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 next cdkd deploy. Measured live: that deploy UPDATEs the dependent resource once with the resolved value, and the deploy after it reports no changes. cdkd diff does not preview that update (#3456). Separately, an update() 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-task and local invoke-agentcore now fold an upper-cased AWS_REGION / AWS_DEFAULT_REGION at 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-state integ fixtures, plus tests. Each command folded the --region flag and the region chains it builds itself, but the SDK clients it creates with no region read AWS_REGION directly and raw. So AWS_REGION=US-EAST-1 cdkd local invoke --from-state without --region failed outright with Failed to verify state bucket ... HeadBucket (HTTP 400), and applyRoleArnIfSet's STS client and the --profile credential resolver were exposed the same way. The three handlers now call foldRegionOption, as local start-api does since #2103. local run-task captures the raw env spelling before the fold and passes it to the bootstrap-marker read as rawEnvRegion, so the marker read's second probe now also tries the key an AWS_REGION=US-EAST-1 cdkd bootstrap writes (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-cloudfront now refuse to forward secrets under CDK_DOCKER=finch on macOS / Windows unless CDKD_ALLOW_SECRETS_ON_ARGV=1 is 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 the limactl command 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 each start-agentcore / start-cloudfront container 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 own CDKD_ prefix, the name run-task uses.

  • 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 (new displayStackName), plus the call sites in src/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.ts and src/cli/commands/ (orphan, emulator-docker-context, synth, local-invoke-agentcore, local-start-api); an asset's free-form display name renders through displayAssemblyPath instead, 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 through displayIdent: a plain identifier bare (Stack MyStack has no templateFile property), anything else as one JSON string, with non-ASCII characters blanked. The sites in src/cli/commands/import.ts, export.ts, scrub.ts and diff-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-route help and the cdkd diff routing example now name a property cdkd really does leave unwired (issue #3615) -- src/cli/options.ts, docs/cli-diff.md. Both used AWS::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, and docs/cli-diff.md showed a [via CC API: RuntimeManagementConfig] annotation cdkd no longer prints. They now use AWS::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 in diff-recursive.ts, provider-registry.ts and recreate-targets.ts carried the same example and were updated too. No behavior change.

  • AWS::S3::Bucket no longer reads the SDK spellings of the inventory / analytics Destination block (issue #3602) -- src/provisioning/providers/s3-bucket-provider.ts, five tests/unit/provisioning/s3-bucket-provider-*.test.ts files, and the tests/integration/s3-analytics-inventory/ comments. A Destination written as the nested SDK { S3BucketDestination: { ... } } shape, or with a Bucket alias for BucketArn, deployed before and is now refused before any AWS call by the nested required-member check (#3567), because BucketArn and Format are schema-required. CloudFormation rejects these spellings too: the L1 CfnBucket.DestinationProperty declares only bucketArn / bucketAccountId / format / prefix. resolveS3BucketDestination now 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-substitute Format still reaches it. Same decision as #3585.

  • cdkd local run-task / invoke-agentcore now refuse to forward secrets under CDK_DOCKER=finch on macOS / Windows unless CDKD_ALLOW_SECRETS_ON_ARGV=1 is 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 KEY so its value stays off the docker run command line. finch on macOS / Windows resolves each such flag itself and rewrites it as -e KEY=<value> on the command line of the limactl process it starts, where other local processes can read it, so the documented guarantee did not hold there. A task with an ECS Secrets entry 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-service runs 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-role and --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 invoke now 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 which docker-credential-* helper the container client execs for the pull, and that helper inherits the client's environment. A secret named CLOUDSDK_PYTHON_ARGS reached docker-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's SHELLOPTS / PS4 and the BASH_FUNC_ exported-function prefix; Python's PYTHONPATH, PYTHONHOME, PYTHONUSERBASE, PYTHONPYCACHEPREFIX, PYTHONPLATLIBDIR, PYTHONWARNINGS and BROWSER; the CA bundles REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, HTTPLIB2_CA_CERTS and the TLS key log SSLKEYLOGFILE; Node's NODE_OPTIONS, NODE_PATH, NODE_COMPILE_CACHE, NODE_EXTRA_CA_CERTS, NODE_TLS_REJECT_UNAUTHORIZED; Ruby's RUBYOPT, RUBYLIB, GEM_PATH, GEM_HOME, RUBYGEMS_GEMDEPS; Perl's PERL5OPT, PERL5LIB, PERLLIB, PERL5DB; OpenSSL's OPENSSL_CONF, OPENSSL_MODULES, OPENSSL_ENGINES; gcloud's GCE_METADATA_HOST / _ROOT / _IP; and the whole CLOUDSDK_ prefix, since every gcloud property is an environment variable. CLOUDSDK_AUTH_ACCESS_TOKEN is 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-agentcore and cdkd local start-cloudfront now refuse an asset-manifest path that escapes the assembly, like every other cdkd local command (issue #3597) -- package.json (cdk-local ^0.149.2 -> ^0.149.3), src/local/docker-image-builder.ts, plus tests and docs/local-emulation.md. The bundled engine now contains every manifest path it reads. That covers the Docker build context of every image it builds (the start-agentcore container arm, a container Lambda behind a start-cloudfront function URL origin, the start-service / start-alb emulator), the --watch soft-reload source, which also refuses an absolute value outside the output directory, the AgentCore code bundle, and the <stack>.assets.json lookup. It also warns about BuildKit passthrough paths outside the assembly on each image it builds. start-cloudfront serves an absolute BucketDeployment source (the cdk synth --no-staging shape) only from a folder inside your project, with a warning, and never serves its hidden files. The local invoke / start-api / invoke-agentcore builds now hand the engine the app's output directory, so a cdk.Stage image's ../asset.<hash> still builds. A source.executable there 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 drift reports a baseline position it could not certify as not compared, not as drift (issue #3595) -- src/cli/commands/drift.ts (new partitionUncertifiedBaselineChanges, new uncertifiedBaseline cause, the --accept refusal wording), src/deployment/secret-redaction.ts (new isUncertifiedBaselineMaskPosition), src/analyzer/drift-calculator.ts (new equalModuloMask), docs/cli-drift.md, a new unit file and a re-pinned secrets-array-nested integ 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 read drifted (exit 1) on every run. When the mask is the only difference at that position, the resource is now listed under notCompared with the cause uncertifiedBaseline and 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. --revert sends AWS's own value at such a position instead of refusing the resource when a real change shares its property. A NoEcho custom-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's file=, source.path=, source.directory=, Stack metadata file, an inline Handler), the nested-template chain and cycle messages, the cdkd synth --verbose symbolic-link refusal, and the absolute-path, whole-assembly and Stage-root warnings in src/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.ts and src/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 through displayAssemblyPath (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 AwsClients now use explicitly supplied credentials (issue #3588) -- new src/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.ts and role-arn.ts, plus intrinsic-function-resolver.ts's client caches; tests in tests/unit/utils/ambient-client-defaults{,-fence}.test.ts and tests/unit/deployment/intrinsic-cfn-fallback.test.ts. A library caller that installed AwsClients with an explicit credentials object had every client cdkd builds for itself -- provider, context-provider and asset-publisher clients, and the source identity of the cross-account Fn::GetStackOutput sts:AssumeRole hop -- sign with the SDK's default credential chain instead. Those clients now carry the active AwsClients' profile and explicit credentials, in AwsClients' 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 the cdkd local ECR 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::Bucket no 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 spelling NoncurrentVersionTransitions[].TransitionInDays as NoncurrentDays, or a notification item's Topic / Queue / Function as TopicArn / 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 L1 CfnBucket types lack them, and no L2 emits them on AWS::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. The Transitions[] Days / Date aliases, the notification Events list and the inventory string booleans are unchanged, because the check does not refuse them.

  • Fn::Select now 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, new selectIndexPosition / lookupResourceRecord), comment updates in src/cli/commands/import.ts and src/utils/error-handler.ts, two new unit files and a new intrinsics-torture-2 arm. The Fn::Select index was used exactly as written: a Ref to a parameter or an Fn::FindInMap index (both valid CloudFormation) selected undefined with no warning, and a non-numeric string read the list's prototype chain ("constructor" gave the Array function). 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 whose physicalId is not a string (or that is not an object) made several Ref / Fn::GetAtt arms throw a bare TypeError; 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, new waitForRegionalCopyReleased / isReplicaCopyExistsRefusal) and a new unit file. Deleting a GlobalTable waits only until each cross-region replica has left the source table's Replicas[], and the replica region's own copy stays DELETING for 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. addReplica now reads the replica region on that refusal: while the copy is DELETING it 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_AUTHN with WebAuthnFactorConfiguration: MULTI_FACTOR_WITH_USER_VERIFICATION to a pool already at MfaConfiguration: ON now deploys (issue #3562) -- src/provisioning/providers/cognito-provider.ts. update() used to send UpdateUserPool before SetUserPoolMfaConfig. AWS refuses to add WEB_AUTHN to a pool at ON until its factor configuration is MULTI, so the edit failed, although CloudFormation deploys it. For that shape alone, the update now reads the live MFA configuration and sends SetUserPoolMfaConfig first. If the UpdateUserPool behind it then fails, the previous MFA configuration is put back and the original error surfaces. A failed read keeps the usual order. Lowering MFA from ON while adding EMAIL_OTP still 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's missing list is JSON.parsed, so a row keyed __proto__ arrives as an ordinary key. ContextProviderRegistry.resolve wrote its answers onto a {} literal, where that assignment ran Object.prototype's setter and DROPPED the value (success, unknown-provider and failure arms alike); ContextStore.save merged into the parsed cdk.context.json object 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 onto nullPrototypeRecord() and the store merges with defineOwnKey, 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__=x was silently dropped too; it now builds onto nullPrototypeRecord() 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, new displayAssemblyPath). 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 crafted directoryName, templateFile, asset file or aws:asset:path could 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 matched MyXStage1, and 'My(Stage*' failed with a SyntaxError, 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 covers deploy, diff, destroy, list, synth, publish-assets, scrub, the cdkd local stack 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-alb now REFUSE a Docker asset whose relative source.directory escapes 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 bundled cdk-local engine, which joins source.directory onto 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-task was the only local image build already contained. Behavior change: the three direct builds now go through a shim that judges the path with the same resolveDockerContextDirectory the deploy path uses, bound by the app's output directory (a cdk.Stage's ../asset.<hash> still loads), before the engine runs. start-service / start-alb hand the whole run to the engine, so cdkd checks every Docker asset in every stack's manifest at the strategy's resolveBoots, which the engine calls before any build, at startup and on each --watch reload (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 absolute source.directory is 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 inside cdkd local start-agentcore and start-cloudfront, which cdk-local runs end to end, and the directory a start-service / start-alb --watch soft 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. isReadableOrphanRecord is the one predicate for a usable row -- a WHOLE-record question, including a NON-EMPTY state.physicalId, the only handle on a resource cdkd left LIVE and the one field a resources entry is not asked for. Behavior change: cdkd deploy, cdkd destroy (both reads), cdkd rollback, cdkd import and a real cdkd scrub refuse such a record, naming the rows; cdkd scrub --dry-run DROPS it, warns, and reports it in the audited-record refusal (exit 2). cdkd diff drops by isPreviewableOrphanRecord, the NARROW half: a non-string logicalId or an empty or non-string state.physicalId goes, a torn properties / attributes map STAYS previewed -- and the preview now WARNS naming the row, at every node it reaches with an adoption preview, that cdkd deploy refuses the record over it; the TOP-LEVEL stack also exits 3, so a clean --fail run never precedes a deploy that cannot start. The quietest shape read as undefined field by field, so cdkd destroy printed 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 scrub no longer loses one row's secret needles to another row sharing its logicalId, which could save a plaintext; and cdkd 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 absent orphans field stays absent. Not in scope: a readable properties map's CONTENTS.

  • Resolver and template-parser log lines no longer print an Fn::Select index, an Fn::Split delimiter, 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: the Fn::Select out-of-bounds warning and its debug line, the Fn::Split debug line, the VPC Ipv6CidrBlocks lines, the ServiceDiscovery HostedZoneId and DescribeLaunchTemplates failure warnings (including the AWS error text, which echoes the ID), and the template validator's Resource <id> is not an object / missing Type errors 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::Volume is now deleted with EC2 DeleteVolume instead 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's DeletionPolicy: Snapshot final snapshot. Both times the handler deleted the volume while its snapshot was still pending, the volume stayed in deleting for 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 through CloudControlProvider now calls DeleteVolume and waits until the volume is gone, with the same 15-minute limit. A volume that is already deleting is waited on rather than failed. InvalidVolume.NotFound goes 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. The rollback-deletion-policy-snapshot and deletion-policy-snapshot integration fixtures now assert that no Cloud Control DeleteResource request 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::GetAtt text raw (issue #3441) -- src/deployment/intrinsic-function-resolver.ts. A resource Type, a parameter Type, a string-form Fn::GetAtt operand or a non-literal Fn::GetStackOutput RoleArn from 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, the RoleArn shape echo, the nested-stack secret-coercion refusal, and the unrecognized SSM parameter Type warning. 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 cdkd commands move out of prose quotes and off bare placeholders, and a source fence reports three shapes that put them back (issues #3436, #3307) -- new scripts/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: a cdkd verb 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; one export.ts refusal 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 -- pasting cdkd events prune <stack> --all beside a file named stack created --all before 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-region requirement. Its legacy region-less refusal names the stack on its Stack: line and in its Migrate with: command only for a plain identifier; otherwise no Stack: line, a quoted '<stack>' hole and a reason clause. state.ts's three Usage: lines and cdkd 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 --help names cdkd events prune '<stack>' --all, that command's real arity.

  • cdkd orphan refuses 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, outputs and each surviving resource's properties were 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 unreadable attributes map, or an unreadable orphans list or record, was saved as it was. All of these are now refused at the load, --dry-run included, with the same ways out as the properties refusal. A sibling's Ref, Fn::GetAtt or Fn::Sub of an orphaned record that cannot be read is now reported as unresolvable. Before, it was resolved to undefined or x-undefined. --force no longer reads an unreadable attributes cache, 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 a cdkd state show command 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, new tests/unit/provisioning/provider-prose-display-safe.test.ts. Issue #3136 made the pasteable aws ... 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 through displaySafe where it is interpolated, across both files: the partial-create cleanup warnings, the cdkd import refusal 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 (which JSON.stringify alone did not make terminal-safe). The AWS::Logs::LogGroup partial-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 before displaySafe, so a secret that contains a character displaySafe rewrites 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 as abcd-x, the secret minus its space, in deploy warnings and cdkd scrub output, 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 import now 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 the observedProperties baseline 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 same properties the 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 drift does not compare it until a deploy that creates or updates it, or a re-import that proves it, restores one.

  • A Date in 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 a Date has no own entries, so whenever the resource's secrets map was non-empty (a stack that resolved at least one {{resolve:...}} reference) the observedProperties drift baseline got {} at that field: a permanent phantom difference in cdkd drift, and a value cdkd drift --revert could push. The same walk runs over properties, attributes and outputs, and cdkd drift --accept and cdkd scrub reach it too. The walk now keeps an unmodified Date (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, since JSON.stringify persists 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-observed re-captures the baseline.

  • cdkd local run-task / cdkd local invoke now 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 in src/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 under CDK_DOCKER=podman a resolved secret named CONTAINER_HOST reached the client's environment and could redirect it -- the DOCKER_HOST hijack, 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 of ssh), the base directories they fall back to (XDG_CONFIG_HOME, XDG_RUNTIME_DIR, and on Windows APPDATA, PROGRAMDATA, PROGRAMFILES, LOCALAPPDATA), and AWS_ECR_CACHE_DIR. The JSDoc states the rule for additions; behaviour toggles such as STORAGE_DRIVER and CONTAINERD_SNAPSHOTTER stay 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-api now folds an upper-cased AWS_REGION / AWS_DEFAULT_REGION to 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 --region flag at its entry but read the two env vars raw, so AWS_REGION=US-EAST-1 cdkd local start-api handed US-EAST-1 to every Lambda container it started, to the --assume-role STS client, to the API server's service-integration region and to the WebSocket container's AWS_REGION. AWS SDK endpoint resolution is case-sensitive, so a handler's own SDK calls could fail to sign, and CN-NORTH-1 resolved the commercial partition. The handler entry now calls foldRegionOption, as the other region-taking commands do, and each of the four read sites folds again at the read, matching cdkd local invoke. Credentials forwarded into the container are still copied verbatim.

  • Cognito user pools: WebAuthnFactorConfiguration is supported, and the MFA pre-flight now covers WEB_AUTHN and 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). WebAuthnFactorConfiguration is now sent as WebAuthnConfiguration.FactorConfiguration and read back; it used to be an unhandled property that routed the pool through Cloud Control. WEB_AUTHN as a first auth factor beside MfaConfiguration: ON is refused before any AWS call unless it is MULTI_FACTOR_WITH_USER_VERIFICATION: AWS rejects that SetUserPoolMfaConfig only after UpdateUserPool has landed the sign-in policy. When a template does not declare Policies.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, with EMAIL_OTP / SMS_OTP still live, is refused too. A failed read falls back to the template. A bare EmailAuthenticationMessage / Subject now warns that it enables EMAIL_OTP (the block has no enable switch), and the Schema-replacement advice no longer calls DeletionProtection unmeasured: 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 in tests/unit/deployment/intrinsic-cfn-fallback.test.ts. The Fn::ImportValue ListExports and Fn::GetStackOutput DescribeStacks fallback client was built from the SDK's default credential chain alone, so a library caller that constructed AwsClients with an explicit credentials object had those reads made under a different identity. The client now carries the active AwsClients' 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-arn already reached this client through the environment. The other SDK clients built outside AwsClients (providers, context providers, the cross-account STS hop) are tracked in #3588.

  • A multi-region cdkd deploy no 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 read process.env['AWS_REGION']. cdkd deploy used to point the process-global AWS clients and process.env.AWS_REGION at each stack's region in turn. At the default --stack-concurrency of 4, stacks in two regions overwrote each other's setting, so a provider call made after any await could 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 an AsyncLocalStorage scope that carries its own clients and region. getAwsClients(), awsClientDefaults() (which every SDK client in src/ is built from) and the new ambientRegion() read that scope first. The deploy no longer changes process.env.AWS_REGION per 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-agentcore now 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.aws or <acct>.dkr-ecr-fips.<region>.on.aws failed with no basic auth credentials. docker login always targeted the plain <acct>.dkr.ecr.<region>.<suffix> host that GetAuthorizationToken reports as proxyEndpoint, while docker pull targeted 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 reported proxyEndpoint. 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) into k before the region guard ran, so <acct>.dkr.ecr-fips.us-<U+212A>east-1.amazonaws.com parsed as the region us-keast-1; it is now refused. start-service / start-alb / start-agentcore pull 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: an AWS::ECS::Service DeploymentCircuitBreaker without Rollback turned a live rollback off, while CloudFormation refuses the template. The check reads each type's schema required lists (a new nestedRequired fixture 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::LogGroup accepts a tag with neither Key nor Value, 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 GlobalSecondaryIndexes is 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 (new withdrawReplicaIndexBlocks), its replay-create unit tests, and an opt-in cross-region arm on tests/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's GlobalSecondaryIndexes override 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 value readCurrentState can 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 orphaned AttributeDefinitions prune, 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.md told users an ARN in aws-cn, aws-us-gov, aws-eusc or the four ISO partitions "passes cdkd's parse and then fails at the AWS call, because the download rebuilds the ARN with a hardcoded aws partition". Measured on the installed engine: fetchLayerContentUrl strips only the :<version> suffix from the caller's ARN and hands the remainder to GetLayerVersionCommand as LayerName, 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 declares cdk-local as a plain runtime dependency with no bundledDependencies, 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 cited cdk-local@0.147.7 and the bundle chunk dist/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 the grep that re-checks it; the symbols are not exported, they are findable because the bundle is unminified.
  • cdkd synth MyStack prints 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. synth declared no positional, so a stack name was silently ignored under commander@12 and became error: too many arguments under commander@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 MyStack prints one, and piping it into jq / yq is ordinary use of a drop-in CDK CLI. Behavior change: synth accepts [stacks...] (physical names, CDK display paths, wildcards -- the matcher diff uses); a selection of exactly one prints its template; any other prints none and lists the SELECTED ids, matching cdk's Supply 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 as Found 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 --verbose per-stack template dump, and the summary -- which counts N of M when a pattern narrowed, since Found 1 stack(s) on a three-stack app is false. What it does NOT narrow is synthesis itself. Measured in aws-cdk@2.1142.0: Toolkit.synth runs synthAndMeasure(...) and only then assembly.selectStacks(...), and the context loop reads assembly.manifest.missing for the whole assembly -- so a stack argument does not change which cdk.context.json lookups run, and a case fences that.
  • resolveAssetCodeDirectory takes 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's Metadata['aws:asset:path'] really lives for cdkd local invoke and cdkd local start-api, and positionals left FOUR adjacent string parameters 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 bound path.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 to logicalId and defaulting the bound to manifestDir, silently reinstating go-to-k/cdkd#3493's B1. Both call sites updated, behaviour identical, old spelling now error 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, and local-asset-code-path-containment.test.ts is 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 new asset-code-resolve-options-shape.test.ts pins 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.
  • resolveDockerContextDirectory takes 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. resolveDockerContextDirectory now 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: manifestDir is the assembly-derived base a relative value resolves FROM, directory is the attacker-chosen source.directory, and both are string, 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 local contextDirectory closure takes a bag for the same reason ((directory, sink) were also two adjacent strings). resolveFileAssetSourcePath keeps its leading positionals deliberately: its second parameter is a FileAsset, 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 two error TS.
  • cdkd local start-service and cdkd local start-alb now announce a Docker asset's source.executable before 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 bundled cdk-local engine, several layers past any cdkd-side boundary, so go-to-k/cdkd#3539's warning could not reach them and docs/cli-deploy-safety.md named the gap explicitly. Fixed upstream in cdk-local 0.149.1 (go-to-k/cdk-local#747) at buildDockerImage, which is the ONE spawn point every path shares -- so start-agentcore and studio, which this issue did not name, are covered too, and cdkl users get the line rather than only cdkd's. Behavior change: one warning at default verbosity, never a refusal, naming executable[0] and the argument COUNT; the full argv stays at --verbose, because a build script is not docker build and its own --token / --password matches 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: a DesiredCount: 3 service 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.sh under 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. resolveFileAssetSourcePath and resolveDockerContextDirectory take their trailing values as an options bag (FileAssetResolveOptions / DockerContextResolveOptions) instead of positionals. go-to-k/cdkd#3532 made assetOutdir required so that DROPPING it is a compile error; every remaining value is also a string, so a TRANSPOSITION still compiled -- (…, assetOutdir, assetId) on the Docker resolver bound the asset id to sink and printed cdkd will cdkd-asset-<hash>, and (sink, assetOutdir) on either bound a prose clause to the containment bound. The same PR deleted FileAssetPublisher.publish's unused profile for 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 to commander@^14 because cdk-local@0.149.0 requires it, and commander 13 flipped allowExcessArguments to 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 answer error: too many arguments for 'synth'. Expected 0 arguments but got 1.; the thirteen taking EXACTLY ONE (rollback, events, events prune, import, export, the two state read commands, and six local ones) reject a SECOND; the fourteen variadic ones, deploy / destroy / diff / list among them, are unaffected. The one-argument half is the likelier trip: cdkd deploy A B IS valid, so passing several stack names is trained behaviour, and cdkd state show A B now fails where it used to silently answer about A alone. 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 -- but cdk synth MyStack IS valid upstream, and that parity gap, masked by commander 12 rather than created here, is tracked in #3550. Re-derive the sets by walking buildProgram() for registeredArguments and each argument's variadic flag, NOT by descending only to leaves: events carries 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), no storeOptionsAsProperties, no Help subclass, no allowUnknownOption / passThroughOptions; --concurrency -5 parsed the same on both majors.
  • cdkd local start-alb and cdkd local start-cloudfront now contain a Lambda's Metadata['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 bundled cdk-local engine 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/task with no refusal and no warning, while cdkd local invoke and cdkd local start-api warned about the identical value. Behavior change: the bump to cdk-local@0.149.0 carries 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 in node_modules/cdk-local/dist and the old unguarded isAbsolute(assetPath) ? assetPath : … spelling is gone. cdk-local is also excluded from pnpm's minimumReleaseAge, 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-assets now 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 a dest= key (the FIELD does not decide: cacheFrom can carry a dest=). (2) source.executable names 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 knows docker build flag shapes and not a build script's own secret arguments. cdkd local invoke warns too. (3) An asset destination bucket or ECR repository that is neither default-bootstrap-shaped nor cdkd-managed names itself, since redirectFileAsset rewrites 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.md gains 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::GetAtt arms now refuse a masked persisted value (issues #3171, #2936) -- src/deployment/intrinsic-function-resolver.ts. When an Fn::Sub built a dynamic-reference name or an Fn::GetAZs region 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 rethrown ParameterNotFound / AccessDenied / staging-label miss from a {{resolve:ssm:...}} or {{resolve:secretsmanager:...}} lookup, the --verbose retry line of a throttled lookup, and the Fn::GetAZs describe-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] and Fn::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 deploy and cdkd publish-assets now consume a cdk synth --no-staging assembly, instead of failing with an ENOENT on 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. Under aws:cdk:disable-asset-staging CDK writes each asset's absolute SOURCE directory into files[*].source.path and dockerImages[*].source.directory. resolveAssemblyPath joins with path.join, which ignores a leading separator, so an absolute value was folded INTO the output directory and died at statSync or BuildKit, with nothing naming the flag. Behavior change: an absolute value is now HONOURED, warned about when it falls outside StackInfo.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 destination s3://bucket/key, the BuildKit context, a source.executable's working directory, or the AgentCore watch loop's docker 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/passwd folded inside and ENOENTed, /../etc/passwd and 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 upstream cdk-assets honours the field with no containment at all. assetOutdir became REQUIRED on both resolvers and both publishers, so dropping it is a compile error (go-to-k/cdkd#3489's own defect). Also fixed: absoluteAssemblyPathEscape warned about two spellings of ONE directory (/var vs /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 as KNOWN_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 of State for '<unrenderable>' ('<unrenderable>') and --stack-region '<unrenderable>', a flag that selects no record at all. No caller can deliver '' today (cdkd state throws on a falsy region, cdkd export normalises at its call, namedCliRegion floors it), so this finishes stating a contract rather than fixing an output anyone sees. Each builder stopped spelling its own State for ${shellQuote(safeStackName(...))} clause and its own cdkd state show ... tail, taking stackClause and inspectCommand instead: byte-identical for a present identity, and the absent form for ''. stackClause gained 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. namedEntriesClause was 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-resources message is now treated as ABSENT rather than rendered as <unrenderable> (issue #3520, PR #3525) -- malformed-resources-bag.ts. safeStackName('') and safeRegion('') 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. undefined was always right: the clause and the flag are both dropped, which is what a legacy version: 1 record needs. No caller can reach this today (cdkd state throws on a falsy region, cdkd export normalises at its call), so this states a contract rather than fixing an output. Behavior change: five exported builders now normalise '' -> undefined at their boundary, so '' renders identically to undefined there. 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: malformedOrphanResourcePropertiesRefusalMessage measured a helper-only fix as a half-fix, and a floor in stackClause / inspectCommand was tried here and removed: three unboundaried builders do reach them, but each passes exact ? x : undefined and '' is never exact, so they already receive undefined and 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-resources message no longer buries its pasteable cdkd state show mid-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. malformedStateDetail baked a fully-substituted cdkd state show <stack> --stack-region <region> --json into 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-only resources warning now END on the read command; the two DESTROY refusals emit one command per line instead (Inspect the record: then Drop the record:), because one line cannot both end on the read and keep the cdkd 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 and cdkd 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-api now REFUSE a relative aws:asset:path that 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 as isAbsolute(p) ? p : resolve(cdkOutDir, p), so .. folded exactly as join does 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, and cdkd local invoke forwards the caller's credentials -- so a hand-modified cdk.out could 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 real cdk synth emits 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 than asset.<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 to StackInfo.assetOutdir, NOT the manifest's directory: a Lambda under a cdk.Stage legitimately carries ../asset.<hash>. Both sites share ONE spelling, resolveAssetCodeDirectory, throwing through each caller's own wrapError so each keeps its error class; the absolute arm's verdict comes from the new absoluteAssemblyPathEscape, beside resolveAssemblyPath so the rule is not re-spelled. Verified accepted against a real 2.268 synth with a Stage, a nested stack, a Code.fromAsset function 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.ts and four commands; ten suites. AssemblyReader.getAllStacks recursed into a cdk:cloud-assembly artifact inside a try whose catch only WARNED, so every refusal raised while reading a Stage -- the absolute aws:asset:path tripwire, an escaping templateFile or 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. The try now wraps ONLY the read of the Stage's own manifest.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: readAssembly returns the Stages that failed, Synthesizer carries them on SynthesisResult.failedStages, and the shared renderNoStackMatch appends Stage 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 answer Multiple stacks found: .. Every assembly-chosen name in these messages -- a Stage path, a directoryName, each stack name -- renders through displayIdent, and a read failure names only its own error code, so none can forge a cdkd-sounding clause. SynthesisResult.failedStages is now REQUIRED, which is breaking only for code that hand-builds that literal; reading the result is unaffected. cdkd scrub and cdkd destroy still word their own empty-selection message (#3507).
  • A nested-stack row whose logical id collides with Object.prototype is 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.parse yields __proto__ as an ordinary own data property, so a row spelled that way ran Object.prototype's setter: the assignment DROPPED the key, the lookup then answered Object.prototype, and cdkd deploy / diff / export / import refused, naming [object Object] rather than following the row. All four now build onto nullPrototypeRecord(), joining NestedStackProvider.indexGrandchildTemplates, converted by #3448. This also WIDENS validation: a dropped top-level row was invisible to refuseMalformedNestedTemplateTrees, 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 with if (!index[id]) across NestedStackProvider, diff-recursive.ts, import.ts and export.ts. An inherited member is truthy there, so a row carrying no Metadata['aws:asset:path'] lost its "no template path" refusal and failed later and unreadably -- though still CLOSED: path.join / path.resolve / readFileSync each refuse a non-string with ERR_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. defineOwnKey would 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. displaySafe maps 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 a stackName, displayName, context key, transform name, Parameters key, template logical id or aws:cdk:path reached the terminal raw, so a crafted value could erase cdkd's own line with ESC [ 2 K plus CR and forge one. The most reachable sites needed no error at all: cdkd list writes one display id per line to stdout at default verbosity, so cdkd list -a ./cdk.out over someone else's assembly printed it; so do cdkd orphan's Target: / success lines. The --long / --show-dependencies payloads are covered too: measured per character, neither JSON.stringify nor yaml escapes DEL, C1, U+2028 or the bidi overrides, so their encoder is no boundary. A region takes asciiOnly; AWS text echoing a template value back takes displayAwsMessage, which caps it — including formatError's Caused by: line, previously sanitized but UNBOUNDED, so it printed the whole text one line under a cut marker. cdkd orphan's unquoted identity lists take displayIdent: displaySafe TRIMS, so a planted entry would otherwise render byte-identical to the genuine one said to be missing. cdkd list does NOT route through describeStack: its field order is inverted and displayIdent quotes, which a payload cannot take. Rows still open on the issue: the CDK-annotation display, the nested-stack-messages.ts pasteable remedy, assembly-reader.ts's per-slot helper choice, and diff-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.ts plus nine files across src/analyzer/, src/deployment/, src/provisioning/providers/ and src/state/, two suites plus one new one. These are the keys OUTSIDE the record class #3323 closed. The consequential ones: lambda-vpc-deps.ts dropped 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's Fn::GetStackOutput with another stack's outputs, s3-replication-purge-gap.ts's warn key joined FREE TEXT; and three provider attribute caches keyed physicalId against an attribute name with a PRINTABLE separator, read BEFORE the attribute switch, so one table's or proxy's attribute could answer another's Fn::GetAtt. Moving those three keys also had to move their READERS: two RDS providers evict by scanning for a leading physicalId, and injectiveKeyPrefix derives 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_SEPARATOR claimed 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. The deploy-engine.ts rows 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 across src/synthesis/, src/assets/, src/local/, src/provisioning/providers/nested-stack-provider.ts and src/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, and cdkd deploy -a <dir> consumes a PRE-SYNTHESIZED assembly, so nothing upstream validated one. A templateFile of ../../../home/<user>/.aws/credentials was 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: a source.path of ../../../home/<user>/.aws was zipped and uploaded with the caller's own credentials. A Handler of ../../x.handler wrote the inline Code.ZipFile body to an arbitrary host path. resolveAssemblyPath refuses a candidate resolving outside, lexically and through realpath(3); its doc comment carries the rest. The absolute-path tripwire is KEPT and worded apart, and every refusal renders values through displaySafe (go-to-k/cdkd#3277). Asset paths RESOLVE against the manifest's directory but are CONTAINED within the app's outdir -- at deploy, publish-assets, local run-task and local invoke-agentcore alike: cdk synth stages a Stage's assets into the app root, so source.path is ../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 --verbose also lstats the path it writes, refusing a symbolic link. Tests: eleven new files, cases in eight existing ones, and a new stage-assets integ fixture.
  • Thirteen message modules now build the cdkd command they tell you to paste through one gate, and print it on its own labelled line (issue #3436) -- new src/utils/pasteable-command.ts, 13 message modules across src/cli/commands/, src/deployment/, src/assets/, src/provisioning/ and src/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, and cdkd 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, for cdkd deploy, holding * / / becomes a hole too. Messages end in labelled lines rather than naming the command mid-sentence. shellQuote and commandHole moved into the new module, re-exported from src/state/lock-contention-message.ts since src/utils/** imports nothing from src/state/**. scripts/check-docs-error-strings.ts now collapses a \n escape like the whitespace it renders as, without which a trailing command line is unanchorable. The cdkd drift sites 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 orphans list is not a list is no longer read as "no orphans", and cdkd rollback no 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. parseStateBody validates only the record root and the schema version, so a hand-edited "orphans": "abc", 5, {} or null survived the load with no reader asking Array.isArray first. Three outcomes: a string was WALKED one character at a time and orphansAfterRollback returned a list of those characters, which cdkd rollback SAVED; shapes counting zero read as an empty list, so cdkd diff previewed no adoption and cdkd destroy deleted the record without reporting resources an earlier failed deploy left live in AWS; the rest threw a bare TypeError naming nothing. Behavior change: a command that READS this container on a path that can write state now refuses the record before it acts -- cdkd deploy before any resource operation, cdkd destroy at its first read and its under-lock re-read, cdkd rollback before any replay, cdkd import before it carries the container into the record it writes, cdkd scrub on a real run -- with a non-retryable error naming the container and how to inspect the record. cdkd orphan never reads it and still runs, carrying it verbatim; the destroy refusal names cdkd state orphan as the way to drop a record deliberately. Read-only paths continue: cdkd diff and cdkd scrub --dry-run treat it as empty, warn, and list an (orphans container) row in the diff's unreadable set, which --fail and --json see. A well-formed record is unaffected, and a record with NO orphans key 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 diff now exits 3 over a record whose repaired container cdkd deploy refuses, instead of 0 (issue #3335) — an unreadable resource properties map (#3317) and an unreadable outputs bag (#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, so cdkd diff --fail exited 0 and a CI step gating on it passed over a record the next deploy stops on; where the template did declare something, --fail exited 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's blocking — 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, so countBlocking raises DeployRefusalPreviewError (exit 3) ahead of --fail. The ids are deliberately NOT added to --json's unreadable, 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 (the resources bag and its entry drop carry the same gap), #3339 (stand-in rows in --json), #3513 (the nested outputs warning does not say the deploy refuses).
  • cdkd scrub can no longer read one producer's state record as another's (issue #3323) -- new src/state/record-keys.ts plus eight files across src/state/, src/cli/commands/ and src/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 in scrub.ts and 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 at No plaintext secrets found over surviving plaintext. The route is the consumer's own TEMPLATE: Fn::GetStackOutput's StackName is 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 through producerRecordKey (a stack+region record) or the new producerCoordinateKey (a stack+export-name coordinate) -- two names over one private JSON.stringify encoding injective over ALL string pairs, in the new leaf and re-exported. The fence now sweeps ALL of src/ 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; listStacks never was), the bound corrected from #3308, and the fences' limits: docs/design/3323-composite-record-keys.md.
  • cdkd state's two S3 listings take EncodingType too, 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 later getState addresses, and the bootstrap-marker walk SLICES the key into a region name and hands it back to GetObject -- 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 defensive startsWith re-check, or the guard would test a different string from the one that gets used. The suite's HELD_BY_ANOTHER_PR list, 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 promotes state.ts into 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 the cdkd deploy it tells you to paste (issue #3307) -- src/cli/commands/drift.ts, tests. That refusal built its command from listStacks'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 new stackCommandFor gates it: sanitize (asciiOnly) and compare against the RAW value, refuse an empty one, cap at STACK_REF_MAX_CODE_POINTS (a legitimate Parent~Child~... still renders), shell-quote, and refuse a leading - or, for cdkd deploy, a * / / pattern. stackIdentityLine puts the identity on a gated Stack: line rather than in the sentence: a name carrying a newline above a labelled line FORGES one, and displayIdent closes only that half -- a $(...) name comes back inside its JSON quotes intact. Withheld, the message states the rule. This is the only labelled command line cdkd drift prints, 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, so Run 'cdkd drift MyStack --revert' is now Run 'cdkd drift --revert' for this stack, re-run 'cdkd drift MyStack --revert' is now re-run 'cdkd drift --revert' for this stack, and Run 'cdkd state refresh-observed MyStack' is now Run 'cdkd state refresh-observed' for this stack. A grep matching the old spelling breaks; cdkd drift --revert with 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.ts and src/synthesis/stack-messages.ts. Every value those two rendered was chosen by whoever wrote the assembly -- a manifest key, a stackName property, a template key, a Metadata['aws:asset:path'] string, a directoryName joined into a path, and the readFileSync / JSON.parse failure 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, and formatError sanitizes only an error's cause, never its message, 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:path refusal -- whose own text says the input was hand-modified -- was the message a user was asked to trust. All ten render sites in assembly-reader.ts (thrown refusals and debug / warn lines alike) and the side-file refusal in stack-messages.ts now go through displaySafe. displaySafe and not displayIdent: 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, which displaySafe maps 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 in src/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 in tests/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 the secrets-dynamic-ref integ. An Fn::Sub / Fn::Join that puts a resolved secret where a reference's service name goes ({{resolve:${Pw}}} over a secretsmanager variable) 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 into state.json (properties, observedProperties, outputs, the exports index), the rollback journal and the deploy summary's Outputs: lines. Behavior change: on the template route such a token is refused with a masked, non-retryable Refusing 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-getatt fails 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 names secretsmanager, ssm or ssm-secure: a recorded plaintext in any other span is replaced by its expression, so cdkd scrub heals a record an older release leaked into (the result is unresolvable text, not plaintext; deploy the corrected template first, see docs/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 deploy or 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 the import-deployed-parameter-secret integ. 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 no observedBaselineRefusalReason and were still cleared by an UPDATE: a code-only Lambda update wrote the deployed secret to observedProperties. 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 import carries such a marker the same way across a re-import on an unchanged physical id. Pseudo parameters and CDK's BootstrapVersion do 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 deploy after cdkd import no longer records a decrypted secret as a drift baseline for a resource whose parameter was deployed over a placeholder Default, 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 the import-deployed-parameter-secret integ. cdkd import refuses the observed baseline of a resource that depends on a template parameter it cannot prove was deployed at its Default (#2854), and an UPDATE used to clear every such refusal. But cdkd deploy binds the same Default, 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 -- to observedProperties. The import now records WHY it refused, in a new optional observedBaselineRefusalReason: "unverifiable-parameter" beside observedBaselineRefused (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 (wasReplaced AND a changed physical id) or a fresh CREATE clears them. The same hole one command over is closed too: re-running cdkd import once 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 the Default. 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.ResourcePolicy and StreamSpecification.Tags are now applied and read back, instead of being dropped on the wire (issue #3458) -- dynamodb-table-provider.ts, new dynamodb-stream-members.ts. The SDK's StreamSpecification has 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 calls PutResourcePolicy against it -- at create, on an update-time enable, when either member changes, and AGAIN after a StreamViewType change, which replaces the stream: both go to the newly minted LatestStreamArn, 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-sends UpdateTable. Unreadable member (unresolved intrinsic, wrong shape): refused by position, before CreateTable and before any call of an update; a rollback replay or drift --revert warns and keeps the live setting instead. Read: cdkd drift calls GetResourcePolicy / ListTagsOfResource on the current stream arn, each ONLY when the template declares that member, dropping aws: tags and re-asking a not-yet-consistent answer for a few seconds. An out-of-band policy delete or untag is reported under StreamSpecification, and drift --revert restores it. New IAM actions on the stream arn, needed only by a template declaring the member: dynamodb:PutResourcePolicy, GetResourcePolicy, DeleteResourcePolicy, TagResource, UntagResource, ListTagsOfResource.
  • cdkd import no longer records a decrypted secret as a drift baseline when a template parameter was DEPLOYED with a secret reference over a placeholder Default (issue #2854) -- import.ts, new import-deployed-parameters.ts, docs/import.md, new import-deployed-parameter-secret integ. cdkd import takes no parameter values, so a parameter is recorded at its Default; a stack deployed with --parameters DbPassword='{{resolve:secretsmanager:...}}' over Default: CHANGEME (the ordinary --migrate-from-cloudformation shape, 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 to observedProperties in state.json with 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 with DescribeStacks and compares them with what it bound. Every resource whose properties depend on a parameter it cannot PROVE equal -- a different value, a literal reference, any NoEcho parameter, one missing from the response, a failed call -- through Ref, Fn::Sub, any nested intrinsic, a (transitive) condition or the resource's own Condition, plus every resource reading an attribute (Fn::GetAtt) of such a resource, gets no baseline and the observedBaselineRefused marker, 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 deploy heals a state record that lacks an attribute a Fn::GetAtt asks for, instead of refusing forever (issue #1852) -- intrinsic-function-resolver.ts, deploy-engine.ts, new stale-attribute-heal.ts, the RDS / DocDB / Neptune providers' import(), docs/troubleshooting.md, new stale-attribute-heal integ. A record written before its provider recorded an attribute (AWS::SSM::Parameter Arn, AWS::RDS::DBSubnetGroup DBSubnetGroupArn), written under --no-wait while a DBInstance had no Endpoint.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 added param.attrArn output hit the *Arn refusal 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 DBProxy VpcId), the deploy re-reads that resource's attributes ONCE through its provider's read-only import() (routed by the record's provisionedBy), 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-run reads but writes nothing; cdkd diff / drift / export never re-read and point at cdkd deploy. cdkd import of a DBInstance now records Endpoint.* and Arn. Design: docs/design/1852-stale-attribute-heal.md.
  • AWS::DynamoDB::Table: a GSI's per-index ContributorInsightsSpecification is now applied and read back, instead of being dropped on the wire (issue #1782) -- dynamodb-table-provider.ts, new dynamodb-contributor-insights.ts. The SDK's GlobalSecondaryIndex has 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 calls UpdateContributorInsights with IndexName for each index whose block changed (at create, index added, Enabled flip, Mode change). 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 drift issues one DescribeContributorInsights per index, ONLY for an index whose template entry declares the block, and reports Mode only when the template declares one, so an undeclared index and AWS's default mode are never phantom drift. ENABLING / DISABLING read as their target (the deploy does not wait for the toggle); FAILED or a failed read omits the block. An out-of-band toggle of a declared index is reported as GlobalSecondaryIndexes drift and drift --revert undoes it. Also fixed, table level: the block is now read by the same reader, so Enabled: 'false' (a CloudFormation-legal string) no longer ENABLES the feature, and an unreadable block (either level) is refused before CreateTable and, on an update, announced and left alone -- never read as a default. Also: GlobalSecondaryIndexes / LocalSecondaryIndexes are now compared as unordered sets (leaf-only, so each index's KeySchema stays order-significant). Real AWS returned a [giA, giB] template as [giB, giA], which a properties baseline reported as whole-list drift; the phantom pre-dates this change.

Recently Implemented (2026-09-19):

  • cdkd deploy refuses a cyclic nested-template tree before it starts, not at the nested-stack row (issue #3449) -- deploy.ts, new src/cli/commands/nested-template-preflight.ts, tests, docs/cli-deploy-safety.md, nested-stack-deep integ arm. #3448 made NestedStackProvider refuse 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 deploy now 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-run too; a stack outside the deploy set is not judged. The error is a non-retryable SynthesisError ending in Refusing 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 own NestedStackProvider: ... Refusing to deploy any level of it. wording. The walk is not seeded with the root template's path (StackInfo does not carry it); a child pointing back at the root template is still refused, one lap later in the rendered chain. cdkd rollback, cdkd destroy and cdkd state destroy build a destroy-mode nested context that cannot start a nested deploy, and cdkd import --migrate-from-cloudformation recurses 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). On AWS::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 no provisionedBy: 'cc-api' record warned and dropped it. AWS::BedrockAgentCore::Runtime is not in STICKY_CC_MIGRATION_EXEMPT, so an existing SDK-provisioned resource of that type whose template gains one moves to Cloud Control and pins provisionedBy: 'cc-api' ONE-WAY. The snapshot marks none of them create-only, so createOnlyDrops and the record narrowing are unchanged; the capture is top-level-only, so it says nothing about a nested create-only path.
  • cdkd orphan's properties refusal: 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-bucket printed State bucket: '' while the sentence promised that line named the bucket, and disagreed with recoveryCommandFlags, which emits no --state-bucket for ''; it is now treated as no bucket, and the sentence sends the operator to cdkd state info. Like the empty-stack floor beside it this arm is not reachable through the CLI today -- orphan.ts calls verifyBucketExists(), whose HeadBucket throws 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 (identityWithheld reads 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 info is 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: resolveSub re-enters resolveRef with unvalidated ${...} text, and cdkd reads the template as JSON, so a KEY is only as constrained as the file. Measured: an id carrying ESC[2K + CR redrew five renders, two of them THROWS the user sees at any verbosity. Fix: per-site, not a blanket wrap. 18 render through displayMasked and their notes are deleted. The 19th is left alone -- reaching it means resolvePseudoParameter's switch MATCHED, 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: the Parameters / Conditions name lines, Fn::If's condition renders, the unsupported-intrinsic throw, the AWS::NoValue line, the two display: builders, and DeployEngine.maskedRecordRemedyFor -- which rendered a raw id into its cdkd import ... --resource <id>= command, now WITHHELD for an id outside isPasteableIdent, since sanitizing would name a different record --force accepts. Every site is pinned by its emitted BYTES, one case each. Gate note: all three cdkd scrub shape patterns now match an id whose only unmatched characters were invisible, moving such a unit count -> 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::ImportValue export 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 through displayMasked and 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 carrying ESC[2K + CR emitted a warn AND a throw that erase and rewrite the line (raw CR 7 across both sinks in one cdkd deploy; 0 after). The dynamic-reference names, the attribute name, the origin builders and the parameter-value lines were the same shape. Fix: closed at the chokepoint, not at the sites. maskSecretsForLog is GONE -- the masking answer is maskSecretsRaw, reachable only from maskThenStripThenMask, itself reachable only from displayMasked, 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 (stripControlChars leaves U+2028), the Ref <id> not found warn (an Fn::Sub placeholder name is not validated) and every logical-id render in src/analyzer/dag-builder.ts, whose dangling-DependsOn warn was the last live one (displayIdent there). 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), and MASKERS in the mask-coverage checker now means "masks AND sanitizes", so its walk -- which resolves an interpolated identifier, let included, to its declaration -- reports a bare-masker binding wherever it is rendered; no exclusion marker silences it. Cross-account refusal semantics are untouched: class, markNonRetryable marker and every message's WORDING are unchanged.
  • A force-exit from cdkd local run-task / start-api now 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-^C reaches process.exit(130), which runs no finally and awaits nothing, so the cleanup disposing that tmpdir never fires. The message named only the CONTAINERS -- the artifact docker ps already finds -- and nothing about the one nothing reports. The file holds live credentials, $TMPDIR is 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.ts was 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 --detach notice adopts too, so the three sites cannot drift into three wordings. A run with no --profile gets no extra sentence. The path is read from a hook the writer fires at mkdtemp time rather than from the resolved file, so a ^C landing inside the write itself still names the directory it strands. cdkd local invoke and local invoke-agentcore need none: their second ^C re-enters the same single-flight cleanup and the file is disposed. Why NAME rather than rmSync: every path that reaches this notice leaves the containers running with the file bind-mounted, which is the premise the --detach arm 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 deploy resolver message (issue #3397 review) -- src/deployment/intrinsic-function-resolver.ts; 97 render sites. maskSecretsForLog answers "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: a StackName carrying ESC[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, then displaySafe) is now the ONLY way a masked value reaches a message, and displayLeaf is its log-twin sibling for the origin builders. Both are in MASKERS in scripts/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.ts now 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 live Fn::ImportValue exposure. 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, markNonRetryable marker 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-empty AWS::S3Express::DirectoryBucket with no auto-empty opt-in advised aws s3 rm s3://<name> --recursive with 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 in deployments/*.jsonl. The command now renders through the shared renderDisableCommand (sanitize, shellQuote, SUPPRESS the whole command when sanitizing CHANGED the name, since the sanitized name is a DIFFERENT bucket). The quoted unit is the ASSEMBLED s3://<name> argument, because aws s3 rm has no --bucket form. Site-specific suppressions: a name containing / (s3://a/b addresses bucket a) and an empty name. A suppressed command becomes console advice. The PROSE copy of the name goes through displayIdent, 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 read NotFoundException / NotFound in 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. No maskSecrets: DeleteContext has no masker (#2007). Residuals: the other bare-interpolating advice sites are #3136, provider-warning prose is #3269.
  • cdkd deploy refuses a cyclic nested-template tree before any level of it deploys (issue #3247) -- nested-stack-provider.ts, new src/utils/nested-template-cycle.ts, tests, docs/cli-deploy-safety.md, nested-stack-deep integ arm. NestedStackProvider followed Metadata['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 a cdkd/<parent>~<Loop>~<Loop>.../state.json record per level -- until S3's key-length limit stopped it at roughly 190 levels. create and update now 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 are realpathed, so a cycle spelled through a symlinked directory (whose joined path never repeats as a string) is caught; a row's Condition is not evaluated, matching the diff walk. The same walk reports an absolute aws:asset:path at any depth before anything deploys. Template-controlled text in this provider's thrown messages, skip reason and log lines now goes through displaySafe. 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 --recursive still misses the symlinked-directory spelling (#3450); one delete-path message still renders the child stack name raw (#3277).
  • A resource Type change 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, new type-change-replacement integ. 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 additive previousResourceType on completed UPDATE ops (no journalVersion bump, 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 with ROLLBACK_REPLACEMENT_UNROUTABLE and the journal is kept (--orphan <id> skips it). --revert-failed skips a failed Type change with a warning. A Type change into or out of AWS::CloudFormation::Stack is still refused (TYPE_CHANGE_NESTED_STACK), with its message corrected. Design: docs/design/2668-type-change-routing.md.

Recently Implemented (2026-09-18):

  • cdkd export resolves the AppSync identifiers AWS re-declared in September 2026, blocks AWS::AppSync::GraphQLApi up front from a measured list, and cdkd import records a GraphQL API's Arn (issue #3414) -- src/cli/commands/export.ts, src/provisioning/providers/appsync-provider.ts. AWS moved the API's primaryIdentifier from ApiId to Arn (issue #3327) and re-published AWS::AppSync::ApiKey with a read handler and the composite [ApiId, ApiKeyId]. Before: the API shipped its bare apiId as Arn; the key, no longer refused by the pre-flight, hit the add an entry to COMPOSITE_ID_SPLITTERS throw and aborted the export. After: the key has a splitter (<apiId>|<apiKeyId>, the key ARN, a bare pre-flip id with ApiId from properties; other arn: values refused), overlay narrowed to ApiId -- a standalone IMPORT changeset carrying one key reached CREATE_COMPLETE (us-east-1, 2026-09-18). The API resolves from the recorded Arn (tolerating a registry still reporting ApiId for a non-ARN physicalId), BUT the export integ run measured CloudFormation refusing the type for IMPORT despite its read handler, so a dated CFN_IMPORT_REFUSED_DESPITE_REGISTRY map now blocks it before the lock with the same remedies; --skip-import-support-preflight bypasses both checks. BEHAVIOUR CHANGE: an AppSync API used to fail at CreateChangeSet after locking the stack and now blocks pre-flight. AppSyncProvider.import records ApiId / Arn / GraphQLUrl for an adopted API (was attributes: {}). Live: the appsync fixture asserts an export --dry-run names exactly GraphQLSchema + GraphQLApi as blockers with the other identifiers resolved. No flag or schema change.
  • AWS::SNS::Topic now writes MaximumMessageSize, 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 to STICKY_CC_MIGRATION_EXEMPT on the premise that it has NO drops -- cdkd diff rendered sticky for a cc-api topic that cdkd deploy --allow-unsupported-properties AWS::SNS::Topic:MaximumMessageSize would flip to the SDK provider. Now: create() sends it inline on CreateTopic; update() sends a change through SetTopicAttributes and resets a template REMOVAL to 262144 (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); readCurrentState emits the member unconditionally as a number, folding an absent attribute to 262144 (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 via cdkd state refresh-observed): a template removal compares equal to itself and a console-side change -- including an ADD after a removal -- is drift --revert can push back. The cc-to-sdk-reroute fixture 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 clean cdkd 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). On AWS::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 no provisionedBy: 'cc-api' record warned and dropped it. The snapshot marks none of them create-only, so createOnlyDrops and 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 plus src/cli/commands/rollback.ts, each through displayIdent with ROLE_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, whose inArnScope carries the measurements) to the four USER-SUPPLIED-ARN surfaces found ten: two --assume-role argparse refusals in src/cli/options.ts -- argv, whose IAM_ROLE_ARN_REGEX is START-anchored, so even an ACCEPTED value is unconstrained past role/ -- and five in src/utils/role-arn.ts itself, the file the issue body cites as having adopted this rule in issue #2170. It had, in ONE of its two functions; assumeRoleForCrossAccountStateRead rendered roleArn raw 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 takes displaySafe, as does drift.ts's IAM error text. The cross-account refusal semantics are untouched -- CrossAccountSecretRefusalError, its markNonRetryable marker and the message wording are unchanged, so cdkd scrub's instanceof branch and every retry classifier behave exactly as before. NOT widened tree-wide: that reports 153 sites under src/provisioning / src/synthesis, out of scope by REACHABILITY (go-to-k/cdkd#3405).
  • AWS::DynamoDB::Table now REFUSES a per-GSI OnDemandThroughput on 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 accepts OnDemandThroughput only on a PAY_PER_REQUEST table, and applyGsiUpdates' THREE per-index send sites all ride one GlobalSecondaryIndexUpdates array -- so AWS rejected the whole request, took a co-resident capacity edit with it, AFTER the flip had landed. update() now walks GlobalSecondaryIndexes[] 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 omitting BillingMode, 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), that DescribeTable OBSERVES live, and only when this update does not flip to PROVISIONED (the snapshot predates the flip). -1 is live-verified at both positions (#1434; #1423 plus PR #1433 at UpdateGlobalSecondaryIndexAction).
  • A --assume-role / --ecr-role-arn ARN can no longer redraw your terminal from a cdkd local failure 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.ts has rendered its own ARNs through displaySafe since issue #2170; the cdkd local copies never adopted it, and ConsoleLogger.formatMessage sanitizes 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: resolveAssumeRoleArn can 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 takes displaySafe too, and that one is not symmetry: STS answers an unparseable RoleArn with a ValidationError that 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 -- a encodeURIComponent'd credentials URI and a cache key, neither of which is displayed. The deploy-path copies in src/deployment/intrinsic-function-resolver.ts are 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 through shellQuote ALONE, which is the wrong instrument: inside '...' an injected \ncurl ... | sh is a literal, while an ESC still redraws the line. The BUCKET is third-party plantable -- config-loader.ts resolves it from a repo's cdk.json context.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 --profile a 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 -- asciiOnly would have deleted the entire hint for prod-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 a cdkd local error; 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-role fallback notices). The issue reported two sites; the sweep found four here plus one in buildForceUnlockCommand (its own entry) -- the two fallback notices were nobody's report. ConsoleLogger.formatMessage sanitizes only a call's EXTRA ARGS and formatError only a cause, 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 through displayIdent -- allowlist, length cap, and a JSON-quoted boundary only for a value that could spoof, so a legitimate name reads as before. The aws sso login --profile <name> line is a command an operator PASTES, and displayIdent is not enough: every character of ~evil and -rf is 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 on isPasteableIdent and 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 -e line -- a different question -- and the section header still carries the name byte-for-byte.
  • cdkd orphan's properties refusal 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.json listed with no region, pickStackRegion loads it under the SYNTHESIZED region, and the refusal (#3318) printed that region into cdkd state orphan ... --stack-region <region> and cdkd state show ... --json; both select by the LISTED region, so neither found it. pickStackRegion now also returns the record's own region and the refusal takes it: the drop remedy is the bare cdkd state orphan <stack>; a record listed under a region keeps --stack-region. cdkd state show cannot read that shape, so its inspect line names the S3 object instead, on labelled Object 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 to cdkd state list --json. Both carry the run's --profile / --state-bucket / non-default --state-prefix via recoveryCommandFlags, shared with cdkd 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 shared inspectCommand template also changes the Inspect ... with: line of the destroy, divergent-region and deploy/diff refusals and the diff warning. Siblings: #3388.
  • A cdkd destroy failure that is a value JavaScript cannot stringify is now REPORTED as itself, instead of as a TypeError (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 / null toString, 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 the level.map(...) that Promise.all awaits, and its retry loop stringifies first -- so a hostile value became a TypeError and the RESOURCE_FAILED row named TypeError: Cannot convert object to primitive value rather than the AWS failure. Measured: the run is NOT abandoned -- a TypeError is an Error, so handlers below take the Error branch. Guarding any one leaves an earlier one throwing first, which is why all five are here -- and withRetry wraps every provider op, so cdkd deploy / rollback / import gain 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 becomes describeAwsFailure(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 become safeStringify(err), keeping the error's name. A value that cannot be converted is now RECORDED as a value that could not be converted to text. The sibling population is tracked by issues #3361 and #3341.
  • cdkd scrub now 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[].sourceStack and outputReads[].outputName are redacted back to their {{resolve:...}} expression at the persist choke point — but both lists rode scrubStack's ...carriedState spread untouched, so a record already on disk kept its plaintext and cdkd scrub --fail exited 0 over it. The same three template-derived names are now matched against this run's recorded plaintexts and rewritten; sourceRegion and imports[].sourceStack are deliberately left verbatim, the second because cdkd destroy matches its producer refusal on that literal key and redacting it would drop a destroy-blocking record. A repaired record is counted, so --dry-run --fail reddens 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, plus tests/unit/cli/commands/scrub-cross-stack-read-repair.test.ts and a docs/cli-scrub.md section. Issue go-to-k/cdkd#3337; the four-case analysis and the per-field provenance table are in docs/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::GetAtt on an AWS::SNS::Subscription's Arn is 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 recorded attributes: {} and let guardedPhysicalIdFallback answer off the physical id, which IS the subscription ARN. That worked on the ordinary path -- so the type held the tree's only SDK_ATTR_ALLOW_LIST entry rather than being reported as a gap -- and left three edges: a CONSTRUCTED <topicArn>:<logicalId> when Subscribe returns no ARN (ARN-shaped, so the resolver's guard passes a fabricated value), an imported PendingConfirmation id (where it refuses), and --strict-getatt (which throws on any fallback). create now records Arn from 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. update delegates to create, so a replacement carries it. The allow-list entry is DELETED, which is what verifies the caching (classifyType reads cachedKeys first, so one left behind goes inert while the matrix still reports a carve-out); probed by reverting the caching, which makes audit:sdk-attr-coverage:check name 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 backfills attributes on 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.ts feeds state.region into the --recreate-via-* probe's expectedRegion, whose mismatch arm refuses to read a ResourceNotFoundException as "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.expectedRegionDiverged restores 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 on cdkd state orphan <stack> --stack-region <region> while naming a stack whose rendering displaySafe may have TRIMMED -- so a record keyed 'prod-api ' opened byte-identically to a healthy prod-api, and an operator filling the template with the name above it orphans the intact record. It now computes the same exact test its sibling has carried since #3332 and, when it fails, names no target and sends the reader to cdkd state list --long. It also fails CLOSED on a bag it cannot count. export.ts's safeSegment caps too -- the last uncapped rendering of a body-supplied region. cdkd rollback shares the inference and is #3370.
  • cdkd destroy / cdkd state destroy now 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 (an aws s3 cp between region prefixes, a restore, another tool), they are not, and the runner reads each provider's *NotFound as ALREADY DELETED: deletedCount++, the row dropped, then deleteState. 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.expectedRegion and 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, with STATE_REGION_DIVERGED and 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 orphan all keep working on a record this refuses to delete from. Also drops a non-string or empty region on the LEGACY read path (tryGetLegacy's gate short-circuits on falsy, so "" and 0 reached acquireLock as regions and built cdkd/<stack>//state.json).
  • cdkd export's refusals no longer build a pasteable cdkd state orphan line 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. walkCdkdStateStackTree mints each nested child's stack name as ${parent}~${logicalId} from the record's own resources keys -- 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 suggested cdkd 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 through orphanCommandFor, which applies both halves of the rule malformed-resources-bag.ts records: sanitize THEN shell-quote, and GATE the substituted form on the identity rendering EXACTLY -- displaySafe trims, so a record keyed 'prod-api ' renders byte-identically to a healthy prod-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 to cdkd state list --long. Its region is required: omitting --stack-region drops 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 takes safeDetail, not the identifier helper. safeSegment's truncation marker is displayIdent'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 destroy can 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.json carrying "region": "eu-west-1" made cdkd state destroy <stack> --stack-region us-east-1 print ✓ State deleted while 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 misdirected DeleteContext.expectedRegion, which is how a provider tells "genuinely gone" from "wrong-region miss". getState now replaces the loaded record's region with the key's (adoptKeyRegion), making the read symmetric with saveState, 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 built cdkd/<stack>//state.json and a numeric one reached acquireLock as 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. --verbose shows it, sanitized. cdkd export's nested-child region refusal reads the divergence from the new divergentBodyRegion field. Destroy-path code is unchanged: normalizing at the read fixes destroy-runner.ts's four sites, deploy.ts and drift.ts with nothing threaded.
  • An AWS::DynamoDB::Table GSI whose IndexName is an unquoted YAML NUMBER no longer takes the whole cdkd deploy down (found while closing issue #3287) -- src/provisioning/providers/dynamodb-table-provider.ts (warmThroughputOpFor). IndexName is an UNCHECKED cast off the template, so IndexName: 2024 reaches the provider as a number, and the real maskSecrets is a String.prototype.replace call that THROWS on one. That method opened with a bare, EAGER maskSecrets(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 with Failed to update DynamoDB table <id>: text.replace is not a function, from a purely diagnostic code path. The indexScopeAt guard 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 and skipZeroCapacityIndexUpdate's zero-capacity refusal, the one of the three a numeric name can actually reach) all read through it, and indexName is typed unknown at 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 by site 5 update: survives a NUMERIC IndexName with a masker in play in tests/unit/provisioning/dynamodb-table-provider-cfn-integer.test.ts, whose site-4 twin could not reach this arm.
  • AWS::DynamoDB::Table now SENDS a per-GSI OnDemandThroughput change (issue #3287) -- src/provisioning/providers/dynamodb-table-provider.ts, tests/integration/dynamodb-ondemand/. UpdateGlobalSecondaryIndexAction.OnDemandThroughput exists in the SDK model and NEITHER per-index update arm set it, and updateHasMember was 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 through coerceOnDemandCeilingsForSend, the reader the other four send sites use. A live-DescribeTable idempotency 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 accepts OnDemandThroughput only 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 the UpdateTable REJECTED, 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: an effectiveProperties + canonicalizeDesiredProperties fold was written for this change and WITHDRAWN, so issue #3286 stays open with the evidence.
  • cdkd local invoke-agentcore --sigv4 now NAMES the --role-arn exclusion 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 /invocations is what the emulated agent sees as its INVOKER, so cdkd signs as the CALLER and deliberately never as a --role-arn assumed role (issue #3130). On an SSO / instance-role / container-role caller there is nothing to put back, and the old refusal read no AWS credentials available... Set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY while AWS_ACCESS_KEY_ID was 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-role lead) and --profile drops out when one was already passed. Rationale on sigv4NoCredentialsRefusal's doc comment. Same PR, no behavior delta: AWS_CREDENTIAL_ENV_KEYS is now IMPORTED by all three forwardAwsEnv bodies rather than documenting a guarantee nothing consumed (item 4); the cdkd local credential fence gained the INI credentials-file channel that neither fence could see -- a lowercase aws_access_key_id written 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 orphan no longer saves a resource record whose properties map 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. rewriteResourceReferences passes each surviving record's bag through rewriteValue, whose first line returns a non-object verbatim, and re-assigns it through a bare cast; refuseMalformedState above it answers a question about the record ROOT only, and unreadableResourcePropertyBags returns [] 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, null and [] 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 next cdkd deploy refuses (#3191) and a cdkd diff in between previews as a replacement. A LIST bag is the one unreadable shape rewriteValue walks, so its rewrites landed in the audit table while the container stayed un-map-like. refuseMalformedResourcePropertiesForOrphan now refuses at the load, above the rewrite and the --dry-run return, with its own text (the deploy sibling's diff verdict and the outputs refusal'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 --force bypass. 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, plus src/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 / ListObjectVersions answer 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: PutObject stores such a key, the un-encoded listing returns \n, and EncodingType: 'url' returns %0D which decodes to the real CR. The dangerous half is the delete that follows: it SUCCEEDS and removes nothing, so s3-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. NextContinuationToken is deliberately NOT decoded (it is opaque and unencoded; decoding it breaks pagination) while NextKeyMarker is (the next request sends the raw value) -- both measured, not inferred. Two sites deliberately do not ask: config-loader.ts reads KeyCount and recreate-targets.ts is 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.ts is 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. Fifteen catch blocks read the caught failure's text with error 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 or null toString, and every one of these handlers exists to DEGRADE: an enrichment that cannot read an attribute logs at debug and carries on. The out-throw escaped the handler and failed the create or update instead, reporting a TypeError that names neither the resource nor the attribute. Reachable because a non-Error rejection genuinely travels this far -- the same shape #3236 measured reaching the poll loop. All fifteen now read describeAwsFailure(error).detail, which is byte-identical for an Error and 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 from this.delete(...), which wraps into a ProvisioningError) and where converting would blind isNotFoundMessage, 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 with String(err) -- bare, or behind the err instanceof Error ? err.message : String(err) ternary -- THROWS for a null-prototype object or a hostile toString. 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 a TypeError instead 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 becomes describeAwsFailure(err).detail, byte-identical to it; the bare form becomes the new safeStringify(err) -- String(err) plus a guard, and not .detail, which drops the error's name and so shortens four persisted outcome: '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 the outputs bag, so it can carry a NUL too, and this repo already plants one. Measured: stack Evil<NUL>us-east-1 in ap-northeast-1 and stack Evil in region us-east-1<NUL>ap-northeast-1 produced the SAME key, so whichever was warned about second was silently not warned about at all. Both sites now call one shared producerRecordKey, which encodes with JSON.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, so cdkd local invoke-agentcore -- whose boot builds TWO resolvers, one for the fromS3 bundle's bucket intrinsic and one for the container env -- reported one damaged record as two. S3LocalStateProvider now 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; buildCrossStackResolver keeps its own set when no caller supplies one, so local invoke and local run-task are 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 describeAwsFailure is used, and a credential-resolution failure stops passing through verbatim (issue #3297) -- src/utils/aws-failure-text.ts, src/provisioning/cloud-control-provider.ts. isAwsAuthoredFailure decides 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 $metadata onto EVERY error it gives up on, socket errors included. Measured: a plain ECONNREFUSED carries one, so it was reduced to its name -- for a socket error the bare token Error. The predicate is now a real SERVICE signal ($fault, or a NUMERIC $metadata.httpStatusCode) plus the CredentialsProviderError NAME, which carries neither because credential resolution runs OUTSIDE the retry middleware. $response is 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.ts and two in cloud-control-provider.ts: each had been reporting a transport failure as Error, and four of the five had been letting a CredentialsProviderError through verbatim into a persisted message, which under credential_process meant the helper's ARGV and its stderr (the fifth catches config.region(), which that shape cannot reach). That is withheld now and reachable at debug; the cost is the corporate-proxy self-signed certificate symptom reading as a class name at default verbosity. s3-state-backend.ts is deliberately untouched -- it reads detail, not summary. 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 logged JSON.parse's own message, and V8 ECHOES the input around the failure point: JSON.parse('{"pw": hunter2SuperSecretValue}') answers Unexpected token 'h', "{"pw": hunter2Sup"... is not valid JSON, so roughly thirty characters of the model reached a warn line 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 through describeAwsFailure -- the same instrument the sibling abandonment path uses -- with the full text at debug; the existing Model 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 durable deployments/ store.
  • state.imports / state.outputReads no longer persist a secret in plaintext, and cdkd 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 rode redactStateForPersist's ...state spread untouched, so an Fn::ImportValue / Fn::GetStackOutput whose name an Fn::Sub assembled around a resolved {{resolve:secretsmanager:...}} wrote that plaintext into state.json and kept it there. imports[].exportName, outputReads[].outputName and outputReads[].sourceStack are now redacted back to the unresolved expression at persist; sourceRegion and imports[].sourceStack deliberately are NOT. No state schema bump — the fields keep their shape. The third of those IS the literal key findDownstreamConsumers matches 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 in docs/design/3289-cross-stack-read-name-redaction.md. Residuals: MIN_NEEDLE_LENGTH still 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, and cdkd scrub cannot reach the rotated one either. cdkd scrub is owed the repair (go-to-k/cdkd#3337).
  • Every AWS::DynamoDB::Table OnDemandThroughput ceiling 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 are Long in the SDK model, and all FOUR send sites forwarded the declared block verbatim, so a CFn-legal MaxReadRequestUnits: '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 read coerceCfnInteger, 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 whole GlobalSecondaryIndexes array, invisible to any grep for the property name -- the blind spot that also hid its ProvisionedThroughput twin. 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 and cdkd drift reports it until the template is fixed, tracked as issue #3286. The two per-index Update actions send no ceiling at all -- a missing send, issue #3287.
  • --remove-protection no 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::GlobalCluster and their siblings) polls its own UpdateResource through waitForOperation, 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, the DeleteResource that 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 a cdkd destroy --remove-protection against a dead link fails in seconds rather than minutes per protected resource. The grace is a per-call argument to waitForOperation defaulting 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 the aws cloudcontrol get-resource-request-status line -- 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 as User: 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 at debug.
  • 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 own delete() 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 named PageNotFound would 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 new isWaitAbandonedError first; the replacement arm, which carried no typed guard at all, also gains isInterruptedWaitError and isMarkedNonRetryable. A marker rather than an exported class, because three of the four cannot import the provider without closing a cycle. cleanupFailedCreateRemnant takes the same guard for a sharper reason: its isNotFoundMessage is case-INSENSITIVE, so a getaddrinfo ENOTFOUND ... cause matches /not\s*found/i and 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 durable deployments/{runId}.jsonl, and it interpolates whatever the poll failed with. A 403 there carries the account id and the assumed-role ARN; a CredentialsProviderError carries the credential_process helper's ARGV and its stderr (measured: a vault token). Both are now reduced to the wire error name, with the full text at debug so --verbose still diagnoses it. A TRANSPORT failure keeps its wording -- connect ECONNREFUSED <ip>:443 is 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 *ProviderError family, 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 of a non-Error value of type undefined; a non-Error rejection is WITHHELD but reported by type rather than dropped; and an Error the SDK normalized to an empty message renders no dangling colon. describeAwsFailure also stopped being able to out-throw the failure it describes: String(value) throws for a null-prototype object, which replaced the abandonment with a TypeError. The shared isAwsAuthoredFailure keys on $metadata PRESENCE, 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 at abandonWait.
  • Fn::GetStackOutput no 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 in intrinsic-resolver-name-argument-log-twin.test.ts. The resolver masked every name it printed itself, then handed the RAW StackName and Region to the state read -- they are state-key segments -- and S3StateBackend quoted them back through an ASCII sanitizer, not a masker. The read was uncaught, so that sentence reached evaluateConditions' default-verbosity warn, and every other caller as a throw, with an Fn::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: formatError renders Caused by: <cause>, so masking a fresh top-level message alone would print the original one link down. The clone keeps every own descriptor, so markNonRetryable's marker, $metadata, Code and name still reach the retry classifiers. maskSecretsInError takes 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 the displaySafe-sanitized spelling as well, that being what the reader is shown. The DescribeStacks fallback warn takes the same pass, since a real AccessDenied quotes 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; the Fn::ImportValue sibling 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 -- maskSecretsForLog masks, it does not sanitize.
  • cdkd deploy no longer REPLACES a live resource because of an unreadable properties map in state (issue #3191) -- src/analyzer/diff-calculator.ts, src/state/malformed-resources-bag.ts, src/cli/commands/diff-recursive.ts, plus diff-calculator-malformed-properties.test.ts, diff-recursive-malformed-properties.test.ts, deploy-engine-malformed-properties-refusal.test.ts and cases in malformed-resources-bag.test.ts. parseStateBody validates nothing inside a record, so a resource's properties could be a string, a list, a number or null -- from a hand edit, a torn write, or anyone holding s3:PutObject on 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, an AWS::S3::Bucket declaring BucketName against a stored "abcdef" reached requiresReplacement: true, a DELETE + CREATE of the live bucket. calculateDiff now REFUSES (STATE_RESOURCES_MALFORMED, exit 1) before provisioning or writing state, naming up to five damaged records then a count -- under --dry-run too, 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 stored 5 enumerate 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 through displayIdent, so a padded "Bucket " no longer prints as a bare Bucket indistinguishable from a healthy sibling. cdkd diff still 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 absent properties is refused; an empty {} is healthy.
  • cdkd scrub no 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 an Fn::Sub variable map in bare sequential loops with no per-unit try, 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 in state.json survived a cdkd scrub that reported the stack clean. Each unit now resolves under its own try: a failed FETCH is recorded and the walk continues, so the later reference is scanned and redacted. cdkd scrub opts in at the three passes that discard the resolved value (resource properties, orphan records, output values); Export.Name keeps 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 --fail even 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, an ssm-secure spelling over a public parameter, and the NAMELESS spellings ({{resolve:secretsmanager:}}, #2692). The recovery is OPT-IN via ResolverContext.abandonedResolutions, and a caller that keeps its resolved value must fail on a non-empty bag; deploy, diff, drift and rollback pass 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 gc and cdkd bootstrap --destroy stop 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 holding s3:PutObject, the premise #3207 already rests on for the record BODY; describeStateKey split one into stack (region) and every reader put that in front of an operator deciding whether to destroy something. Measured before the change: a segment Prod<ESC>[2K<CR>SafeStack rendered as itself, so a terminal ERASED the line and showed SafeStack (us-east-1) -- a different stack than the record names. Both halves now render through displayIdent, and so do the raw key beside each row, the marker-sibling region list, and three logger.debug keys -- 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.ts re-parsed its own rendered string to build a cdkd state show ... command it tells the operator to RUN, which measured out as cdkd state show $(whoami) --stack-region us-east-1. It now takes the parts from a new structural parseStateKey and offers the command ONLY when both pass isPasteableIdent, else names the key. That predicate is an allow-list plus the displayIdent round-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 about displayIdent's cut marker, in the same held file); go-to-k/cdkd#3313 is a separate listing defect this surfaced.
  • cdkd destroy no longer reports SUCCESS having deleted nothing over a record whose resources map cannot be read, and cdkd deploy no 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. parseStateBody validates nothing inside a record, so resources could hold a string, a list, a number, a boolean or null, and the destroy routed on Object.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 was 0, the run took the empty-stack fast path, deleted state.json with no confirmation, and reported success, leaving every resource it named live in AWS and untraceable. A string enumerated one id per character; null and an absent field threw a bare TypeError. The deploy read the same map and planned every template resource as a CREATE. All three now REFUSE (STATE_RESOURCES_MALFORMED, exit 1) 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-run too; 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 and DiffCalculator. The discriminator is the container's SHAPE, not its size, so an empty {} still takes the fast path and still deploys. The destroy refusal names cdkd state orphan for dropping the record and leaving the resources -- as a template, not a pasteable command, and withheld when the identity does not render exactly. cdkd diff and state show still repair and warn.
  • A 1-3 character secret in a nested-stack parameter spelled as an Fn::Join / Fn::Sub around an ssm token (or a service left to a Ref), or with a Ref outside 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 in tests/unit/deployment/nested-stack-refused-frame-carry.test.ts and nested-stack-refused-frame-carry-delegation.test.ts, a Framed arm in the nested-stack-3level integ. The sub-floor carry certified an intrinsic frame only on a secretsmanager: / ssm-secure: spelling the position pass wrote. The resolver now records, per Fn::Join / Fn::Sub object 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's Parameter, Ref and Fn::Join lines mask it whole, pass-through and re-wrapped alike; nested-stack-secret's SubFloorPinSsm lines now read ***, not port:***. Still refused (listed on recordNestedStackParameterExpressions): a public or multiple replacement, a token inside a nested intrinsic part or a used Fn::Sub variable, an Fn::If around 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 ProvisionedThroughput now 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()'s CreateTable mapper and all three applyGsiUpdates actions (the adopted-index repair, the Create and the same-name Update). So a CFn-legal ReadCapacityUnits: '7' went out as the STRING "7" in a number field 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 only create() 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 no 5 default); a non-object block is forwarded verbatim. The GUARDS around the update sites (liveCapacityAlreadyMatches, skipZeroCapacityIndexUpdate, the change detector) deliberately stay on the wider toFiniteNumber, 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 on coerceIndexCapacityBlock and indexCapacityForSend. Item 2 of the issue (RecoveryPeriodInDays) is untouched and it stays open for that.
  • cdkd diff --recursive now 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 its Metadata['aws:asset:path'] with no visited set and no self-reference check: indexNestedChildTemplates refuses 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 with Your key is too long against 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. buildDiffTree now 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 through displaySafe, 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. waitForOperation polled GetResourceRequestStatus with no per-poll error handling, so a transport failure propagated out of the loop and took the RequestToken -- 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, to cdkd destroy, and to cleanupFailedCreateRemnant, whose first guard admits only a CloudControlOperationFailedError. Reported against an AWS::RDS::DBInstance that reached available while 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, since GetResourceRequestStatus invokes 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's Rate exceeded, which the retry classifier matches, so the outer withRetry re-invoked create() 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 new CloudControlWaitAbandonedError carrying the token, the last Identifier any poll reported, and a pasteable aws cloudcontrol get-resource-request-status line. Fixes all four waitForOperation call 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 invalid and the ec2:DescribeSecurityGroups rejection, 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 no IAM_PROPAGATION_ERROR_MESSAGE_PATTERNS entry, so neither was retried: One or more security group IDs are invalid and doesn't have permission to perform ec2:DescribeSecurityGroups, which the list's not authorized to perform does 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 and ec2: 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 for Invalid 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 the ec2: 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. isNameCollisionError recognised only already exist(s) and the AlreadyExists code, and ELBv2 says neither: measured live (us-east-1, 2026-09-16) a duplicate name answers DuplicateTargetGroupNameException with A target group with the same name 'x' exists, but with different settings, and the NAME never reached the predicate because providers render Failed 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 reporting No completed operations to roll back), and the rollback executor's delete-new-first arm could not be reached either. New isNameCollisionErrorFrom reads the ERROR instead, walking the bounded cause chain for an exception NAME in NAME_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 why DuplicateListenerException / DuplicateTagKeysException are 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 outputs bag stop fabricating from it, cdkd deploy and cdkd destroy included (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 shared hasReadableOutputs at 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 a null / 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's Fn::GetStackOutput arm: Object.hasOwn('abcdef','0') is true, so OutputName: "0" resolved the character a and the deploy SENT it to AWS. It refuses with a new MalformedProducerRecordRefusalError, an IntrinsicResolutionRefusalError subclass so an Fn::Sub cannot launder it -- and so cdkd scrub keeps #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. The resources bag 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 properties bag (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 — the skip-failed-absent precedent), 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 — the refuseMaskedReplayBaseline precedent. Either way the pass continues. Each message names the remedy its arm supports: cdkd deploy, or cdkd drift on --revert-failed, where the op died mid-flight so cdkd diff (which reads state.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, while previousState is 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: the reverse-replacement-readopt arm writes such a record into state.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. The reverse-replacement replay-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 in DeployEngine fill a FALLBACK_NAME_RULES name 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 through applyDefaultNameForFallback when the registry's ROUTING DECISION -- not the recorded provisionedBy hint, which is absent on a pre-v7 record and can disagree via the sticky rule's sdk-coverage exemption -- says cc-api; an SDK-routed replay is untouched, since its provider mints the name itself. Both rollback entry points already bind withStackName, so the replayed name is byte-identical to the forward create's. The fill lands only on the bag handed to create(); 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::TargetGroup on Port / VpcId, AWS::WAFv2::WebACL on Scope) -- 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 scrub no longer loses a secret reference because an unrelated property in the same resource failed to resolve — the per-resource pass handed resolver.resolve the WHOLE Properties bag, and one throw aborts the walk, so an unresolvable Ref in property A abandoned the {{resolve:secretsmanager:...}} in property B. B recorded no needle, its legacy plaintext was never rewritten, and the stack could still print No plaintext secrets found at exit 0. Reachable by accident — scrubStack catches resolveParameters wholesale and continues with an EMPTY parameter bag, so ONE parameter with no Default makes every {Ref: <param>} in the stack throw — and, on a repo running cdkd scrub --all --dry-run --fail in CI, defeatable on purpose by putting one dangling Ref ahead 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; pinCrossRegionSecrets already walked per-leaf and returns the same shape; a CloudFormation intrinsic references other RESOURCES, never a sibling property; and resolveCrossStackReads stays 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.A taking out ...B), because the resolver's own object walk has no per-key try — #3218 tracks that. Issue #3196; the counted-finding half is #3160 and the resolver's per-token loop is #3181.
  • cdkd orphan, import and scrub refuse a state record whose outputs bag is not an object instead of saving a rebuilt one over it; cdkd scrub exits 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, four docs/ pages, docs/design/3192-outputs-consumers.md, cases across 10 test suites plus a new tests/unit/state/importable-outputs-malformed.test.ts. Sibling sweep of #3189, which guarded cdkd diff alone. Two WRITE paths, confirmed by execution: cdkd orphan rebuilt outputs: '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 shared exports.json. Such loads now REFUSE (STATE_RESOURCES_MALFORMED), naming the broken container. Exit-code change: a cdkd scrub whose CONSUMER is healthy but whose PRODUCER record cannot be read now raises SCRUB_PRODUCER_RECORD_UNREADABLE and exits 2 — with or without --fail, --dry-run included — rather than exiting 0 over a stack whose imported plaintext it could not verify (docs/cli-scrub.md carries the table). The exportNames FIELD is a further site: a hand-edited non-array killed cdkd diff with a raw TypeError, and now reads as an EMPTY export set, never the pre-v9 every-key rule re-opening #2193's shadowing; cdkd diff WARNS, naming stack and region. The same predicate covers the deploy resolver and cdkd local. An ABSENT outputs field stays exempt; a healthy or empty bag is untouched and silent. Sites left unguarded are in the design doc and #3207.
  • cdkd diff no longer invents Outputs REMOVE rows from a stored outputs bag 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, so computeOutputsDiff admitted the stored bag on a bare ?? {} -- which covers null and undefined only. Object.entries then walked a string or a list as readily as a map: measured at aced052a, 'abcdef' produced six REMOVE rows named "0".."5", each carrying one character of the record as its old: side, [1,2,3] produced three, and cdkd diff --fail exited 1 on them. The bag is now repaired AT THE LOAD (loadStateOrEmpty), through the isReadableBag predicate the resources guard already used, with malformedOutputsWarning naming 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 answered hasOwnProperty TRUE on a string for '0' / 'length' and THREW outright on null, one call above the walk. computeOutputsDiff takes 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 an ADD and no stored key as a REMOVE. An ABSENT outputs field is exempt -- inert on this path, and a record cdkd itself writes. A healthy or empty bag is untouched and silent; cdkd diff writes no state, so the repair is in memory.
  • cdkd state show and cdkd state resources no longer invent rows from an outputs, skippedOutputs, attributes or properties that 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 the resources bag; these four containers are walked by the same renderers with the same Object.entries, out of the same unchecked cast, and kept fabricating a row per character or element. A view reports only the containers IT renders, so cdkd state resources speaks about attributes in --long and --json and is silent in its plain listing. Measured on the shipped bundle: cdkd state show over a HEALTHY-bag record whose outputs held a 5,000,000-character string cost ~1616 ms / ~1010 MB RSS and emitted 5,000,002 lines; skippedOutputs cost ~2436 ms / ~2148 MB, and --show-nested pays it per node. Each unreadable container is now emptied at the render ENTRY -- through the plain-object predicate isReadableBag the resources guard 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 show renders the record without that block (no Outputs:, no Skipped outputs:, Attributes: (none), Properties: (none)), judged per node under --show-nested; cdkd state show --json still emits the stored value verbatim so the evidence survives. An absent or null container is NOT a defect here, unlike the resources bag: skippedOutputs is absent on every pre-#2740 record and attributes is 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 resources and cdkd state show no longer invent resource rows from a resources bag 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 in resources. Object.entries accepts all four: "abcdef" yielded six [index, character] pairs, rendered by cdkd state resources as six resources that do not exist -- in --json too -- and by cdkd state show's text view under a Resources (6): header. One pair per element also made a planted multi-megabyte bag a memory-exhaustion path. Both commands now repair the bag through repairMalformedResourcesForReadOnly and warn through malformedResourcesWarning (src/state/malformed-resources-bag.ts), the treatment cdkd diff already takes; neither refuses. cdkd state resources repairs at its LOAD, covering both output modes; cdkd state show repairs after every --json branch, per node of the --show-nested tree, so cdkd state show --json still 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>~0 and abort. An absent or null bag warns too, rendering unchanged; cdkd state list --long still counts those as 0. A healthy or empty bag is untouched and silent.
  • cdkd state list --tree no 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 --tree path recursed once per level of the tree -- the child sort inside buildStackTree, the box-drawing renderer, stackTreeToJson, and the JSON.stringify that serializes it for --tree --json -- and NONE of them sits inside renderTreeMode's per-stack guard, which covers only the state READ. So a long enough chain of parentStack links ended the command with a RangeError and printed no rows at all, for every stack in the bucket, not just the chain's own. buildStackTree now 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.json can 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 a previousState of "x" lost the whole desired bag — every arm coalescing with ?? {} — and a patch provider applies that by removing every property; a properties of "abc" (on the operation or inside previousState) reached a provider verbatim, a non-string previousState.physicalId was rendered, compared AND written back into state.json, and oldResourceRetained: "no" selected the readopt arm through ?? -- skipping the re-create and re-pointing state at the old physical id. parseRollbackJournal refuses 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, since previousState is forwarded verbatim from a state record parseStateBody deliberately does not validate, and provisionedBy is not refused at all -- an unrecognised value only routes to the SDK provider, so refusing it would lock cdkd rollback out of a journal cdkd itself wrote. The state.json half of the issue was refused against the placement decision parseStateBody records, and its remainder tracked as issues #3018 / #3191 / #3211; an absent properties still reaches an arm that coalesces it to an empty desired bag, which is #3203.
  • The AWS::DynamoDB::Table capacity and PITR forwarders read an Integer the way CloudFormation does, instead of forwarding a bare Number() 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 every toFiniteNumber-reading DynamoDB forwarder onto coerceCfnInteger, CloudFormation's MEASURED grammar (/^[+-]?\d+$/, NO trim). That sweep grepped toFiniteNumber, so this provider's reads never appeared: they go through Number(), wider still. Five sites move: create()'s table-level ProvisionedThroughput, its twin on update()'s BillingMode-flip arm, the per-GSI readCapacityNumber, and recoveryPeriodInDays. A template spelling " 7 " / "0x9" / "1e1" -- every one rejected by CloudFormation -- reached AWS as 7 / 9 / 10, and "" as 0 (not the ?? 5 default: '' 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 ?? 5 default. RecoveryPeriodInDays is dropped with a warning, so PITR enables at DynamoDB's default period rather than failing on NaN; warn not throw, since the update path is replay-reachable. The MIRROR predicates hasUsableTableCapacity / hasUsableDeclaredCapacity move onto the same reader, closing a divergence the other way: Number(null) was 0, so a DECLARED null read as unusable and refused the pre-flip index removal while the forwarder's ?? 5 sent 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 RetentionInDays falsy 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] applyUpdate refuses a RetentionInDays of 0 / '0' / false / null on a template bag, admitting 0 only under UpdateContext.desiredFromAwsReadback. The rollback executor's two revert arms hand update() the journaled previousState.properties and set no such flag -- correctly, since that bag is a template recorded earlier, not an AWS readback -- and UpdateContext had no replayingState counterpart to CreateContext's. So a record spelling the retention falsy (a pre-#2699 template cdkd accepted, or drift --accept writing a readback 0 onto a record with no observedProperties) was REFUSED where it used to take the delete arm: the rollback op failed and the resource stayed at the failed deploy's retention. UpdateContext now carries replayingState, 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 through Number() ('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 and drift --revert keep 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, new tests/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 exported renderDisableCommand -- displaySafe(asciiOnly), then shellQuote, 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 one manualBucketDeletionClause, and both S3 arms now route their whole warning through the caller's masker, which they previously never reached at all. renderDisableCommand gained an optional maskSecrets: shellQuote rewrites 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 what shellQuote emits and what issue #2669 established; three existing assertions moved with it. Residuals: the PROSE half is #3269, the unquoted S3 directory-bucket command #3270.
  • ignoreAssumedRole now genuinely resolves the CALLER's identity, so the cdkd local opt-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 very AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN triple applyRoleArnIfSet had just overwritten with the role. Only the --profile shape was safe, because credential-provider-node skips that link once a profile is named. So with --role-arn and no profile, cdkd local run-task / invoke / start-api resolved ECS SecureString and Secrets Manager PLAINTEXT as the deploy role and injected it into the user's container, took the --assume-role STS 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 second applyRoleArnIfSet is now refused BEFORE its sts:AssumeRole rather than after. That shape-3 chain also drops credential-provider-node's Multiple credential sources detected warning -- false for a triple cdkd itself wrote -- while forwarding every other warning.
  • cdkd local invoke / start-api / run-task / invoke-agentcore no longer hand an emulated function or task a --role-arn assumed 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-arn overwrites the AWS_* credential triple with the role's, and each command copied it into the container, so cdkd local invoke --role-arn <deploy-role> MyFn ran the user's own handler AS THE DEPLOY ROLE, normally the more privileged identity. A --profile-flag overlay masked it; an EXPORTED AWS_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; --sigv4 host 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 bare cdkd local invoke that is nothing, so its first AWS call fails loudly; pass --profile or --assume-role). A run with no --role-arn is unchanged. The four long-running local start-* commands are NOT covered -- start-service, start-alb, start-cloudfront and start-agentcore hand 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 --profile FLAG alongside --role-arn there (issue #3240). Mechanism and both channels: .claude/rules/local-caller-identity.md.
  • --role-arn is now the identity every AWS call cdkd makes runs as, even when --profile is 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. applyRoleArnIfSet published the assumed credentials through the AWS_* env vars alone, and @aws-sdk/credential-provider-node skips the env provider whenever a profile is selected -- so --profile ci --role-arn <r> assumed the role, reported it assumed, then ran every call as ci, in whatever account that was; an ambient AWS_PROFILE did it with no flag. Clearing AWS_PROFILE would not have fixed it (cdkd also threads profile as an explicit client-config key, which outranks the environment), so the credentials go to awsClientDefaults() instead -- spread FIRST at all 157 client sites under src/**, so each takes the role unless it opts out or carries its OWN credentials -- 18 sites on the cdkd local surface opt out deliberately, to keep the emulated workload on the caller's identity. Published once only: a second publish is refused and the AssumeRole hop itself opts out, so no run chains role onto role. What cdkd local resolves FOR the emulated workload keeps the caller's identity, now fenced at every client site on that surface including new AwsClients(...) bags.
  • UPGRADE NOTE for anyone already combining --profile with --role-arn (issue #3130) -- docs/cli-reference.md, docs/cross-stack-internals.md, docs/troubleshooting.md; the behaviour they document comes from src/utils/aws-client-defaults.ts and src/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-bucket cannot point back at the profile account's bucket, because ExpectedBucketOwner rejects it. (2) A cross-account Fn::GetStackOutput assumes the producer role AS the assumed role, so the PRODUCER role's trust policy has to name the --role-arn role rather than the profile's principal -- until it does, the first deploy resolving such a reference fails with AssumeRole into <producer> failed: AccessDenied, which names the producer role and not the principal that changed. (3) CDK_DEFAULT_ACCOUNT becomes the role's account, which is correct (it is where the stack is deployed), while the CDK app's OWN context lookups -- Vpc.fromLookup and 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 --region and let --role-arn supply the identity alone). docs/cli-reference.md carries 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}} passed hasReadableResources, which tests the BAG, and surfaced as a bare TypeError; so did a typeless object. cdkd state refresh-observed and cdkd drift --accept / --revert REFUSE (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 a cdkd deploy example only for an exact name with no *, / or leading -. Before, a LIST bag was refreshed and saved, and both drift writers persisted phantom 0, 1 rows. Plain cdkd drift and cdkd diff REPAIR instead and warn, reporting each dropped row, or an unreadable map as one (resources map) row: drift as notCompared (new cause unreadableRecord), exiting 2 (1 if drift); diff in its preview and --json unreadable, 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; a null one 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 writing refresh-observed command 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::CapacityProvider has no CapacityProviderName deploys, instead of failing with Resource 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 a FALLBACK_NAME_RULES entry, 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 to add it -- 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's UserPoolName). The same holds for a name an SDK provider's preparePropertiesForFallback hook 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 with AlreadyExists (measured). Live coverage: tests/integration/lambda-capacity-provider-default-name/.
  • VectorIndexes on AWS::DynamoDB::Table / AWS::DynamoDB::GlobalTable is 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 nested VectorIndex / VectorAttribute / SearchSchemaElement definitions. Before this, VectorIndexes post-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 no provisionedBy: 'cc-api' record, reportUnrecognizedProperties warned 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 a silentDrop entry 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 in STICKY_CC_MIGRATION_EXEMPT, so an existing SDK-provisioned table whose template gains the property moves to Cloud Control and pins provisionedBy: 'cc-api' ONE-WAY. The snapshot does not mark it create-only, and its capture is top-level-only, so createOnlyDrops and 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 in cdkd state list or in the state orphan / state refresh-observed confirmation prompts (issue #3164) -- src/utils/display-safe.ts, src/cli/commands/{state,rollback}.ts, docs/cli-state.md, new tests/unit/cli/state-ref-display-boundary.test.ts. Both halves of a reference now render through displayIdent instead of the bare displaySafe(..., { 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 key cdkd/ProdStack (us-east-1)/state.json printed byte-equal to the genuine ProdStack in us-east-1, and a cdkd state list | while read -r ref cleanup loop acted on the wrong one. One trailing space did it too: sanitization maps non-printable ASCII to a space then TRIMS, so cdkd/ProdStack /... was trimmed back to ProdStack before any boundary test -- as were leading space, tab, NUL, ESC and a padded REGION. displayIdent now 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 rollback takes 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; --json is unchanged and still unsanitized (go-to-k/cdkd#3163, go-to-k/cdkd#3179).
  • cdkd scrub no longer reports a stack CLEAN when a dynamic reference failed to RESOLVE — a deleted SSM parameter, a secret with no SecretString, a missing JSON_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 in state.json survived under No plaintext secrets found, exit 0. These are now a counted FINDING rather than a silent debug line: 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 --fail exits 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: sendWithThrottleRetry rethrows an AWS rejection RAW, so a genuinely deleted parameter arrives as a bare ParameterNotFound with 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 unresolvable Ref / Fn::GetAtt or a Default-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 in intrinsic-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, so env-${P} printed env-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 same Fn::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 by canonicalizeRegion, a nested stack's output name sliced off Outputs. (*** when a mask covers that prefix; also in the declared list), and an invalid region at the Fn::GetAZs / Fn::GetStackOutput gates, *** 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-stack origin is built from twinned names. No log twin is newly registered: Fn::Base64 output 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.json whose operation carries a non-string logicalId is now refused by the parser instead of crashing cdkd rollback at its first lookup (issue #3140) -- src/types/rollback-journal.ts. parseRollbackJournal validated only journalVersion and that segments was an array; every per-operation field was an unchecked cast, and the executor keys every lookup on op.logicalId -- so a numeric id coerced into the record named '123', and an object whose toString is not callable threw a raw TypeError from stateResources[op.logicalId] after the lock was taken. The parser now refuses a segment that is not an object, an operations (or a present failedOperations) that is not an array, an operation that is not an object, and an operation whose logicalId is not a non-empty string, whose resourceType / changeType is not a string, or whose present physicalId is 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's rollback-journal.json; cdkd destroy sweeps it too). The same text reaches cdkd deploy's Failed to write rollback journal warning, since appending a segment parses the existing journal first. Every journal cdkd itself writes passes unchanged. The executor keeps its own typeof guard on the pasted --orphan remedy 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 to toCfnInteger's doc beside the Logs one), src/provisioning/providers/dynamodb-globaltable-provider.ts (every capacity / ceiling forwarder, the auto-scaling RegisterScalableTarget min / max and PutScalingPolicy cooldowns, plus the diagnostic / mirror predicates paired with each), .claude/rules/layout-provisioning.md, unit tests. Live A/B on AWS::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 through Number(), so a template CloudFormation refuses deployed with 7 / 9 / 10. They now read through config-shape.ts's coerceCfnInteger (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 on toFiniteNumber, where over-acceptance is the safe side. toCfnInteger (the Logs helper) is unchanged. The AWS::DynamoDB::Table provider's raw Number(x ?? 5) capacity forwarders are a sibling class with their own default arithmetic, filed as #3147.
  • cdkd drift no longer hides a drift at a key literally named __proto__ and no longer flattens a Date readback to {} before comparing it (issue #3121) -- src/analyzer/{drift-normalize,drift-principal-normalize,cc-api-strip,drift-calculator}.ts, new src/utils/own-keys.ts (hasOwnKey / ownValue / defineOwnKey / hasPlainPrototype / nullPrototypeRecord, moved out of src/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 (what JSON.parse yields from state.json or a Cloud Control Properties document) was assigned as the rebuilt node's prototype and dropped symmetrically, so calculateResourceDrift returned [] for two bags differing only there; and Object.entries(new Date()) is [], so a Date member of a raw SDK readback reached the comparator as {} -- phantom drift against an ISO-string baseline, and cdkd drift --accept then 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 inside drift.ts. Net effect: a top-level or nested __proto__ drift is detected, reported and accepted like any other key, and a Date readback is compared as itself and -- on a resource with no recorded secret -- persisted by --accept as its ISO string (with a recorded secret the redaction value walk still flattens it, #2427); drift-calculator.ts compares a Date in its JSON form, so the next run reads that string and the readback's Date as EQUAL rather than as drift forever. Sweep: grep -rn "Record<string, unknown> = {}" src/analyzer/ 9 -> 3 (the three left are orphan-rewriter.ts / diff-calculator.ts, not on the drift comparison path); 0 key in bag membership tests in the three files.
  • A 1-3 character secret that an Fn::Join / Fn::Sub embeds in a longer string under Fn::Base64 (cdk.Fn.base64('port:' + secret.secretValueFromJson('pin')), the CDK UserData shape) is no longer persisted to state.json in its base64 form (issue #3119) -- src/deployment/intrinsic-function-resolver.ts (resolveBase64), a unit case in tests/unit/deployment/intrinsic-resolver-sub-floor-log-mask.test.ts, and a Base64Pin arm in the secrets-dynamic-ref integ. Issue #2759's mask-only detector at that site asked the needle mask alone ("did masking change the input?"), which cannot see a plaintext below MIN_NEEDLE_LENGTH (4) unless it is the whole text, so the encoding of port:q7 reached the persist path in the clear -- decodable in one command -- while the Resolved 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 direction logTwinOfProduct accepts, since such a string carries the secret's text.
  • --verbose no longer prints a 1-3 character secret on a nested-stack child's parameter lines, or on an Fn::Join over a value that still spelled a reference after an earlier stage resolved it (issue #3114) -- src/deployment/intrinsic-function-resolver.ts, with cases in tests/unit/deployment/intrinsic-resolver-nested-child-log-twin.test.ts and a SubFloorPinSsm arm in the nested-stack-secret integ. 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 gives Fn::Split its piece masks, now reads the inherited bag too, and two different registered twins mask the whole string. The Parameter X: using user-provided value, Resolved Ref to parameter and four Fn::GetAtt value lines now mask through the resolver's leaf mask, and a Fn::Join part that is re-resolved starts from its registered twin. It matters where the parent's carry refuses the frame, such as an Fn::Join around an ssm token. One state effect: Fn::Base64 over 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: an Fn::Sub string variable, which is template text its one substitution resolves whole.
  • cdkd diff --fail now 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 in tests/unit/cli/diff-recursive.test.ts and tests/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 own mergeNoChangeOutputs: 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 own Condition, or Fn::If / {Condition} outside Resources and Conditions; 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, an AWS::NoValue symbol, an unsubstituted Fn::Sub placeholder, or a container holding one), and when an Export.Name cannot be resolved or decided.
  • Fn::GetAtt [<SecurityGroup>, VpcId] on a security group declared WITHOUT VpcId (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 an Fn::Join / Fn::Sub (issue #3097) -- src/provisioning/providers/ec2-provider.ts (readSecurityGroupVpcId), src/deployment/intrinsic-function-resolver.ts (the AWS::EC2::SecurityGroup VpcId arm, isImpossibleEmptyStoredAttribute), a default-VPC group and its Output in the ec2-instance integ. The provider copied the TEMPLATE's own VpcId with ?? '' (the last two sites #3077 exempted), so a template with no VpcId recorded ''; the resolver arm behind it answered undefined. Both halves now read the group back: the provider records VpcId from a best-effort DescribeSecurityGroups after 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 marked sg-<hex> shape guard on the persisted physical id. A LEGACY record holding VpcId: '' (a pre-#3097 binary, group declared without VpcId) is read as absent for this one attribute (no security group has no VPC), so the live arm answers; the record keeps '' until the next update(). GroupId is unchanged. The attribute-map fence's allow-list is now empty; the handled-property-wiring matrix records VpcId seeded 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.sh Phase 0 (c). When the state record omits the attribute (#3077) and the live read finds it unassigned or fails, the AWS::EC2::Instance PrivateIp / PublicIp / PrivateDnsName / PublicDnsName / AvailabilityZone arm, the AWS::EC2::VPC DefaultSecurityGroup arm (new live filtered DescribeSecurityGroups, cached for the run) and the new AWS::CloudFront::Distribution DomainName arm (live GetDistribution, cached) throw an IntrinsicResolutionRefusalError (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 an i-... / 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 / DBProxyEndpoint VpcId with 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 in skippedOutputs, then re-resolved on the next deploy; Fn::Sub re-raises it (#1740); Conditions still absorb it. InstanceId / CloudFront Id keep answering the physical id. A VPC record whose id is not vpc-<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 --long no longer loses the whole listing to one stack it cannot read (issue #3069) -- src/cli/commands/state.ts (stateListCommand, new readOrFailure), docs/cli-state.md, unit tests. Each row read its state record and lock inside one unguarded Promise.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 shows Resources: unknown (...) and keeps its lock; a row whose lock failed keeps its real counts and shows Lock: unknown (...). --long --json adds stateReadError and lockReadError, and sets resourceCount / locked to null on the failed side instead of 0 / 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: a lastModified outside the Date range (1e300) no longer throws past the guard; a resources that is neither a JSON object nor null shows an unknown count instead of one counted per character or element, and under --json sets resourceCount: null with its own stateReadError reason; and the plain listing and the --long / --tree text rows print the stack name and region through the ASCII display sanitizer, since an S3 key segment can carry a newline or ESC (--json is unchanged). --tree (state-list-tree.ts) no longer empties on a non-string parentStack / parentRegion and drops such a link whole, and a parent loop's stacks land at the root instead of vanishing. Residual: a legacy version: 1 reference with no resolved region is still not read by this view and renders zero resources with no reason attached.
  • A number or boolean Fn::Join part now counts as literal text when cdkd positions a secret reference in state.json (issue #3055) -- src/deployment/secret-redaction.ts (intrinsicSkeletonSegments, new joinPartLiteralText), unit tests. The resolver joins such a part as String(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 as String(part), so the frame is written and carried. A null, array or object part stays unknowable, and a number delimiter is still refused. Residual, stated in joinPartLiteralText'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 public ssm leaf) 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 or Token.asString(8080) folded into a literal string, and a non-string part only from a hand-written Lazy.any inside Fn.join.
  • A malformed resources bag no longer kills cdkd diff, import, orphan, rollback or scrub with a raw TypeError — a hand-edited or truncated state.json whose resource map is absent, null, or some other non-object used to abort each of those commands from the first Object.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 diff now 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 — scrub on a real run, import, orphan, rollback — instead REFUSE by name (STATE_RESOURCES_MALFORMED) rather than repairing: scrub saves whenever anything changed, an Outputs change alone included, so repairing would save a well-formed resources: {} over the only evidence that the record is broken, after which the next deploy re-creates the whole stack and the next destroy orphans every live resource. cdkd scrub --dry-run is the one exception — its write gate carries !opts.dryRun, so it audits the record and then exits non-zero rather than reporting No plaintext secrets found, which would make the documented --dry-run --fail CI gate pass over resources it never read. Every message names the record to inspect and states that deploy / destroy must not be run against it. deploy and destroy themselves are unchanged and are tracked as #3161: they abort on a null or 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 --accept and the --revert narrowing write mask an UNCERTIFIABLE position instead of persisting a stored plaintext when the bag lands in observedProperties (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-failing STATE_SOURCED_READBACK_RULES unconditionally although each site already knows its destination: --accept computes hasObserved two lines away, and the narrowing write persists a VALUE only against an observed baseline. Each now selects STATE_SOURCED_BASELINE_RULES (fail-closed, the constant cdkd import and cdkd state refresh-observed declare) when the bag is a drift baseline and keeps the readback constant when it lands in properties, where a mask blocks cdkd export and the rollback replay. Net effect: a pre-#2852 record carrying a rotated-away plaintext at a position the walk cannot pair is cleaned to SECRET_MASK by the --accept it 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, --accept refuses it and --revert preserves the live value there until a deploy rewrites the baseline. A clean record is never made worse.
  • cdkd export refuses 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 (buildImportPlan choke point, resolveIdentifierValue, maskedIdentifierAttributeReason), tests/unit/cli/export-composite-identifier.test.ts, docs/cli-export.md. The #2274 blocker tested properties only, while CloudControlProvider.import writes the mask into attributes for 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 resolved resourceIdentifier or 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 a COMPOSITE_PHYSICAL_ID_IDENTIFIERS resolver failing on a masked attributes.<field> names the mask and the real remedy (re-import with cloudformation: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 --revert no 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 or null at a position the baseline types as a string was shipped to provider.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 (what cdkd deploy sends). 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 now isTokenOrMaskLeaf for 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 --revert no 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.parse on state.json yields 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 failed hasPlainPrototype -- the token preserve walk returned it by identity and mergeUntemplatedValue stopped merging it. The walk rebuilds onto Object.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 in buildRevertNewProperties and --accept's setAtPath define the key as an own data property, getAtPath and the preserve walks read own keys only (a bare read answered Object.prototype for an absent segment), the narrowing delta rebuilds onto a null-prototype target, and every key in bag membership test (mergeUntemplatedValue, collectMissingPaths, collectNarrowedTopLevelKeys, the two plan reporters) is own-key — a property named constructor / 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.ts and 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 --revert records a provider narrowing whose value is a non-plain object (a Date, a Uint8Array) 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 by collectNarrowedTopLevelKeys and runAccept's "was NOT recorded" check), unit tests. The comparator walked own enumerable keys, so any two Dates compared equal (0 === 0 keys): a narrowing at such a key was hidden and observedProperties stayed 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 guard secret-redaction.ts's deepEqualJsonValue already carries -- so the provider's echo is recorded (the truthful answer). The accept warning's wording names both causes. Through the CLI a Date readback 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 RetentionInDays of false, null, 0 or '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_MATRIX re-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 -- while false, 0, '0' (rejected by the Logs handler) and null (rejected at update-stack; cdkd's resolver omits the key for AWS::NoValue, so a null here is a template literal) are refused like 'abc'. One cdkd-side exception: a numeric 0 on a desiredFromAwsReadback bag (cdkd drift --revert) is readCurrentState's own never-expire and still deletes, so reverting a console-added retention keeps working; on a template bag 0 is 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::LogGroup reads RetentionInDays the way CloudFormation does, and refuses the spellings CloudFormation rejects instead of forwarding their Number() reading (issue #2698) -- src/provisioning/dynamodb-warm-throughput.ts (new toCfnInteger, 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. toCfnInteger reuses config-shape.ts's coerceCfnInteger digits rule plus a trim (each helper's padding rule is its own measurement). The DynamoDB capacity forwarders stay on toFiniteNumber until 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 scrub no 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 bare Error that fell through scrub's typed-refusal test into a debug line. The resolver's token loop has no per-token try, 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 in state.json survived under No 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 an Fn::Sub that keeps its raw ${Field} reaches them on a healthy stack; they are tracked separately as #3160. Issue #2692.
  • The LogGroupClass refusal'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 (renderDisableCommand now 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 and deployments/*.jsonl lines. 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 the UNNAMEABLE_ID_CLAUSE. The same-file create-cleanup aws logs delete-log-group command took the same rendering. Behavior change: message only -- the quotes around a clean id are gone. Residual: the other 40 pasteable aws ... commands in provider messages, #3136.
  • Six asset- and state-layer failures now thread the underlying error as cause. The four AssetError arms (docker re-tag, ECR login, tag, push) and both StateError arms (HeadBucket reporting the state bucket missing, PutObject losing the optimistic-lock race) previously wrapped the caught value in an interpolated message and dropped it, so isTransientServerError / isThrottlingError / isMarkedNonRetryable classified on that message alone and extractDeploymentEventError lost 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-arg value through formatError's cause.message — the channel issue #2440 closed. Those four reject through spawnStreaming, whose error carries stderr / stdout / exitCode and nothing else, so exitCode is 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 (the start-api layer merge), unit tests, the local-invoke-layers fixture. Both layer merges copied each layer's asset directory into one tmpdir with fs.cpSync, whose verbatimSymlinks defaults to false in every Node release: a RELATIVE link such as bin/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-link resolved to a path that does not exist inside the container and a handler executing through it failed with ENOENT. 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 with cpSync (so +x survives), 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 bare verbatimSymlinks: true would instead have made Node's C++ cpSync (every release after 22.12) throw EEXIST when two layers carry a symlink at the same path (node_modules/.bin/<tool> from a shared dependency) and a recursive readdirSync descends 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: the local-invoke-layers fixture execs /opt/bin/rel-link and returns real-via-link; the pre-fix binary fails with ENOENT. cdk-local tracks the same fix as #727.
  • --verbose no longer prints a 1-3 character secret that an Fn::Join or Fn::Sub embeds in a longer string (issue #3100) -- src/deployment/intrinsic-function-resolver.ts, with cases in tests/unit/deployment/intrinsic-resolver-sub-floor-log-mask.test.ts and intrinsic-resolver-assembled-secret-region.test.ts, and a --verbose arm in the secrets-dynamic-ref integ. The debug lines masked resolved strings with the recorded-secret needle mask, which matches a value under four characters only as the whole text, so port: + secretValueFromJson('pin') printed as port: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::Split pieces) and prints the twin, masking the whole text when the needle mask also changes the value. It reaches the Fn::Join / Fn::Sub lines, the Fn::Base64 line (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::Cidr and 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 a Fn::Split whose whole source is a recorded secret masks every piece. The resolved value and state are unchanged. Also closes #3113: the nested-stack-secret verbose scan no longer excepts the Resolved Fn::Join: line. Residual: #3114 (a nested child Ref to 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 planted rollback-journal.json (issue #3092) -- src/utils/display-safe.ts (new displayIdent), 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, under cdkd rollback AND the deploy engine's automatic rollback. Fifty-three interpolations of a journal op's logicalId / resourceType / changeType into 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 through displayIdent: 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-ASCII X (AWS::RDS::DBInstance) -- already reverted that 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 --orphan remedy is printed only for an id in CloudFormation's own logical-id charset (~x is plain but shell-expanded; otherwise it says why it is withheld). The new resource's physicalId from state.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, the msg a collision classifier reads, and the reason strings recorded into deployments/*.jsonl (persisted; cdkd events sanitizes 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 Parameters entry spelled as a raw PUBLIC ssm token that a cdkd import record 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. Under STATE_DERIVED_RULES the 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 unpinned ssm reference (a pass-local pair) still certify, while a token this pass never resolved falls to the value scan. Consequence for the rules argument: trustAnyExpression alone no longer changes what the recorder writes for a resolved reference (an unpinned ssm positions through the span arm's empty frame under either ruleset); the constants still decide sourceIsSameGeneration. Unit fixtures that hand the recorder a bare Map now record the pairs the resolver would.
  • A lock's deadline renders the same everywhere it is shown, and a numeric-string expiresAt reads as unknown in the contention refusal as it already did in cdkd state show (issue #3085) -- src/state/lock-contention-message.ts (new exported formatLockExpiry, replacing the private formatRemaining), src/cli/commands/state.ts and src/state/lock-manager.ts (their own copies from issue #3083 deleted in favour of the export), docs/troubleshooting.md, a new tests/unit/state/lock-expiry-renderer-sync.test.ts and a case in tests/unit/state/lock-contention-message.test.ts. The helper takes the RAW expiresAt and tests it with Number.isFinite -- the test isLockExpired makes -- so a numeric string, which the check treats as expired and the next acquire takes over, no longer prints expires in ~Xm in 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 of in ~12m / in under a minute / already expired, matching the LockError refusal that advises the same decision and already did. The sync test refuses the expiry phrases in any other file under src/, and a consumer that hands the helper a difference.
  • A lock whose expiresAt is not a finite number renders as expires at an unknown time instead of expired NaNmNaNs ago (issue #3083) -- src/cli/commands/state.ts (formatLockSummary via a new formatExpiry), src/state/lock-manager.ts (a private formatExpiry shared by the expired-lock takeover warning, the acquire retry line and the final LockError), docs/cli-state.md, docs/troubleshooting.md, and cases in tests/unit/cli/state-record-shape.test.ts / tests/unit/state/lock-manager.test.ts. expiresAt is an unchecked cast, so {}, "soon", an absent field, or the NaN that getLockRecord substitutes for a coercion that throws (issue #2947) all reached formatDuration through subtraction and printed NaNmNaNs, while lock-contention-message.ts's formatRemaining already said "at an unknown time" for the same input. For those inputs the two lock renderers now agree (a NUMERIC string still diverges: formatExpiry reads 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-finite expiresAt as the cause. Two changes on the FINITE path: the LockError refusal reads expires in 4m12s rather than expires in: 4m12s, so all three LockManager sites share one spelling; and the retry line and that refusal render a finite PAST deadline (an expired lock whose takeover was refused) as expired 5s ago where formatDuration used to floor it to expires in -5s.
  • Two nested-stack Parameters spelled through ONE literal frame around two DIFFERENT secrets resolving to the SAME 1-3 character value (port:{{resolve:...A...}} and port:{{resolve:...B...}}, both port:q7) now each persist their OWN frame in the child's state.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 reference cdkd rollback / drift --revert re-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 and cdkd 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 (an Fn::Sub embedding the Ref) 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 leaf src/provisioning/attribute-map.ts (definedAttributes / stringifyIfAssigned), applied at every ?? '' / || '' reachable from a provider's attributes value (96 sites in 18 files): ec2-provider.ts (Instance create + update via a state-gated describedInstanceAttributes; VPC create), rds / docdb / neptune (cluster + instance, create + update), ecr, elasticache, codecommit-repository, servicediscovery, appsync, cloudfront-distribution, eventbridge-bus, s3-vectors, rds-dbproxy(-endpoint), four agentcore-* providers; docs/cli-deploy.md, docs/provider-development.md. Under --no-wait a pending EC2 instance recorded PublicIp / PublicDnsName as '' and a creating RDS-family DBInstance recorded Endpoint.* 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 RDS Endpoint.* resolves to the identifier with a warning until the next update() (#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.Port is never 'undefined'. An AppSync GraphQLUrl or a CodeCommit CloneUrl* / Arn missing from the create response now hits the resolver's *Url / *Arn refusal instead of serving ''. tests/unit/provisioning/attribute-map.test.ts fences the shape; the two AWS::EC2::SecurityGroup VpcId sites are allow-listed (#3097).
  • GenerateSecretString now honours IncludeSpace and RequireEachIncludedType, 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 from GetRandomPassword four 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 (an ExcludeCharacters naming " ' / \ backtick ~ was inert), and a fully excluded charset fell back to lowercase the template had excluded. Now: one generateCharset function serves the member predicate and the generator; the space is admitted under IncludeSpace (and taken back by ExcludeCharacters); under RequireEachIncludedType one 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 replaces byte % n). Three refusals, each measured on CloudFormation rather than read off the docs: PasswordLength below the number of required classes (Password length is too short based on the required types), a REQUIRED class emptied by ExcludeCharacters (All characters of the desired type have been excluded -- with RequireEachIncludedType: false the 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-secure secret a parent embeds in a nested-stack Parameters value through an Fn::Join / Fn::Sub frame (cdk.Fn.join('', ['port:{{resolve:secretsmanager:', secretArn, ':SecretString:pin::}}'])) now reaches the child's state.json as port:{{resolve:...}} instead of port: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, and cdkd diff --recursive reported 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 spells secretsmanager: or ssm-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): an ssm: 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 --verbose the parent resolver's Resolved 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 pack target node22), .github/workflows/ci.yml (the runtime-compat matrix smokes the built CLI on exactly 22.12 — the floor row — and on 24), README / docs/getting-started.md / docs/testing.md / CONTRIBUTING.md and the agent-facing pages that state the range. installing on Node 20 now warns (npm EBADENGINE, pnpm Unsupported engine) and is refused under engine-strict, instead of installing silently onto an unsupported runtime; 22.12 is the floor commander@15 declares, so the next commander major cannot move the floor again as a side effect of a dependency bump. The five surfaces are pinned to one another by tests/unit/scripts/node-floor-sync.test.ts, which goes red when any one of them moves alone. Under bump-minor-pre-major this 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 --fail stops showing a real ADD for 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 in tests/unit/deployment/no-change-outputs-merge.test.ts, deploy-engine-outputs-only-change.test.ts, deploy-engine-skipped-outputs.test.ts and deploy-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 whose Export.Name is 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. computeOutputsDiff reads its secret-expression predicate from the merge module. Follow-up: go-to-k/cdkd#3101.

Recently Implemented (2026-09-13):

  • A planted rollback-journal.json can no longer forge the plan rows cdkd rollback asks you to confirm (issue #3064) -- src/types/rollback-journal.ts, src/cli/commands/rollback.ts, cases in two suites. The journal is a SIBLING of state.json in the same bucket, so anyone with s3:PutObject writes it, and it carries no more validation than an unchecked cast. Issue #3003 hardened the state.json / lock.json readers; this is the reader it did not cover. Two surfaces. parseRollbackJournal interpolated the raw V8 SyntaxError -- which QUOTES the offending input -- plus the stack name (an S3 key segment) and the record's own journalVersion; nothing downstream caught it, because formatError prints a non-CdkdError's message RAW and sanitizes only cause. The worse one is the PLAN PREVIEW, which rendered each entry's logicalId / resourceType / changeType and each segment's reason / runId unsanitized: 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 six previewState LOOKUPS keep the raw logicalId deliberately: sanitizing a key silently mismatches the record it is meant to find, and a test pins that. NOT changed: the thrown error's CLASS. formatError prints message raw 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 GenerateSecretString is 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) reached new Uint8Array('abc') and minted an EMPTY password; a truthy 'false' EXCLUDED punctuation; an unparseable SecretStringTemplate sent the bare password RAW where a JSON document was declared -- as the new AWSCURRENT, silently; and ExcludeCharacters: null THREW from update(), the #1544 hazard one level below the container. Fix: every member is read through the shared config-shape.ts predicate for its type -- the new configIntegerRefusal / coerceCfnInteger pair for PasswordLength (a CFn "32" coerces; 0 / 3.5 / 'abc' / '1e2' / null refuse), the boolean pair for the Exclude* flags, configStringRefusal for 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__. PasswordLength is 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 path generateSecretString hands the message to the same onUnusable skip the container takes, so the value is SKIPPED, announced once, and the previously-applied block is retained via effectiveProperties. IncludeSpace / RequireEachIncludedType (declared, never read) are a fidelity gap: #3068.
  • A 1-3 character secret a parent embeds in a nested-stack Parameters value through a LITERAL frame (Pin: 'port:{{resolve:...:pin::}}') now reaches the child's state.json as port:{{resolve:...}} instead of port: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/above MIN_NEEDLE_LENGTH; the framed value was neither. The parent's recorder now positions a MARKED copy of the row's resolved Parameters and 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 scrub cannot repair it); an Fn::Join / Fn::Sub parameter source is issue #3062.

Recently Implemented (2026-09-12):

  • A malformed GenerateSecretString no 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: changedSecretValue passed the container straight to generateSecretString, which reads ExcludeCharacters through readConfigString -- 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 --revert never hands this key over). Fix: the value is SKIPPED (UpdateSecret goes out with no SecretString, so AWS's merge semantics keep the live value) and the previously-applied block is RETAINED in the recorded bag via effectiveProperties. The second half is load-bearing: recording the malformed desired bag would overwrite the usable one, the next deploy would compare equal, take the unchanged early 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 an onUnusable downgrade, because proceeding is the harm: a malformed container indexes every member to undefined, minting a bare default-charset password, returned RAW as the new AWSCURRENT. create() keeps REFUSING (no live secret to fall back to), now through the same requireConfigObject guard, and WARNS when the bag carries no value source at all (a secret with no version). Both container gates are != null rather 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 tested isPlainObject, but { "Ref": "X" } is a plain object -- so it passed, every member probe indexed to undefined, and the block read as EMPTY: the silent DROP this family exists to refuse (#1581). All FOUR gates had it, not just requireConfigObject: the siblings are safe only in their FIELD halves, and review measured readConfigString returning the FALLBACK for an Fn::If VersioningConfiguration -- versioning OFF on a live bucket, this module's own #1471 headline through the new shape. Widest site: the replication Filter, whose fall-through emits Filter: {} -- valid CFn for "replicate EVERY object" -- so an intrinsic widened replication to the whole bucket; a lost lifecycle Filter expired 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 unresolvable Ref / Fn::GetAtt and on an unknown Fn::*, but Fn::Transform has 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-key Ref / 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 passes onUnusable. No call site changed. The message names the cause, not got an object. The predicate is deliberately NOT shared with the other near-copies; its doc comment says why, and a test pins its keys.length === 1 boundary against the broader keys.every spelling.

  • AWS::S3::Bucket's ObjectLockConfiguration.Rule.DefaultRetention.DefaultEventHold now 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 added DefaultEventHold (an OBJECT carrying Days / Years) under DefaultRetention, and applyObjectLockConfiguration named only Mode / 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, and readObjectLock emits 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 every cdkd drift --revert. The floor moved: @aws-sdk/client-s3 is ^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 malformed DefaultEventHold SKIPS 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 every requireConfigObject site 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 reports NO_CHANGE. cdkd drift surfaces it; drift --revert or any edit to the block applies it.

  • A 1-3 character secret embedded through an Fn::Join / Fn::Sub source, and one cdkd import resolves 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) and tests/integration/import-secret-observed/ (SubFloorParam). The dominant CDK shape -- 'port:' + secret.secretValueFromJson('pin'), an Fn::Join fusing the prefix into the token's opening part, the ARN Ref inside -- 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 unpinned ssm whole-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 import now marks the bag its own resolver produced (pair provenance). Residuals: a nonliteral intrinsic frame (a Ref outside the token) still falls to the scan; a public ssm leaf 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; maskSecretsInText keeps the floor (#2453).

  • cdk-local 0.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 local path reaching derivePseudoParametersFromRegion — Fn::Join Code.ImageUri on local-start-api, local-invoke-agentcore (x3), lambda-resolver — resolved us-isof- / eu-isoe- / eusc- regions as COMMERCIAL even spelled canonically, synthesizing hosts like <acct>.dkr.ecr.us-isof-south-1.amazonaws.com that do not exist. They now resolve aws-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.ts stopped asserting the commercial answer and now asserts the correct literals AND agreement with derivePartitionAndUrlSuffix, the invariant #1821 was really about. Commercial regions unaffected.

    Resolver error text. A --from-state / --from-cfn-stack cross-stack lookup that THROWS no longer puts the error's message into 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.ts now 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 drift now 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 on ResourceState.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 untrustworthy properties with 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 bare observedProperties === undefined would sweep in every pre-observed-capture record. Fix: warnIfPreV10BaselineGap prints ONE warning per stack when the loaded state is below schema v10 AND some resource drift actually COMPARED has no drift baseline (a skipped or unsupported resource 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 < 10 is 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 -- saveState stamps 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 resources refusals can no longer forge a row with the record that caused them (issue #3003) -- src/utils/error-handler.ts, src/utils/logger.ts, both src/state/ backends, the state / export commands, 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 with s3:PutObject can 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 is formatError's Caused by: line, which printed the underlying error verbatim: every site below passes the original error as its cause, routinely a JSON.parse SyntaxError, which QUOTES the offending input -- so an unreadable state.json put bucket bytes on the terminal one line BELOW an already-sanitized message. Measured: state show rendered 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 a LockError rethrow 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.stringify escapes C0, so no line could be forged, but U+0085, the C1 range xterm reads as CSI, and U+2028 survive it -- and getLockRecord logs the parsed lock.json whole, of which two keys were safe. NOT closed: a value throwing on STRING COERCION still throws -- issue #2947.
  • --allow-unsupported-properties is 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. Why prefer rather than a negative is in preferSdkRouteOption'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 in provider-registry-report-silent-drops.test.ts keeps the false sentence gone. Five option-surface probes, all red; live-verified on the built binary.
  • cdkd drift no longer prints the live secret for a resource whose observed baseline cdkd import refused (issue #2952) -- src/cli/commands/drift.ts, tests/unit/cli/drift.test.ts, docs/cli-drift.md, docs/state-management.md. Bug: detection picked observedProperties ?? properties, so a refused record (schema v10's observedBaselineRefused, (#2944)) was compared against the recorded properties -- which after a downgraded Fn::If hold 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: secretPaths comes 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 fourth NotComparedCause, baselineRefused. Detection reports such a resource notCompared rather than drifted, 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. notCompared rather than clean: a silent clean is the "report a resource cdkd never compared as a pass" failure the cause enumeration exists to prevent. Exit code: the new cause inherits outcomeExitSignal'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 / --revert refusals become a second layer, since a marked resource no longer becomes drifted at 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 list render a record whose shape violates its declared types instead of aborting on it (issue #2947) -- src/utils/display-safe.ts (displaySafe no longer throws on a JSON value, new truncateCodePoints), 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 new tests/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 raw TypeError before anything rendered. Inner record-shape validation stays out of the read boundary: parseStateBody is 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. A null resources entry or bag renders in every mode, --show-nested and both JSON walks included. getLockRecord turns an expiresAt whose number coercion throws into NaN, which reads as expired, so a takeover no longer deletes the lock and then throws. displaySafe falls back to Object.prototype.toString when String throws, closing the half of issue #3003's version refusal that its own entry records as still open. A skippedOutputs digest 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 destroy and state orphan among them.
  • A cdkd import baseline 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 only observedProperties: undefined behind — ALSO what a pre-v3 record leaves — so the commands whose job is to FILL a missing baseline filled it against the record's properties, 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 and cdkd state refresh-observed persist the plaintext as a baseline, cdkd drift --accept persists it into properties, cdkd drift --revert pushes the placeholder to AWS OVER the live secret, and cdkd import itself refills a record a SELECTIVE MERGE preserved. Fix: ResourceState.observedBaselineRefused?: true (schema v10). cdkd import sets 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-running refresh-observed. Visibility: cdkd state show renders an ObservedBaseline: REFUSED line. 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 diff previews a rollback-orphan adoption, and cdkd scrub scans the records (issue #2943). Both commands were blind to StackState.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 diff ran no adoption pre-pass, so it reported a CREATE for a resource cdkd deploy re-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 AND cdkd deploy would refuse to start. Reasons print under Blocking (cdkd deploy will refuse): after the full preview. Distinct from --fail's 1 and from 2. --json gains blocking and adoptedOrphans per node.
    • cdkd scrub walked resources and outputs only, so No plaintext secrets found was silent about a field carrying a whole ResourceState, and orphans rode 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.
    • readSiblingPhysicalIds moved from a private DeployEngine method to makeSiblingClaimReader in src/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: Retain resource survives a rollback and leaves cdkd's state, and because cdkd's generated physical names are deterministic, the next cdkd deploy asked AWS for a name the orphan still held — failing and repeating forever (#2902). The rollback now records the discarded ResourceState in StackState.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 rollback and cdkd destroy no longer treat a stack holding such records as empty: the former deleted state.json in 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: absent orphans is 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 by tests/integration/retain-orphan-redeploy and retain-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 observedProperties baseline state.json already 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-closed STATE_SOURCED_BASELINE_RULES for the observed bag whenever the per-resource secrets map was EMPTY. That is right for the fresh drained readback the refusal exists for, but DeployEngine.redactStateForPersist reaches 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's observedProperties, re-written UNCHANGED. A position the walk could not pair (an array AWS reordered, an identity key AWS normalised) became SECRET_MASK, producing permanent phantom drift no cdkd drift run 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 carries markSameGenerationBag's mark, i.e. THIS RUN produced it. drainObservedCaptures marks every bag it installs and is the deploy path's only observedProperties writer, 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 in state.json verbatim. Scope: the derivation, so the orphans arm (#2948) added to the same function inherits it; cdkd state refresh-observed and cdkd 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 passed STATE_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 into observedProperties in state.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 passes STATE_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 --accept keeps the other constant because for a record with NO baseline it writes into properties, where a mask blocks cdkd export and 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 --accept refuses 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 placeholder Default ((#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 into observedProperties -- the GHSA-p5qg-v9gv-hc7w direction. Measured leaking shapes: a downgraded Fn::If dropping one reference while a parameter-Default-sourced one is ADDED elsewhere (totals balance); a reference sourced from a parameter Default or Mappings then dropped (both counts zero, no warn); multi-key dispatch sibling drops; and, from review, Fn::FindInMap's LAZY 4-arg DefaultValue (discarded unresolved on a lookup hit) plus non-literal lookup keys, and Fn::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: an Fn::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-Default value 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's onAbandoned), src/deployment/drain-budget.ts, .claude/rules/layout-deployment.md and layout-deployment-secrets.md. The #2563 drain's wait is capped, so a part it stops waiting for keeps running and records plaintext -> expression LATE. The Export.Name block resolved into a per-output map copied into the outputs pass map in a finally, 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; every redactOutputs now 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 deploy no longer calls a provider's update() for a change the no-change re-check should absorb -- a DeletionPolicy / UpdateReplacePolicy-only flip, or an intrinsic resolving to the stored value -- on a resource carrying a --prefer-sdk-route silent drop (issue #2809) -- src/deployment/deploy-engine.ts (the UPDATE arm's no-change re-check) and tests/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 redundant update() whenever the diff reported a change the re-check exists to absorb, and the attribute-only branch nested inside the skip was unreachable. For AWS::SNS::Subscription, where Region is such a drop, that update is the provider's delete-then-create, so by that code path a DeletionPolicy / UpdateReplacePolicy-only flip re-created the subscription under a new ARN. Live-tested on AWS::CloudWatch::Alarm by sdk-to-cc-autoroute 4b/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 no provisionedBy counts 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 redundant update() used to rewrite it away; the diff and the re-check narrow it before comparing, and cdkd drift compares 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: Retain stays in AWS when a deploy rolls back and its state record is dropped — CloudFormation semantics, and what Retain is 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.md gains 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 UserPool L2 auto-creates that role for mfaSecondFactor: {sms: true}, and cdkd's fast SDK path issues CreateUserPool before IAM has propagated the trust policy, so AWS answered InvalidSmsRoleTrustRelationshipException / "Role does not have a trust relationship allowing Cognito to assume the role". No entry in IAM_PROPAGATION_ERROR_MESSAGE_PATTERNS matched 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 under cdk 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 both create() and update(). 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 in retryable-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) and cognito-provider.test.ts (the inner-loop rethrow, on both paths). Live: edge 5 of tests/integration/propagation-races-2.

  • cdkd scrub / cdkd deploy no longer print a recorded secret that an invisible character had split, and scrub --fail now reports such a key instead of passing over it (issue #2874). Four warning sites in src/deployment/outputs-export-alias.ts decided "does this name hold a secret" from the RAW name and then printed a SANITISED one. stripControlChars DELETES 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 at warn, into the CI logs the --dry-run --fail gate 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 --fail exits 1 on 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 the Export.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.ts and secret-scan-class-superset.test.ts, which scans the BMP to prove the class covers both sanitisers'. The remaining stripControlChars-alone sites keep the narrower class — issue #2888.

  • cdkd drift --revert stops shipping two wrong values to live AWS, and its mask refusal names the right remedy (#2855, #2893, #2881; all in src/cli/commands/drift.ts).

    #2855 — a record refused an observedProperties baseline (#2842) falls back to raw properties holding intrinsic OBJECTS ({Fn::Join: ...}) from cdkd 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 before provider.update (exit 2, remedy: cdkd deploy), scanning the BASELINE at drifted top-level keys, only for records with NO observed baseline (collectUnresolvedIntrinsicObjectPaths).

    #2893 — preserveLiveValuesAtUnresolvedTokens paired 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's pairedLiveItems rule (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, raw Fn::Join records ((#2846)), BINARY leaves. (#2012)'s rule-3 residual is retired: a refused indistinguishable position persists ***, not the readback. Fix: such a position persists as SECRET_MASK, for a caller that DECLARES its bag a drift baseline (STATE_SOURCED_BASELINE_RULES) -- cdkd state refresh-observed and the deploy's own capture. Destination is not derivable: an --accept mask in properties blocks cdkd export. REPLAYED baselines keep the old answer ((#2886) closed: the journal passes the readback constant); cdkd import's capture is one constant away ((#2885)). --revert now 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 import no longer writes an unredacted AWS readback into ResourceState.attributes (issue #2847). attributes was the only bag on an imported record no redactor touched (properties since the GHSA fix, observedProperties since #2828).

    The choke point: resolveImportedProperties redacts attributes against the same secrets map that redacted properties, on the THROW arm too: a resolve that failed partway already put plaintext in the bag.

    The Cloud Control surface: CloudControlProvider.import surfaced the whole GetResource model. It is narrowed to the type's schema-declared readOnlyProperties; uncertified keys are MASKED (***) rather than dropped, LEAF-WISE so containers stay walkable: a dropped key or masked container dead-ends in constructAttribute's wrong physical-id return; a masked leaf is refused by name. An unresolvable schema certifies nothing and warns naming cloudformation:DescribeType.

    The Ref reader: a state-key Ref recovery 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 deploy fails the resource — or the stack OUTPUT reading it — by name. cdkd diff / scrub / import are unchanged; cdkd orphan reports the site unresolvable, --force warns.

    Not closed: a read-only attribute that IS a credential stays in the clear. The same payload lands unnarrowed in the observedProperties drift baseline (#2868); the deploy path rewrites the whole model back (#2925); cdkd export's mask blocker tests properties only (#2932).

    Changed: import.ts, cloud-control-provider.ts, intrinsic-function-resolver.ts, deploy-engine.ts, orphan-rewriter.ts, new read-only-properties.ts.

  • The intrinsic resolver masks a resolved secret AT the throw and in the log line beside it, and registers an Fn::Base64 encoding 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::Base64 over a dynamic reference returned the secret in a reversible encoding no needle matches, so it reached state.json and printed on the debug line that masked its own input. Fix: every throw and log statement naming a resolveValue result masks the RAW value, never the assembled message. The two truncating region gates mask around stripControlChars, never after .slice(0, 64). resolveBase64 records 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, so scripts/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::Base64 also resolves differently on the deploy and diff paths (#2909, predating this change).

  • cdkd state show now renders the skippedOutputs record, the field that explains a missing cdkd diff row (issue #2772) -- src/cli/commands/state.ts, docs/cli-state.md, and new cases in the state show / state resources suites. Issue #2740 added StackState.skippedOutputs -- the Outputs keys the last deploy could not resolve, each mapped to a digest of its template inputs -- and cdkd diff uses 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 --json and in the stored record, and nowhere else. A Skipped outputs: block now renders when the map has entries, after Outputs: 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, across state show and both state resources modes: 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.md carries the per-command detail and go-to-k/cdkd#2947 the shapes still out of reach.

Recently Implemented (2026-09-09):

  • AWS::ApiGatewayV2::Api resolves ExecuteApiArn instead 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 only ApiId / ApiEndpoint and constructAttribute had no handler for the type. A cross-resource Fn::GetAtt reads the CACHED resource.attributes[<CFnName>] and never calls getAttribute, so it reached guardedPhysicalIdFallback, which HARD-THROWS for a *Arn whose fallback is not arn:-shaped -- the physical id is a bare api id. Broken deploy, not a wrong value (the #1179 class). Fix, two halves. CreateApi returns no ARN, so buildExecuteApiArn constructs arn:<partition>:execute-api:<region>:<account>:<apiId> on create, on import(), 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 -- so constructAttribute gained the type too, covering pre-existing, CC-routed and cross-binary records with no property change required. Not allow-listed: a knownGap entry would have turned the critic green in one line; the carries no KNOWN GAP entries fence refuses that, and deleting an entry is what verifies a fix, since classifyType tests cachedKeys first.
  • cdkd import now redacts the observedProperties baseline it captures (issue #2828) -- src/cli/commands/import.ts, three unit suites, one integ fixture. Bug: captureObservedForImportedResources assigned the provider's readCurrentState result onto the record VERBATIM -- the DECRYPTED value for a {{resolve:secretsmanager:...}} property -- at both call sites (the root walk and the recursive --migrate-from-cloudformation child walk). The gap issue #1926 closed for cdkd state refresh-observed. Fix: route the captured bag through redactSecretsForState with an EMPTY secrets map, positioned against the record's own redacted properties under STATE_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, and state.json must still be treated as sensitive -- separately, cdkd import also writes ResourceState.attributes from 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 placeholder Default while 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 refreshed tests/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>Input while <X> is the RESPONSE, so the match lands on a shape no request can carry and members existing only on the twin report definition-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 from AWS::Glue::Connection's definitions, turning four clean keys into blockers. Fix: resolveDefinitionInterface prefers the exact name when it is reachable from an operation input, else an <X><suffix> twin over INPUT_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.WarmUpConfiguration and AWS::BedrockAgentCore::Runtime.CapacityProviderConfiguration become known silent drops routed to Cloud Control; AWS::RDS::DBCluster.AvailabilityZones is no longer create-only (no replacement follows). AWS::Glue::Connection gains its FIRST silentDrop, so an existing SDK-provisioned connection setting Tags moves to Cloud Control and pins provisionedBy: 'cc-api' ONE-WAY -- it is not in STICKY_CC_MIGRATION_EXEMPT.
  • cdkd import masks 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: resolveImportedProperties declared its per-resource recordedSecretValues INSIDE the try that calls resolver.resolve(...), so the catch could not name it and interpolated err.message verbatim into a logger.warn printing at default verbosity. cdkd import sets no skipDynamicReferences, 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 through Fn::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 the try and wrap the message in maskSecretsInText, the shape rollback-executor.ts and drift.ts already document at their declarations. Third instance of one class after #2728 and #2531. Two cdkd scrub catches gain the same call for UNIFORMITY, with comments stating the measurement rather than assuming: resolveParameters is called with no context so its bag is provably empty, and the evaluateConditions catch cannot receive a resolver error at all, that method wrapping every condition in its own try (#2748) and downgrading to false. 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 an Object.prototype member answered where no entry existed, mostly as a WRONG RESULT rather than a refusal. Ref / Fn::GetAtt read resources['constructor'] as the Object FUNCTION and shipped undefined into a live property; Fn::If took the TRUE branch where an unknown condition assumes false; Fn::FindInMap, whose keys are free-form text, returned the function as the value; and a SecretString:constructor reference rendered the function's SOURCE as the resolved secret. Three WRITES lost a __proto__ key to the inherited setter, the worst being filterResourcesByCondition -- a declared resource vanished from the effective template, so the diff read "in state, absent from desired" and issued a DELETE for it. Reads use Object.hasOwn (membership, NOT a value test, so a DECLARED parameter holding undefined still returns it, per #2285); the value-bearing writes SHADOW the key with defineProperty, since Object.create(null) would make String() 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 every in test 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 in rollback-executor.ts / drift.ts, and resolveSub's own map.
  • An Output that never resolves at deploy is no longer a phantom ADD in cdkd diff --fail on the unchanged stack (issue #2740) -- new src/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, new tests/integration/output-never-resolved-diff/, four new unit suites, docs. Behavior change: an output the deploy skipped never reaches state.outputs. When the resolver threw inside a SECRET lookup (a JSON key the secret does not hold) the diff could not reproduce it -- skipDynamicReferences assembles the reference cleanly -- so computeOutputsDiff pushed an ADD on 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 but Resources and Outputs), 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, since Resources is 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-observed once 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 masked NoEcho default).
  • cdkd scrub repairs the cross-stack exports index, not only state.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's state.json write, scrub reads {state-prefix}/_index/{region}/exports.json -- one store per region it touched -- and for every entry whose producerStack / producerRegion name that stack, rewrites the entry's VALUE to state.outputs[<entry name>] where that value carries {{resolve: and differs. patchEntry per 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 -- an Export.Name alias is a key of the outputs bag, and ExportIndexEntry carries no output-key field. --dry-run writes nothing and still READS, so --dry-run --fail exits 1 on an index divergence over records that already hold the expression. Reported, not written: a refused write or an unreadable index raises SCRUB_EXPORT_INDEX_INCOMPLETE (exit 2) naming the remainder, since a swallowed failure leaves the value in a region-wide object readable with s3:GetObject and no version id; an owned entry ABSENT from state.outputs is warned; an entry whose producer this run did not scrub is coverage and never reddens --fail. The resolver still gets no exportIndex, so no patchEntry fires as a resolution side effect. No new flag, no IAM change, no schema bump. A rewritten entry is SUPERSEDED, not erased -- docs/cli-scrub.md covers exports.json there 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 in positionByEmbeddedSpan), src/deployment/deploy-engine.ts, three unit test files, and tests/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 persisted port:q7 in properties, the readback and a CfnOutput; #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: a WeakSet mark on the record's properties, the drainObservedCaptures readback and the outputs bag, 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 to redactSecretsForState; 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; the Fn::Join / Fn::Sub source shape -- with the nested-stack and cdkd import twins, #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, wrapping resolveJoin's parts and resolveValue's array arm), new src/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.md and .claude/rules/layout-deployment-secrets.md. A secret dynamic reference records plaintext -> expression into context.recordedSecretValues just before its promise settles, and Promise.all surfaces a rejection AT ONCE, so a caller's catch / finally ran while a sibling was in flight. DeployEngine's Export.Name block copies its private nameSecrets map into the pass map in such a finally: a later recording reached nothing, leaving a plaintext the pass did not know as a needle. The drain keeps Promise.all's error, the EARLIEST REJECTION IN TIME, where scanning Promise.allSettled for 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: resolveOutputs runs after every resource exists, before saveState, 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 NOT unref'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-properties is 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), but CloudControlProvider.update diffs 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. properties now describes what the SDK route SENT. The engine strips such a key from an update's previous side and its no-op skip -- an sdk record cannot have had one written, which heals a poisoned record -- but NOT from the data-loss guards or the replacement delete(), which ask what the resource HOLDS. DiffCalculator narrows 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 sticky cc-api record, 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, parsing createOnlyProperties as /properties/X where 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 with LoggingConfiguration makes CreateStateMachine validate that the role can reach the log destination, and cdkd issues it ~1s after the role's DefaultPolicy CREATE — before IAM propagates the logs:CreateLogDelivery / PutResourcePolicy grants — 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 to IAM_PROPAGATION_ERROR_MESSAGE_PATTERNS so 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/26 sentinel). 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 its MERGE_PENDING row claimed. That row said the stale copy uniquely carried the --stack-concurrency race and the cdkd scrub detail; 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 and MERGE_PENDING is 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.ts no 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; no src/ change and no user-visible behavior change. Since #2738 settings-bash-first-optout.test.ts spells .claude/settings.local.json as a literal it hands to git 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 a NON_READ_LITERALS carve-out (an argument, never a read), qualified by a new OPTIONAL_ON_DISK set: the fence still demands that every other carve-out entry exists, and for an optional entry demands instead that the repo's top-level .gitignore covers it. gitIgnores reads check-ignore -v and accepts only a .gitignore:<line>: source. A global excludes file and .git/info/exclude are refused because either would make the verdict differ between a developer machine and CI, which is the defect itself; a nested .claude/.gitignore because it is outside the gate's check include, 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 throwaway git -c init.templateDir= init repo with .git/info/ created explicitly -- only the init template makes it, so a machine setting init.templateDir failed the write while CI stayed green (.claude/hooks/stop-warn.test.sh:60-63 records 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, new tests/unit/scripts/offline-property-evidence.test.ts; no src/ 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 of dist-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, .X accesses), so a name appearing only in a declaration array does not qualify. Exact AND lowerFirst spellings count, measured: @aws-sdk/client-api-gateway declares stageName and no StageName, 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.DefaultCooldown is 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-type is 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 in src/deployment/secret-redaction.ts, new tests/unit/deployment/intrinsic-sub-variables-not-mutated.test.ts. resolveSub wrote each resolved variable back into subArgs[1] -- the object inside the caller's template (an Output's Value['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 for resolveDynamicReferences), and the positioning source DeployEngine.resolveOutputs retains 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 after JSON.parse) still substitutes; a placeholder naming an Object.prototype member the map lacks (${constructor}) no longer renders that member's source on either form and falls through to Ref; a null / primitive second element is refused unconditionally and non-retryably (newly enforced -- before, a number, boolean or empty string failed only once a placeholder reached the in test), 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-enumerable length is 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, new tests/unit/scripts/backfill-umbrella-sync-workflow.test.ts; no src/ 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 at action_required, as GitHub does for a GITHUB_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 is countDecisions, which renderDiagnosis calls rather than restating: five terms, two rendering no heading. It is CLEARABLE -- Regenerate and Diagnose also 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 a push-to-main workflow, taking issues: write off the repo only unattended contents: write job; both spliceers cut on LINE NUMBERS, since sed -n 1,/BEGIN/p never 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), new tests/unit/deployment/intrinsic-resolver-condition-failure-masking.test.ts, .claude/rules/layout-deployment-secrets.md. That per-condition catch renders at warn -- no --verbose needed -- and condition evaluation re-enters resolveDynamicReferences with the ASSEMBLED string, so an entry whose Fn::Sub variable resolves a secret and uses it as a second reference's JSON KEY fails naming the password. Masking the line was not enough: maskSecretsForLog no-ops against absent bags, and 2 of the 4 callers (diff-recursive.ts, import.ts) pass context literals with neither -- a live cdkd diff against a real secret still printed it in full, which the unit suite could not see. evaluateConditions now 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 scrub hands this pass its OUTPUTS bag on purpose, so a condition's secret IS a needle over state.outputs there, 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 -- maskSecretsInText matches LITERALLY, so a re-encoded plaintext is masked by none of this.

  • cdkd deploy reports an Output resolution failure MASKED on both arms, cause included, and the resolver's own echoes are masked too (issue #2728) -- src/deployment/deploy-engine.ts (handleOutputResolutionFailure + its two resolveOutputs call sites), src/deployment/intrinsic-function-resolver.ts (the Resolving dynamic reference: debug lines, the throttle-retry labels, the SSM unrecognized-Type warn and the Unsupported dynamic reference service: warn, via maskSecretsForLog), tests/unit/deployment/deploy-engine-output-failure-masking.test.ts, a live arm on tests/integration/secrets-dynamic-ref/ (OutputFailureLeak, declared only under CDKD_TEST_OUTPUT_LEAK=true for one --verbose probe deploy; Guard 1b). The default arm warned Failed to resolve output <key>: <error> and --strict-getatt threw it, neither masked. The resolver's failures echo the reference token, secret id / JSON key or SSM parameter name, and resolveSub / resolveJoin re-enter resolveDynamicReferences with 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's cause is a maskSecretsInError clone (the non-retryable marker survives), and the default arm prints error.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, the Type warn 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, new tests/unit/scripts/settings-bash-first-optout.test.ts; no src/ change and no user-visible behavior change. Claude Code's CLAUDE_CODE_THRIFTY_SONIC appends a system-reminder telling the session to read and WRITE files through cat / 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 (matcher Edit|Write|NotebookEdit), the PostToolUse Write|Edit -> vp run lint:fix entry, and every paths:-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.json covers one machine. Probe: at "1" claude -p reports 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.json outranks the pin, making it a repo DEFAULT; it is now listed in .gitignore -- fenced via check-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 scrub resolves each intrinsic Export.Name through 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 SAME outputSecrets map the value loop fills. Since #2485 the resolver records the uncollapsed expression -> plaintext pair beside each entry and a second write with a different plaintext marks it conflicting; an ssm reference whose Type came 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 literal Output embedding 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 a Map subclass 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 because Fn::Join resolves 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's Map.prototype; the live arm (FunctionNameExport, an Fn::Sub name) 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, new tests/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_EXEMPT is 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 legacy getProvider() conservative with no special case each. --pin-cc-api <LogicalId> declines a flip; --recreate-via-cc-api sets the same suppression internally, or the exemption would silently reverse it. cdkd diff renders [returning to SDK provider], fixing a mislabel where an exempt type showed sticky while routing to SDK. No schema bump. AWS::SNS::Topic is 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}.sh and 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; no src/ change and no user-visible behavior change. src/provisioning/register-providers.ts -- the registration TABLE -- was in integ-broad, while provider-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: getProviderFor picks the DELETE provider at deploy-engine.ts:5809 (plain delete) and :4557 (a replacement's old delete), destroy-runner.ts:1342, and seven sites in rollback-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 for delete|rollback|ENI|detach|..., which a routing edit does not write -- five realistic mutations (a type added to STICKY_CC_MIGRATION_EXEMPT, a replaced sticky predicate, a flipped returned provisionedBy) matched it 0 times while a control naming deleteProvider matched, 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.ts is 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 new scripts/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; no src/ 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 -C resolution -- 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-gh client the hooks never could and re-run on synchronize where a hook fired once; two were deleted outright. gh-pr-edit-deprecation-gate went on a MEASUREMENT instead: gh pr edit --body exits 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) -- new scripts/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; no src/ change and no user-visible behavior change. Two residuals of #2717. (1) closes-paren-form-gate was 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 workflow if:, 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, the if: 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 from property-coverage.generated.ts, derived OFFLINE from tests/fixtures/cfn-schemas/*.json with no runtime DescribeType — so a property AWS published after the snapshot produced no silentDrop entry, 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, generatedAt excluded, 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 its handledProperties, and it fires only on the SDK route, mirroring getProviderFor. Warn, not an error or an auto-route (a typo must not trigger the one-way cc-api flip, 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; no src/ change and no user-visible behavior change. The population was git ls-files alone, 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-file vp test run, and git diff then printed Binary files ... differ instead of a diff. It now also lists git ls-files --others --exclude-standard -- git status --porcelain's ?? set, so .gitignore still excludes node_modules/ and dist/ -- 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, so untracked.length > N fails on a clean checkout or is vacuous at N = 0. The invariant is the RELATION, so the test SEEDS a NUL into an untracked .ts file in a SUBDIRECTORY -- pinning that the walk DESCENDS (#2696's own case was a file under tests/) and that no untracked-only extension exemption hides it -- and requires the sweep to name it; clean and gitignored twins cover the other directions. read stays 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 on EACCES rather than reporting a finding. Rationale and probe receipts in that test's doc comment. Five sibling git ls-files populations 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) -- new scripts/markdown-table.ts plus scripts/{audit-stateful-candidates,build-integ-coverage-matrix,build-scenario-coverage-matrix,gen-handled-property-wiring,gen-nested-key-coverage,gen-update-wrap-coverage}.ts and two new unit suites. Behavior change: none; regenerating every matrix left docs/ 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 future invoke|start-api rationale 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 renders sdkDetail, the prose its divergence table renders as detail) plus two in audit-stateful-candidates.ts that are structural in ROLE but arrive from a cloudformation:DescribeType response, third-party schemas included -- the escaping line is PROVENANCE, not role. Exactly ONE implementation existed, in build-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.ts could 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.ts parses 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 and join-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 RetentionInDays and 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 new loggroup-never-expire-guard arms, and unit suites. isStatefulRecreateTargetSync gated on typeof retention === 'number' && retention > 0 and was handed properties alone, so a CloudFormation-legal '30' and a retention set OUT OF BAND (console, put-retention-policy, or an import recording it only in observedProperties) both failed to answer has-retention. Not a data-loss hole since the never-expire fix (#2558): a null there 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: readCurrentState writes 0 for 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: LogsLogGroupProvider sent '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 issues DeleteRetentionPolicy -- 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_MATRIX in the coercion suite.
  • The descendArrays: false rationales 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, the ssm-secure-dynamic-ref fixture and three unit suites. Behavior change: ssmParameterName took inner.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's PARAMETER_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 a local sibling 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 scrub re-raises only the typed refusal, so it swallows this to debug and reports clean -- #2692, blocked on (#2562) by a scrub-side PREDICATE change. The rest is prose: FIVE comment sites said descendArrays: false refuses positional descent without saying BLIND, but since #2012 that constant selects refuseUncertifiedReadbackPositions, whose unkeyed arm pairs by index when unkeyedArrayPairsByAnchors corroborates. The fifth, in rollback-executor.ts, is fixed here too (#2691). Held-file siblings: (#2688), (#2693).
  • A new SDK provider can no longer silently drop update()'s UpdateContext parameter (issue #2613, remedy 1 of 3) -- new scripts/check-provider-update-context.ts and tests/unit/scripts/provider-update-context.test.ts; no src/ change and no behavior change. ResourceProvider.update's context parameter is optional, so a provider that omits it compiles, deploy-engine.ts / rollback-executor.ts / drift --revert keep passing expectedRegion (issue #2301 item 1), and assertRegionMatch never runs for it. Measured on 6ba3cf16: 85 provider classes across 82 files, 18 declaring and 67 omitting -- not the 17/60 the issue recorded, whose grep "async update(" collapsed glue-provider.ts's seven classes into one and could not see the two non-async update()s (ServiceDiscoveryProvider, which DOES declare the parameter, and EFSProvider, which does not). The checker parses each class with the TS compiler, derives the population from interface MEMBERSHIP (create + update + delete present) rather than the optional implements keyword, and requires the 6th parameter to be UpdateContext imported from types/resource.ts -- refusing an unmodelled shape instead of bucketing it. OMITS_UPDATE_CONTEXT names 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 against update.length on the objects registerAllProviders builds. 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.md and thirteen verify.sh teardowns. A sweep that lists under --log-group-name-prefix "${LG_PREFIX}" or starts_with(RoleName, '${STACK}') and deletes every name it gets back matches EVERYTHING when the variable is empty, and cleanup runs under set +eu. Behavior change (teardown only): twelve sweeps now refuse a scope outside the fixture's own and warn on stderr -- a wrapping case, exit 0 in a subshell-bodied helper, or an early return where the whole function is the sweep. Real scopes sweep as before; loggroup-class-guard already 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 literal STACK= 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 carrying teardown sweep refused for a future checker to key on. That checker is #2690; the namespace-anchored back-fill is #2682.
  • A Type change into or out of AWS::CloudFormation::Stack is refused at plan time, before anything is deleted (issue #2668, stopgap) -- new src/deployment/type-change-guard.ts, src/deployment/deploy-engine.ts, docs/cli-deploy-safety.md, plus a new unit suite. A Type change on an existing logical id is diffed as an UPDATE carrying the TEMPLATE's (NEW) type, and provisionResource routes 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.delete ignores the physical id it is handed and destroys <parent>~<logicalId>, so a plain cdkd 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::Stack is not in STATEFUL_TYPES, so nothing else stopped it. Behavior change: such a deploy now fails with TYPE_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-run refuses 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 own DeployEngine and never pass through deploy.ts, and the engine owns the very changes map the routing decision is made from. Deliberately NOT widened to every Type change -- that would refuse a deploy that is correct today, since UpdateReplacePolicy: Retain skips 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-provider targets 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 the recreate-nested-logical-id-collision integ fixture. The two bare ReadonlySet<string> options became one recreateTargets: { 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.runChildDeploy spreads the parent's options into the child engine, so the bare set matched the child's ids -- and recreateFlagged is exactly what SKIPS the mid-deploy stateful guard, whose justification is that the pre-flight already validated the target. A nested AWS::S3::Bucket or AWS::Logs::LogGroup could 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 OWN AWS::CloudFormation::Stack row is refused outright (blockedNestedStackTargets, no --force-stateful-recreation bypass) -- 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, eight docs/** pages, the shipped plugins/cdkd-skills skill, plus cases in four unit suites. cdkd scrub said "The plaintext is no longer stored there"; its only write is a plain PutObject on a versioned bucket, so the pre-scrub body survives as a noncurrent version readable with a VersionId, and nothing purges it. cdkd events prune and cdkd destroy --purge-events delete through DeleteObjects with no VersionId, 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 bootstrap skips PutBucketVersioning for a pre-existing bucket unless --force). AWS::EFS::FileSystem gained the destroy caveat its FSx neighbour already carried: cdkd's own PutBackupPolicy enables automatic backups, and the recovery points outlive DeleteFileSystem in aws/efs/automatic-backup-vault for the 35-day default retention, fully restorable. Live-proven against real S3 -- after events prune --all the listing was empty while three noncurrent versions stayed readable by VersionId. 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.ts plus src/provisioning/providers/{elbv2,emr-cluster,cognito,dynamodb-globaltable,asg,dlm-lifecycle-policy,scheduler-schedule,efs,fsx-filesystem,s3-vectors}-provider.ts and src/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, since cdkd deploy registers no --remove-protection and the engine never sets DeleteContext.removeProtection. Each now reads that bag and names the dead end plus a disable command whose id is SANITIZED, shell-quoted via lock-contention-message.ts's now-exported shellQuote, and SUPPRESSED when sanitizing changes it -- the id is a state.json value. ASG covers prevent-all-deletion only: prevent-force-deletion withholds a FORCE delete, which a replacement never issues. (2) cdkd deploy --replace <LogicalId> was pasted at two sites; --replace is boolean and deploy takes [stacks...], so the id parsed as a STACK NAME. (3) lock-manager.ts named a --force-unlock flag 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 --replace for a STATEFUL_TYPES member, refused again with STATEFUL_REPLACE_BLOCKED. Both polarities pinned per site; tests/unit/cli/replacement-remedy-cli-facts.test.ts fences the CLI facts, the doc pointer and the literal-only command contract. docs/cli-deploy-safety.md names 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 new tests/unit/provisioning/custom-resource-response-display-safe.test.ts. cleanupResponseObject's failure line interpolated s3://<bucket>/<responseKey> and error.message raw. The sibling sweep found the same shape at TWO more sites the issue did not name: generatePresignedResponseUrl's success line, and ensureResponseClient's region-correction line, which also renders a bucketRegion (from resolveBucketRegion) and a currentRegion (from the client's own config.region()). All three are fixed together -- sanitising some of three readers of the same value IS the widening-by-hand failure src/utils/display-safe.ts's header exists to stop. Buckets and regions take the asciiOnly positive allowlist, AWS text and the key the denylist form. Every value that CAN sanitize to nothing carries a || UNRENDERABLE floor, each probed by reverting it; responseKey deliberately has none, because getResponseKey always appends /cdkd-<digits>-<alnum>.json so 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-supplied responsePrefix, 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 are debug -- rendering hygiene, not disclosure.

Recently Implemented (2026-09-05):

  • cdkd events now renders stored provider text through displaySafe, so a control byte in a name or a reason cannot forge a post-mortem line (issue #2438) -- src/cli/commands/events.ts, docs/{deployment-events,cli-events}.md, plus the new tests/unit/cli/events-display-safe.test.ts. JSON.stringify escapes control bytes INTO the JSONL store, but the reader is a plain JSON.parse that restores them and the renderer printed them verbatim -- so an ESC-bracket sequence in a logical id, a resource type, a provider reason (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 raw ESC[2K + 2 ESC[1;31m + a CR reached the terminal, the CR overwriting the reason line with a forged RUN_FINISHED SUCCEEDED; post-fix zero. 44 call sites now wrap every stored value on the HUMAN path -- 23 printRunEvents, 9 printRunList, 1 colorizeEventType, 6 in the EVENTS_* errors, 4 in events prune incl. its confirmation PROMPT, 1 in the --keep refusal. colorizeEventType and the result column 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 bare new Error() renders Error, not Error: <unrenderable>. --json is deliberately untouched: machine-consumed, and JSON.stringify escapes 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: Retain on 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 only rollbackFinalSnapshotId, 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: Retain alone leaves the new copy DELETED, UpdateReplacePolicy: Retain alone leaves it SURVIVING (DELETE_SKIPPED). The table is on rollbackRetainsNewResource's doc comment. Behavior change: a retained new copy is left running, dropped from state (CloudFormation orphans it out too), announced with a warn, recorded durably in the deployment events with its physical id and the survivor's own layer, and counted, so the run exits 2. On reverse-replacement-readopt that 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. deleteNewFirst is the one arm that cannot honour it -- that delete exists to free the name the re-create collided on -- and REFUSES with NAMED_REPLACEMENT_COLLISION instead of destroying the pinned resource, keeping the journal for a re-run. RollbackPlanItem.retainsNewResource keeps 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 !retainOldOnReplace arm 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 --replace delete-first fallback's sentence, masked at construction, and CHAINS the create rejection so the RESOURCE_FAILED event still names the AWS code, which an unchained wrap would drop; two sibling wraps gained the same cause. #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. A captureProvider binding 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: classifyRollbackOp re-derived retention from previousState.updateReplacePolicy -- a different source than the TEMPLATE read every engine path decides from -- so ADDING UpdateReplacePolicy: Retain made a rollback re-create a live resource, and DROPPING it left state naming a deleted one with no re-create. CompletedOperation.oldResourceRetained (additive, no journalVersion bump) 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 -- the isStatefulRecreateTargetForReplace bucket 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 of result.warnings -- that count maps to exit code 2, and the op succeeded.
  • A failing docker call no longer echoes the unredacted argv, and the --verbose argv 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. execFile folds the WHOLE command line into err.message, so the err.stderr?.trim() || err.message fallback -- taken when docker writes no stderr -- echoed every -e KEY=value pair, while the debug line eleven lines up redacted it. The issue's suggested remedy (wrapping redactAwsCredentialsInArgs) would have been INERT: it masks only the three AWS credential keys, which partitionSensitiveEnv already keeps off the argv. What leaks is user config: Environment.Variables, --env-vars, DriverOpts. redactDockerArgvInText masks the VALUE of -e / --env / --opt / --label and keeps the KEY, in four structural (positional, never value-based) passes: exact substitution of args.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 uses util.inspect -- the function Node itself quotes with -- after a hand-rolled \xNN table proved wrong for 16 of the first 128 code points. An EMPTY key (-e =value) is masked too. The --verbose argv line uses the same redactor now, and redactAwsCredentialsInArgs is deleted as dominated. Delta: a --verbose docker run line prints -e KEY=*** per env pair. All 20 failure texts in the four modules are COMPOSED by describeDockerFailure and two siblings, whose REQUIRED args makes redaction type-checked, not fenced -- four review rounds each broke a text fence, the last because err.cmd leaks 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): when ListObjectVersions throws, the target keeps statefulReason: null and the recreate proceeds. That posture is unchanged -- a role without s3:ListBucketVersions must still be able to recreate an empty bucket -- but the SCREEN was wrong: with the verdict left at null, 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 sibling probeUnresolved flag (a field, deliberately NOT a StatefulReason value, which would have made it REFUSE and flipped the documented posture), and the plan prints a row saying the emptiness was not established plus an UNKNOWN: 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 typed NoSuchBucket / NotFound check -- 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 honoured Retain — and rollback-executor.ts already ASSUMED retention, re-adopting a deleted id. Under Retain the 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 own NAMED_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 reports partial, so a top-level run counts an orphaned predecessor and exits 2 (--allow-unaddressed opts 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 its includes('UnsupportedActionException') half was UNREACHABLE (handleError interpolates err.message only). isUpdateUnsupportedError now reads the exception NAME structurally, anchored on logicalId so 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 local defects 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-role was named in every --assume-role help string and rejected by the parser (commander synthesizes no negation for an optional-value option), so the assumeRole === false arms were dead; it is now registered on the four commands that advertise it -- invoke, invoke-agentcore, start-agentcore, start-cloudfront -- and NOT on start-api / start-service / start-alb, which never advertised it and whose downstream code was written for a different shape. local start-cloudfront now 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 four local start-* shims adopt the shared deprecatedRegionOption and fold --region / --stack-region / AWS_REGION / AWS_DEFAULT_REGION in a preAction hook -- user-visible delta: --region disappears from those four --help outputs and prints the removal warning; it keeps working. Two stale strings corrected: --stage no longer claims HTTP API v2 requestContext.stage is 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.ts deleted -- 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, isThrottlingError as 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 carrying IsTruncated / NextKeyMarker / NextVersionIdMarker with 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 with DeleteMarkers absent entirely, and a zero-result listing answered with BOTH arrays absent, IsTruncated: false and 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-recreation at pre-flight.
  • A log group with no retention policy is no longer read as ephemeral: an unset or zero RetentionInDays is 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 new tests/integration/loggroup-never-expire-guard/**, plus docs and unit suites. The predicate was RetentionInDays > 0 on the recorded bag, so the value LogsLogGroupProvider records for "never expire" (0) read as "not stateful": a renamed log group took the property-driven replacement path on a plain cdkd deploy, destroying every event with no flag, prompt or warning. The type now follows the AWS::S3::Bucket shape it sits beside -- a recorded RetentionInDays > 0 still answers has-retention, anything else DEFERS: the --recreate-via-* pre-flight resolves it with a live logs:DescribeLogStreams(limit=1), and the mid-deploy sites, having no probe, resolve it to the new has-log-events reason and refuse without --force-stateful-recreation. Only ONE probe answer clears the guard -- a present, empty logStreams with no continuation token; the two non-answers warn-and-refuse like a failed probe. The LogGroupClass remedy 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 a null reason is re-derived conservatively and every bucket and log group shows DATA LOSS. Why not storedBytes, and why this probe alone fails CLOSED: see probeStatefulRecreateTargetsAsync. Behavior change: replacing a log group with no recorded retention now demands --force-stateful-recreation mid-deploy, and at pre-flight unless it is provably empty.
  • The LogGroupClass refusal 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-recreation UNCONDITIONALLY. With DeletionProtectionEnabled set that dead-ends: the replacement's DELETE never sets DeleteContext.removeProtection (no cdkd deploy --remove-protection exists), so AWS refuses the DeleteLogGroup and 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-band aws logs put-log-group-deletion-protection. It stops there and hands off to docs/cli-deploy-safety.md for what happens to the flag afterwards -- three review rounds each proved a different sentence about that FALSE, since update() sees neither UpdateReplacePolicy nor what the replacement does (under Retain a 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 four update() callers pass a different bag); unprotected wording and the accepted residuals are in applyUpdate'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 LogGroupClass refusal'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 a LogGroupClass change, 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 of state.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 refuses DeleteLogGroup outright 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 the EXIT/INT/TERM trap, tri-state on not-found, ahead of state destroy so it never depends on the flip-off it is testing -- and needs no state record. /cleanup learned 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 specific VersionIds. 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 and GetObject with a VersionId still 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 one GetBucketReplication and warns through the CALLER's sink, naming the destination, the mechanism and the remedy. A bare delete MARKER is neither, which matters because cdkd deploy writes one for the rollback journal on every success. ReplicationConfigurationNotFoundError is S3's ORDINARY answer for an unreplicated bucket (verified live, 404) and is silent; so is AccessDenied, and s3:GetReplicationConfiguration joins the recommended policy as its one DIAGNOSTIC entry. Coverage errs toward warning: a tag-filtered rule covers everything, a Disabled rule 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. Six KNOWN_SCENARIOS descriptions 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 new escapeCell covers 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.ts loses 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 a walk that stopped recursing, a count it had already drifted past: every immediate subdirectory of docs/ 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.json is unaffected: JSON has no cell delimiter. Behavior change: none (docs + tooling only).
  • cdkd deploy --verbose no longer prints a Docker image asset's --build-arg VALUES, and the argv fence can now see src/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 the cdkd local docker paths; its fence derived its module list from readdirSync('src/local'), so the deploy-time asset build was structurally invisible to it. Nine unredacted sites in src/assets/** are now zero: the --verbose build line, the executable-mode command line and its "produced no output" throw, three hand-composed docker-failure texts in the publisher, and -- widest -- a JSON.stringify(source) that dumped every dockerBuildArgs value AND every dockerBuildSecrets entry into a THROWN error, now reduced to the field KEYS. --build-arg joins the shared ARGV_VALUE_BEARING_FLAGS (the same argv builder serves the ECR publish and cdkd local run-task), so the KEY survives and the value prints *** -- in all four argv spellings pflag accepts, since the executable mode renders a USER-authored command line. --secret / --build-context stay unmasked (locators), while --cache-from / --cache-to are not: BuildKit's cache backends take credentials inline, so their params are filtered through an ALLOWLIST of locator names. ARGV_VALUE_TOKEN_RE is now BUILT from the Set, so the argv pass and the text pass cannot diverge. The fence sweeps src/local + src/assets, selects on "spawns docker through a shared helper", and refuses any docker-spawning module outside those roots. Behavior change: --verbose prints --build-arg KEY=***. Design: .claude/rules/docker-argv-redaction.md.
  • cdkd local start-alb --from-state now 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's resolveAlbLambdaTargetEnv rebuilds the options bag as a six-key allow-list dropping fromState / stateBucket / statePrefix -- so the dispatcher's options[key] gate never selects cdkd's factory and the Lambda boots with its intrinsics dropped. Not REFUSED the way #2528 refuses start-cloudfront: that command has no working consumer, this one has half. warnUnresolvedLambdaTargetEnv decorates the ALB EmulatorStrategy, 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 are layout-cli.md's region-options.ts paragraph (it omitted adoptDeprecatedRegionFlag) plus layout-local.md's false same-region-only ECR-pull parenthetical and its "ONLY cdkd-owned stop" claim. Nothing checked those, so tests/unit/scripts/rule-file-module-citations.test.ts fences the checkable half: every .ts a rules file cites -- bare, path-form or markdown link -- must exist. It immediately found a fourth: analyzer.md sent future sessions to src/analyzer/intrinsic-resolver.ts, which has never existed. Behavior change: one boot warning, only on start-alb --from-state against an ALB with a Lambda target group.

Recently Implemented (2026-09-04):

  • A transient DescribeType failure 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 default onError classified ANY failed lookup as Tier 3, and Tier 3 is not a neutral bucket: gen:unsupported-types codegens it into src/provisioning/unsupported-types.generated.ts, where the pre-flight REFUSES the type. Measured on the 2026-09-04 refresh: one socket hang up during a 1737-type walk classified AWS::OpenSearchService::Domain as NON_PROVISIONABLE, and shipping that report would have made cdkd refuse to deploy an OpenSearch domain. The retry could not see it -- it keyed on ThrottlingException, while a socket hang-up arrives as a plain Error whose NAME is Error. Two fixes, since either alone leaves the class open: isRetryableDescribeTypeError retries transient TRANSPORT failures by name, by code, and by the errno spellings Node puts in the message when code is absent; and an unresolved type is recorded in a new undetermined list instead of guessed into a tier, with --regenerate refusing to write a report that has any and --check refusing a committed one. No escape flag: --check runs on every PR, so a report written past the refusal would leave main permanently red. runCheck also compares the committed provider-coverage.md against what renderMarkdown produces 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 withdrawn cause-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, the AWS::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 neither STATEFUL_TYPES nor NOT_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::ClusterSnapshot and AWS::DynamoDB::Backup on the argument the already-guarded AWS::NeptuneGraph::GraphSnapshot rests on -- a snapshot IS the data, so replacing one destroys the copy the user kept; AWS::FSx::Volume as AWS::FSx::FileSystem one level down; AWS::CloudHSM::Cluster as AWS::KMS::Key's class, where the loss reaches ciphertext in other stacks; and AWS::IoTSiteWise::Workspace on 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 no NOT_GUARDED entry 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::LogStream was landing in tier 2 twice (issue #2571) -- scripts/audit-provider-coverage.ts, tests/unit/scripts/audit-provider-coverage.test.ts. partitionCoverage walked allTypes straight into the tier arrays, and allTypes comes from paginateListTypes, which yields whatever ListTypes returns -- AWS returned one type twice across pages. The committed cache therefore held 1372 tier-2 entries against 1371 distinct, with summary.tier2Count publishing the inflated figure on the coverage page. The duplicate also cost a wasted DescribeType and reached every downstream consumer twice, which is why scripts/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 second DescribeType, since an output-time dedupe would fix the counts while still paying for the call. The committed docs/_generated/provider-coverage.json is regenerated in the same change, so it is a set again rather than carrying the duplicate until someone next runs the audit.
  • cdkd force-unlock now 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-region catch logged Failed to unlock stack ... at ERROR level and continued with nothing recording that a failure had happened, so the process still exited 0: a scripted cdkd force-unlock && cdkd deploy proceeded 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 as FORCE_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 a set +eu cleanup, so the new non-zero exit does not abort that teardown.
  • cdkd migrate is REMOVED -- it was the only command that required the upstream AWS CDK CLI binary (issue #2572) -- deletes src/cli/commands/migrate-command.ts, src/cli/commands/migrate/**, tests/unit/cli/commands/migrate/** and the migrate-from-bare-cfn / migrate-from-bare-cfn-codegen-only integ fixtures; edits src/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.md and the regenerated coverage matrices. Every other command runs the user's own app command as a subprocess and never touches the cdk binary; 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-bin relocated the binary without removing the requirement. Replacement: cdkd import --migrate-from-cloudformation is unchanged and still adopts a CloudFormation stack when a CDK app already synthesizes it; for a plain non-CDK stack run upstream cdk migrate --from-stack yourself first. cdkd state migrate and the local-* commands' own --from-cfn-stack are unrelated and untouched. Dead exports go with it -- runImport / RunImportOptions, MissingCdkCliError, LocalMigrateError -- dropping the guarded-prompt population from five to four and making retire-cfn-stack.ts's non-interactive refusal name ONE command instead of two. The explicitBin floors in tests/unit/scripts/integ-cdk-cli-pins.test.ts had that fixture as their only source and become an asserted zero that reds if a fixture reintroduces the shape.
  • cdkd destroy / cdkd state destroy now 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 is if (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 asked legacyMatchesRegion, i.e. readLegacyRegion(...) === region, which answered undefined === 'us-east-1' -- false -- so {prefix}/{stack}/state.json survived a successful-looking destroy. The stack kept appearing in cdkd state list, and the next cdkd deploy of that name read a record asserting resources that no longer existed and planned UPDATEs against their physical ids. The one-line fix -- accept undefined -- would have been wrong, which is why the repair is a type: readLegacyRegion collapsed THREE answers into undefined (the key is absent, the body names no region, the body could not be read), and stateExists reads the same helper, so accepting undefined there would have reported state for a stack that has none and fed reconcileRegionWithLegacyDefault's region choice. probeLegacyState now returns absent / no-region / region / unreadable, and each consumer answers for itself: the delete sweep accepts no-region (mirroring the read gate it must agree with) and refuses unreadable -- a denied or throttled read says nothing about who owns a record and must never authorise a delete -- while stateExists says yes to no-region from any region, matching what getState would return, and no to the other two. listStacks keeps its behaviour through a thin readLegacyRegion wrapper. Proved by a live A/B on real S3 with a hand-seeded version: 1 record carrying no region and zero resources: the pre-fix binary prints ✓ State deleted and leaves the object, the fixed one deletes it, and cdkd state list no longer shows the stack. Both polarities of the wrong fix are pinned -- reverting no-region to the old answer reds 2 cases, and accepting every undefined reds 4.
  • An empty --stack-region is rejected at parse time instead of being read as absent (issue #2556) -- src/cli/options.ts (new parseStackRegion + guardStackRegionOptions), src/cli/program.ts, the eleven --stack-region declarations across src/cli/commands/{state,drift,rollback,force-unlock,events,orphan,export,local-invoke,local-invoke-agentcore,local-run-task,local-start-api}.ts, and tests/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 destroy destroyed in a region the user had not named; cdkd force-unlock released every region's lock; refresh-observed --all rewrote 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 commander argParser refuses it before any command sees it, and a future --stack-region consumer 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 with options.stackRegion ?? options.region ?? AWS_REGION, where '' is not nullish and therefore WINS over both fallbacks. buildProgram sweeps 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 five state subcommands, 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's InvalidArgumentError, so the user gets a one-line ... is invalid and exit 1 rather than a Fatal 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_TYPES had two mechanical lower bounds, both derived from src/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 plain cdkd deploy, with no flag and no prompt. The new audit reads every tier-2 type's registry schema through the same cloudformation:DescribeType create-only-properties.ts uses 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 because AWS::Backup::BackupVault describes its recovery points nowhere in its schema. 93 proposals: 63 joined the guard list, 27 are written off in the script's NOT_GUARDED map with the reason, 3 were already guarded. The fence fails on a proposal in neither place and on a NOT_GUARDED entry 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 like audit:coverage:regenerate; vp run audit:stateful-candidates:check is 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 orphan now 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. listStacks yields a region-less StackStateRef for a legacy {prefix}/{stack}/state.json whose body carries no region field. The orphan removal path branched on that ref and, for the region-less arm, called forceReleaseLock(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 in cdkd state list, and a later deploy of the same name read a record the user believed was gone. deleteState could not be reused -- it takes a region, keys its primary delete off it, and sweeps the legacy key only when that key's own region field MATCHES, so a body naming no region falls through every branch there. The backend gains deleteLegacyState(stackName), unconditional -- justified by its one caller's intent (state orphan with no --stack-region means every record for the name) rather than by any property of the ref, since readLegacyRegion also yields undefined on 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 reaches listStacks as a region-carrying ref and must keep taking the existing deleteState path, whose legacy sweep stays region-conditional. docs/cli-state.md's --stack-region row for orphan drops 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 as Stack 'X' ((legacy)) is locked because the template already parenthesises the placeholder, now a bare legacy; and the destroy path carries the SAME defect on a worse consequence -- getState accepts a region-less body from any region while deleteState'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-state readLegacyRegion (its undefined today means absent OR region-less OR unreadable) and touches the real destroy path.
  • cdkd state migrate --dry-run no 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. The if (!options.yes) confirmation block sat ABOVE the if (options.dryRun) early return, so a preview asked the user to confirm a copy it would never perform -- and on a non-TTY confirmOrRefuse threw NON_INTERACTIVE_CONFIRM and the command exited 1, making --dry-run alone 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, matching state refresh-observed, whose prompt condition has always been !options.yes && !options.dryRun. The existing dry-run case passed --yes and therefore never reached the prompt, which is how the ordering survived; the new cases drive --dry-run WITHOUT --yes on 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-run still prompts and still cancels on 'n'. docs/cli-state.md drops the paragraph documenting the prompt as current behavior, and docs/cli-destroy.md's "on a non-TTY each refuses" line gains the --dry-run exception it now has two rows for. Review round: the bail also prints what --remove-legacy would 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.md entries are capped at 2000 characters, with the overflow rationale moving to docs/design/<issue>-<slug>.md or 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 in tests/unit/deployment/dynamic-references.test.ts, and a live arm on tests/integration/secrets-dynamic-ref/ (DB_DSN_LITERAL). RecordedSecretValues is 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 the ssm-secure-dynamic-ref fixture's first run (go-to-k/cdkd#2492), the embedded leaf persisted ...NAME:1}}@... for a template that spells NAME, and the next deploy diffed that leaf forever (a REPLACEMENT every run for a create-only property); a rollback or drift --revert then re-resolved the version-pinned expression to a stale value after rotation. The resolver now records, beside its secrets.set(plaintext, expression), the uncollapsed expression -> plaintext pair into a side table keyed by the MAP INSTANCE (recordResolvedPair), and redactByPath gained positionByEmbeddedSpan: 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 as prefix + 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, a new 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 public ssm token is never recorded (the seam records secret verdicts only), a mask-only NoEcho value is never recorded, a middle that is itself a complete token is refused as an already-redacted record, two-span / Fn::Sub / Fn::Join sources 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. The Export.Name resolution 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 builds DB_DSN_LITERAL with 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 the Fn::Join every other env var of that fixture is, places it ABOVE SECRET_PASSWORD_STAGED so the collapsed survivor is the staged spelling, and asserts exact equality of the persisted leaf with its own expression on top of the existing diff --fail guard. A second live arm on tests/integration/ssm-secure-dynamic-ref/ (SSM_SECURE_VERSIONED_SAME, the :1 selector 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 Control UnsupportedActionException / "does not support UPDATE" rejection (auto-fallback, no flag needed to reach it) and an SDK provider's typed ResourceUpdateNotSupportedError (reached only with --replace). isStatefulRecreateTargetForReplace was consulted inside if (replaceOptIn) only, so a stateful type on the Cloud Control trigger was destroyed and recreated on a plain cdkd 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 (provisionedBy is 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 declares UpdateReplacePolicy: Retain, because the remedy they offer deletes the resource anyway; the policy is read TEMPLATE-only, so a Retain the current template has dropped produces no note. Both refusals -- this one and its property-driven twin -- are markNonRetryable; only this one carries a cause (the update rejection that routed it here), since the property-driven guard fires from the diff with no provider call behind it. UpdateReplacePolicy: Retain is 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 with STATEFUL_REPLACE_BLOCKED unless --force-stateful-recreation is 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-created AWS::DocDB::DBCluster whose 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 from unsupported-types.generated.ts and its provider declares no disableCcApiFallback -- and Cloud Control reports the type NON_PROVISIONABLE, so it ships no UPDATE handler. Probing it on 2026-09-04 (aws cloudcontrol update-resource --type-name AWS::DocDB::DBCluster) answered UnsupportedActionException: Resource type AWS::DocDB::DBCluster does not support UPDATE action -- the exact substring the fallback matches on, so a plain cdkd deploy took 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, and delete-resource for the type answers does 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-empty silentDrop map, when the template declares one of those properties), only through a sticky provisionedBy: cc-api record or --recreate-via-cc-api (their map is empty), and never (AWS::Elasticsearch::Domain, pre-flight-rejected as NON_PROVISIONABLE). Neither the per-type split nor its sizes are recorded here: both are re-derivable from PROPERTY_COVERAGE_BY_TYPE and docs/_generated/provider-coverage.json at 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::Table was named as reachable, but its silent-drop map is empty, so findActionableSilentDrops returns [] and ProviderRegistry.getProviderFor hands it to the SDK provider -- the type's own provider says exactly that in glue-provider.ts. AWS::FSx::FileSystem was eliminated "because its provider sets disableCcApiFallback", but that check is only reached when actionableDrops.length > 0 and FSx's map is empty too, so the stated reason never runs; FSx sits on the same footing as Glue::Table. Under the sticky route neither is eliminated at all -- the provisionedBy: cc-api rule returns Cloud Control BEFORE any disableCcApiFallback check. 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::DBCluster answering UnsupportedActionException. An earlier draft's aggregate Cloud Control mutability figures are withdrawn, and are not restated here: no repo artifact backs them (provider-coverage.json merges FULLY_MUTABLE and IMMUTABLE into 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_TYPES and final-snapshot.ts's snapshot-capable union were compared in both directions, and the union is now discovered from that module's *_SNAPSHOT_TYPES exports 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 a cdkd destroy while 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 declares SnapshotArns. Each is added with reason always: none has a cheap emptiness signal the way S3 and Logs::LogGroup do, 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 rejects DeletionPolicy: Snapshot on 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 whose delete() consults DeleteContext.forceDataDelete must 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::DirectoryBucket was the one type on the wrong side -- S3DirectoryBucketProvider.delete implements the issue #1344 sibling of the S3 bucket's #1340 data guard, so with CDK's autoDeleteObjects tag 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 is always rather than conditional like AWS::S3::Bucket, on the narrow ground of the probe cdkd HAS: the pre-flight issues ListObjectVersions, a general-purpose-bucket API, so no CURRENT probe can report a directory bucket empty. A probe is not impossible in principle -- S3DirectoryBucketProvider.emptyBucket enumerates the same buckets with ListObjectsV2 -- 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 from register-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::TableBucket and AWS::S3Vectors::VectorBucket, whose deletes call emptyTableBucket / emptyVectorBucket unconditionally (the former walks every namespace and DeleteTables every table in it); AWS::S3Tables::Table, which holds the rows themselves rather than the catalog entry AWS::Glue::Table already covers; AWS::KMS::Key, whose ScheduleKeyDeletion takes every ciphertext under the key once the window elapses -- including data in other stacks that merely reference it -- and AWS::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::Alias stays 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; and AWS::EMR::Cluster, whose TerminateJobFlows destroys the HDFS volumes on the core nodes -- reachable on a PLAIN deploy, since the type has no ReplacementRulesRegistry entry and its registry-schema createOnly set (Name, ReleaseLabel, ...) drives a replacement through create-only-properties.ts. AWS::S3Tables::Namespace is guarded on the fail-safe side of an OPEN question rather than on a proof. cdkd's own delete for a namespace issues a bare DeleteNamespace and enumerates no tables -- but whether AWS's API cascades server-side is unmeasured; an earlier draft inferred that it does not from emptyTableBucket deleting 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 plain cdkd deploy reaches 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 in docs/cli-deploy-safety.md against STATEFUL_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-recreation where it previously went through -- for AWS::CodeCommit::Repository and AWS::KMS::Key only through --recreate-via-*, --replace or a sticky provisionedBy: cc-api record, since replacement-rules.ts classifies 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, the Retain note 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's Retain divergence as #2518, the prose-matching trigger predicate as #2520, the guard's LogGroup input bags as #2521, and the unmeasured AWS::S3Tables::Namespace cascade as #2539 -- which now decides whether that entry can be RELAXED rather than whether it should exist. Issue #2540, which filed AWS::KMS::Key and AWS::CodeCommit::Repository as 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 new tests/integration/ssm-secure-dynamic-ref/ fixture. resolveDynamicReferences had arms for secretsmanager and ssm only; ssm-secure -- the spelling CloudFormation defines for a SecureString parameter, and what CDK's SecretValue.ssmSecure synthesizes -- fell into the unsupported-service else, 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: isKnownSecret treats it as secret by spelling (so the comparison-path resolver skips it with NO GetParameter, exactly like secretsmanager), a new arm calls resolveSSMReference(parts, true) -- the name:version selector reaches GetParameter.Name as-is, which SSM accepts -- pins the verdict true unconditionally, and records the plaintext -> expression pair, so state persists the expression, logs / events / errors are masked and a rollback replay re-resolves it through the existing GHSA machinery unchanged. A parameter GetParameter reports as a definitive String / StringList is REFUSED (IntrinsicResolutionRefusalError, markNonRetryable, naming the type and never the value) rather than resolved as public config under a secret spelling; an unclassifiable Type resolves as a secret. secret-region-classification.ts adds ssm-secure to REPLAY_SECRET_SERVICES so the region question (ambiguous / named-region / local) is asked of it too, and ssmParameterName strips the actual service prefix -- the fixed 'ssm:'.length would have turned ssm-secure:/pw into secure:/pw. The drift side loses its ssm-secure-specific arms: a {{resolve:...}} token that survives the pass is no longer any CloudFormation service, so isSecretBySpelling and the two guards it fed (marking a survivor's path secret-bearing, and preserveLiveValuesAtUnresolvedTokens registering 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. The unresolvedToken cause, 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 the ssm-secure cases flip to the resolved path (a CFn-migrated record compares CLEAN, a rotated value is masked by the recorded pair, --revert writes the decrypted value and persists the expression, the region-less form is refused under a foreign producer region and resolved locally without one, cdkd scrub records the needle through a scrubbed producer). Docs: docs/cli-drift.md's causes table, Unresolvable bullet and known-limitation paragraph, docs/testing.md, the drift layout rule, and the secrets-dynamic-ref fixture's explicit skip, which now points at the new fixture. Residual the fix does not repair by itself, stated in docs/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 reads NO_CHANGE and the upgrade does not rewrite the live value; for a property the provider reads back, cdkd drift now reports it and --revert writes 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, that state.json holds the expressions and never the plaintext, that diff --fail, scrub --dry-run and drift --json are 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::Secret no 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 local GenerateSecretString generator on EVERY call and re-sent an unchanged literal SecretString, and UpdateSecret with a SecretString creates a new version and moves AWSCURRENT to 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-default GenerateSecretString: {} 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 advanced AWSPREVIOUS off the real previous value. The value now rides the update only when its source changed: a generated value is minted only when the GenerateSecretString block differs from previousProperties (isDeepStrictEqual over 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 explicit undefined member), 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 -- on create() as well, and on update() 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. UpdateSecret has merge semantics, so an omitted SecretString leaves the current version untouched. The block is compared as STATE SPELLS IT. A {{resolve:...}} inside SecretStringTemplate reaches 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 same redactSecretsForState the state writer uses, with the pairs from the per-resource secrets scope the deploy engine and the rollback executor bind around the call (absent on drift --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 --revert was 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 same AWSCURRENT VersionId and version count on both a generated and a literal secret, and a positive-control phase that changes the GenerateSecretString block and the literal proves both values DO move (new AWSCURRENT, previous current now AWSPREVIOUS), 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/ (every src/provisioning/providers/* and src/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 158 new XClient(...) sites across 79 files spread awsClientDefaults() FIRST, so site-specific region / profile / credentials still win. The commonest provider idiom is wrapped, not rewritten ({ ...awsClientDefaults(), ...(this.providerRegion ? { region: this.providerRegion } : {}) }), and every site whose scope carries a profile passes it to the helper. Review found the sweep's own oracle had a quarter-population blind spot: the AST checker read only top-level ImportDeclarations, so 18 destructured await import('@aws-sdk/client-*') sites (17 unmigrated — deploy.ts's GetCallerIdentity on every deploy among them) plus 6 new 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, .then chains, 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 the yaml package (issue #2421) -- src/utils/yaml.ts, src/cli/commands/{synth,list}.ts, docs/cli-reference.md, plus tests/unit/utils/yaml.test.ts (rewritten around a round-trip table) and new / re-baselined cases in tests/unit/cli/{synth-stdout-stream,list,list-json-stream}.test.ts. toYaml decided 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 on tests/integration/basic, the repo's MINIMAL fixture (one S3 bucket, no IAM policy involved), node dist/cli.js synth | yaml.parse failed with YAMLParseError: 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 why cdkd synth | yq did 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"), the yaml package was already a runtime dependency, and the AWS CDK CLI does this exact job with yaml.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: false is passed for the reason src/cli/yaml-cfn.ts already 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 adds yes / no / on / off and timestamps, 1.2 core adds 0o octal -- 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 on tests/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's MaxAge: 3600 came back a string while its IAM Version: '2012-10-17' came back a Date -- and the visible consequence for cdkd list --long is that an account id is now account: "123456789012", agreeing with what --json always returned. The leading-newline question the issue carried as its third item is decided here too, INSIDE the renderer rather than per consumer: toYaml returns a document starting at column 0, so list.ts's .replace(/^\n/, '') is gone and synth no 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 added 0o octal 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 with Merge 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: toYaml builds a Document, and for every string scalar asks the library whether the plain rendering survives a parse under BOTH readers (yaml-1.1 and core), forcing QUOTE_DOUBLE where 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 returned false for the key and the visitor correctly set QUOTE_DOUBLE -- and the assignment did nothing, because under the yaml-1.1 emit schema the library's MERGE tag owns << and its stringify returns 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 under core (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 (under core the library emits yes bare 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: under core a date-shaped map KEY emits bare, so probing it under the 1.1 reader resolves a Date and yaml calls process.emitWarning -- 254 bytes on real stderr, during a GREEN unit run (against this repo's "a green run must print nothing" rule) and from cdkd synth for any template carrying such a key. The probe reads are now logLevel: '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's Date output 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 whose stringify discards a forced style -- found none under core, and exactly << under 1.1. The per-branch probe table is recorded in the source rather than left implied: the KEY oracle's catch is load-bearing (it is the path << takes; flipping it reds), the VALUE oracle's identical-looking catch is defensive and unreachable (0 hits over 461 strings, and 0 again over 91,033 after logLevel: 'silent' narrowed what can reach it), and the key === '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 over docs/ — hand-authored navigation groups so the site's IA is independent of the flat file layout; generateOgImage + docs-site/og-template.ts renders a 1200×630 OG image per page; siteMaps emits sitemap.xml / robots.txt / llms.txt; markdownSource publishes 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|preview tasks, eleven new user-facing pages under docs/ (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, and unlisted: true on internal ones (design docs, plans, this changelog). The client build gets an empty docs-site/noop-entry.ts entry because Ox Content emits every page in closeBundle and Vite refuses to build the client environment without an entry module.
  • cdkd events prune refuses 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 no NON_INTERACTIVE_CONFIRM code -- 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 why events.ts still carried its own byte-identical copy of the prompt helper: the tenth, and the last. It now delegates to confirmOrRefuse like 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-interactive cdkd events prune without --yes used 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 response NoEcho: true (issue #2274) -- src/deployment/secret-redaction.ts (the MASK-ONLY needle class: recordMaskOnlyValue / recordMaskOnlyValuesIn / carriesSecretMask / wholeStringLeavesOf / substringNeedlesOf / recordedExpressionsOf, plus the in-run recovery store recordRecoverableMaskedOutput / recoverMaskedOutput / clearRecoverableMaskedOutputs), src/types/resource.ts (new NoEchoAttributesResult, mixed into both provider result types), src/provisioning/providers/custom-resource-provider.ts (carry NoEcho across the simple-handler envelope synthesis; relay it on create / update), src/deployment/deploy-engine.ts (noEchoAttributeResources, registerNoEchoAttributes, refuseRedactedAttributeReads), src/deployment/intrinsic-function-resolver.ts (noteAttributeSecrecy on both attribute reads, the cross-stack twin inside reresolveCrossStackValue, and the two new ResolverContext fields), src/cli/commands/drift.ts (a masked BASELINE is secret-bearing; preserveLiveValuesAtMaskedLeaves plus the --revert refusal), src/deployment/rollback-executor.ts (refuseMaskedReplayBaseline on the three written-side replay arms), src/cli/commands/export.ts (a record whose properties hold the mask joins the per-resource blocked list), 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, plus tests/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 in tests/unit/cli/drift-secret-redaction.test.ts and tests/unit/deployment/intrinsic-import-value-index.test.ts, and a new NoEcho arm in tests/integration/custom-resource-getatt-data/. THE PROBLEM. A Lambda-backed custom resource's Data is persisted verbatim into the resource's attributes and into the resolved properties of everything that consumed it, so a handler that MINTS a value (a generated password, an issued API key) put that value in state.json in the clear. The response envelope has carried NoEcho all 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 a NoEcho custom resource's Data to a dependent resource as PLAINTEXT -- the AWS docs' "masked with asterisks" sentence describes the display channel, the way a NoEcho template PARAMETER behaves. So masking at resolution would make a template feeding that value into AWS::SecretsManager::Secret.SecretString store the literal *** AS the secret, which is worse than the leak. Fn::GetAtt therefore 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 as plaintext -> SECRET_MASK in the same RecordedSecretValues bag, 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's WeakMap was removed after a mutation probe showed its extra conjunct unfenceable and wrong-pointing). A mask-only needle must clear MIN_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 answering Data: { 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 echoing event.ResourceProperties into Data would otherwise mask the resource's own ServiceToken in the record CustomResourceProvider.delete reads 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, because perResourceSecrets is 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 set NoEcho. 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 its inferred needles. 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 and drift --revert / resolveReplayProps would push the corrupted string to AWS. maskSecretsInText is 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::GetAtt on 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 for Fn::GetAtt and, through reresolveCrossStackValue, for Fn::ImportValue / Fn::GetStackOutput / a nested stack's Outputs.<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 gives cdkd drift its three arms (mask the live value in the report, refuse --accept, preserve the live value on --revert and 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 persisted state.outputs -- a nested stack's Outputs.<Key> (via NestedStackProvider), Fn::ImportValue (index or state scan) and Fn::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: DeployEngine remembers stack + region + output key -> plaintext for 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 withdraw provenPublicExpressions (residual #2425): keyed on a value alone, one stack's answer is served to another stack's identically spelled read. NestedStackProvider reports 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 setting NoEcho) and states that a cross-stack value needs producer and consumer in ONE run. The cdkd export blocker 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. And cdkd drift reports a permanent, unresolvable comparison at such a leaf (both sides render ***), documented in docs/cli-reference.md rather 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 new NoEcho arm, and lambda (2026-09-02T17:40:08Z, PASS, 190s) for the integ-broad gate that deploy-engine.ts and intrinsic-function-resolver.ts activate -- 10 and 9 resources deleted, 0 errors, 0 orphans on either. The NoEcho arm asserted BOTH directions against real AWS in one run: the dependent SSM parameter held the real token on AWS while the custom resource's attributes, the dependent's properties, state.outputs and the whole state blob held none of it, and the echoed ServiceToken survived as an addressable Lambda ARN. The existing fixture asserts the OPPOSITE and keeps doing so: custom-resource-getatt-data requires the non-NoEcho custom resource's value in CLEARTEXT on AWS and in state.outputs, which is the negative case. The new arm adds a second custom resource returning NoEcho: true through the SIMPLE-HANDLER shape (no Status field) -- 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's attributes, the dependent's properties, state.outputs and the whole state blob hold none of it. Deliberately NOT included: persisting the flag on ResourceState, 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, plus tests/unit/cli/{non-interactive-confirm-guards,readline-prompt-population}.test.ts (new), a rewritten tests/unit/cli/commands/confirm-prompt.test.ts, and new cases in tests/unit/cli/{state-orphan,orphan,import,state-migrate,retire-cfn-stack,export-nested-loop,drift-json-stream,state-refresh-observed}.test.ts. rl.question never 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 from cdkd import --migrate-from-cloudformation / cdkd migrate --retire-cfn-stack, and cdkd state migrate. Each now throws CdkdError with the code NON_INTERACTIVE_CONFIRM and exits 1, with a message naming the command AND the flag that avoids the prompt (--force on rollback; -y / --yes everywhere, plus -f / --force on 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, and rollback / state orphan differed only in prompt suffix; issue #2259 guarded ONE copy and nine survived. All nine now call confirmOrRefuse(prompt, { refusal, suffix?, output? }), which carries the guard BEFORE readline.createInterface. Its position is correct for free -- every call site sits inside its command's own --yes / --force short-circuit, so a flagged run never consults stdin at all. No user-visible output changed: each site keeps its exact prompt string (the suffix parameter preserves (y/N): on rollback / state orphan against [y/N] on the other seven) and supplies its own refusal message; the two answer-parsing spellings (/^y(es)?$/i on seven sites, t === 'y' || t === 'yes' on two) were equivalent and converged on the regex. promptYesNo stays 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.ts and state.ts's state destroy --all prompt deliberately keep their own inline guards (a default-YES/default-NO pair, and an abort signal for the #2117 Ctrl-C handling; folding destroy-runner.ts would also drag a pure refactor into the integ-destroy / integ-broad gate scopes). tests/unit/cli/readline-prompt-population.test.ts is what stops copy #10: every readline.createInterface in src/ 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 let state.ts hold one guarded and two unguarded interfaces at once. Measured live against the built CLI: cdkd state orphan <stack> < /dev/null exited 1 in 6 s naming -y / --yes and -f / --force with the state record untouched (pre-fix: hangs indefinitely), while --force still 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 transient cdkd-migrate-tmp/ child-template uploads in the DECLINE branch, and the new guard THROWS past it -- past the recursive walk's catch (already left) and past the post-UpdateStack finally (still below) -- so a non-TTY cdkd import --migrate-from-cloudformation on 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 a finally keyed 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 (recursive catch, the gate, the no-modification fast path, the post-UpdateStack finally) now share one drainTemplateUploads helper, 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-cloudformation and cdkd 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 a finally, 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 their targetList through displaySafe(..., { 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 answers y to. Coverage: rollback.ts's prompt had NO end-to-end routing case at all (both rollback suites hardcode force: true, so the guard could have been deleted and both stayed green), drift.ts's --revert call site had none where --accept did, and export.ts's two in-exportCommand prompts were reported unclosable because exportCommand is not exported -- createExportCommand() is, so a new tests/unit/cli/export-non-interactive-confirm.test.ts drives 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 of createInterface at the call site and evades any needle spelled on that name. The setStdinIsTty stub that had been copy-pasted into ELEVEN suites is now one shared tests/stdin-tty.ts, whose restore DELETES the property rather than defining undefined onto it (measured: 'isTTY' in process.stdin is false with stdin at /dev/null, so writing undefined back CREATED an own property that was genuinely absent). Deliberately NOT changed: cdkd events prune is a TENTH mutating prompt and already carries its own non-TTY guard, so it does not hang -- but it refuses with a logger.info and 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 list reserves 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 in tests/unit/cli/state-json-stream.test.ts. state.ts calls reserveStdoutForPayload() four times and every one sat under if (options.json), which is why issue #2410's git grep read the file as covered and its body excluded this site. stateListCommand's DEFAULT mode writes one Stack (region) reference per line -- the same record-set shape as cdkd 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 a while read -r ref consumer read log lines as stack names. Measured on the pre-fix build: cdkd state list --state-bucket <nonexistent> --verbose put 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 --verbose run 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. So state resources (three columns padded to data-derived widths), state show (renderTreeWithChildren / renderStateBlock blocks) and state info (a metadata block) keep their --json gate deliberately, and a case in the suite fences that -- without it, "make every state reservation unconditional" would have passed. state list --long / --tree without --json ARE 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-agentcore now 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), plus tests/unit/cli/{synth-stdout-stream,local-invoke-stdout-stream,local-invoke-agentcore-stdout-stream}.test.ts (new) and new cases in tests/unit/cli/list-json-stream.test.ts. Issue #2280 keyed the reservation on options.json, but a --json flag 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: synth the CloudFormation template, list a listing in EVERY mode (one display id per line by default, YAML under --long / --show-dependencies, JSON under --json), local invoke the function response, local invoke-agentcore the agent response across four terminal emitters (emitResult / emitMcpResult / emitA2aResult / emitWsResult, the last writing only the stream terminator) plus the SSE / --ws chunk sinks. Each now calls reserveStdoutForPayload() at command ENTRY, before the synth whose app-executor.ts re-emits the CDK app's stderr at INFO. The DEFAULT human output contract therefore moves, deliberately and per command: on synth the 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 whole Synthesis complete! summary block goes to stderr -- stdout on synth is the template or it is empty, matching cdk synth. Lines are MOVED, never suppressed. Explicit non-goals, so a reader does not read them as gaps: cdkd deploy and all six long-running local servers (start-api, run-task, start-service, start-agentcore, start-alb, start-cloudfront) keep their human stdout (banner, route table, prefixed container logs); diff (demotes to warn), events, drift and the four state {list,resources,show,info} payload writes were already correct under --json -- and that qualifier is load-bearing, because all four state reservations are --json-gated, so cdkd 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's runStackBuffered short-circuit stays a documented latent gap -- none of the four runs work inside a buffer (deploy.ts's runStackBuffered call 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 --json command". Live A/B on tests/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 --verbose put 15 prose lines on stdout above the YAML (20 lines / 1678 B) and the yaml parser rejected the capture with Implicit 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 --verbose went 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 --json and cdkd list --show-dependencies likewise 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 every process.stdout.write in the four commands to process.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 BOTH promptOnStdout sites 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 --ws site was driven and the --watch one 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 the spawnForeground fd-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): streamLogs pipes the CONTAINER's stdout into ours on the two local commands, which the reservation cannot reach (#2419; measured against a real public.ecr.aws/lambda/nodejs:20 RIE container, whose START / END / REPORT lines AND both console.log and console.error land on container STDOUT while only the rapid internals go to stderr), toYaml left YAML indicator characters unquoted so cdkd synth emitted 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 SECOND ConsoleLogger with its own module state and no reservation hook (CdkLocalEmbedConfig carries no logger field), so the src/local/*.ts shims re-exporting its buildContainerImage / buildAgentCoreCodeImage / downloadAndExtractS3Bundle still print Building 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 foreground docker pull one 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 that cdkd local invoke stdout 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 whenever streamLive is on (which defaults to --verbose), and src/local/ecr-puller.ts's docker login and docker image inspect both reach it, so cdkd local invoke Stack/ImageFn --no-pull --verbose > out.json wrote a multi-hundred-line inspect array -- the image's baked-in Config.Env included -- 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 next runDockerStreaming caller 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: spawnForeground passes stdio: 'inherit', and ecr-puller.ts runs runDockerForeground(['pull', ...]) UNCONDITIONALLY, so cdkd local invoke Stack/ImageFn > out.json put docker pull progress in the payload with no --verbose and 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; under 2> file they degrade to plain lines, the correct trade against corrupting the payload). streamLogs in docker-runner.ts is 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) -- and isStdoutReservedForPayload's JSDoc now says so, rather than leaving the class reading as closed. And the --ws REPL prompt was gated on process.stdin.isTTY alone while writing > to STDOUT, so cdkd local invoke-agentcore Agent --ws > frames.txt from a terminal appended a prompt after every frame and the | tail -1 these very docs prescribe returned > -- the workaround broken by the thing it works around; the two questions are now separate (interactive still drives reading stdin lines, the new promptOnStdout additionally requires process.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 (new IndeterminateGuard, plus an optional indeterminateGuards field on BOTH arms of ResourceDeleteResult), src/types/deployment-events.ts (new RESOURCE_GUARD_INDETERMINATE event type + a guard field; reason widened past RESOURCE_SKIPPED), src/deployment/delete-outcome.ts (withIndeterminateGuard / deleteIndeterminateGuards), src/provisioning/cloud-control-provider.ts (confirmDeleteTargetIdentity returns 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, plus tests/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 to tests/unit/provisioning/cloud-control-s3-delete-identity-2283.test.ts, and a new Phase 0c-ID arm in tests/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 an s3:GetBucketLocation Deny in the target's own bucket policy, settable by anyone holding s3:PutBucketPolicy on 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 third outcome member, because a suppressed guard is orthogonal to whether the resource was addressed and because undefined already means 'deleted' for the ~80 providers that return void -- 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 to RESOURCE_SUCCEEDED, not instead of it (the shape issue #1819's partial-UPDATE skip already established): instead-of would leave the RESOURCE_STARTED row with no terminal partner and would contradict RUN_FINISHED's counts.deleted, which this deliberately does not move. The counter likewise never touches deletedCount / 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 third reason text 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, denies s3:GetBucketLocation on it, proves the deny took effect, then asserts the delete PROCEEDS and that the event lands in the deployments/*.jsonl OBJECT (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) lists s3:GetBucketLocation in none of the five handlers, and delete needs only s3: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 no src/ 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; make withIndeterminateGuard a no-op: 7/49; remove the events.ts colorize arm and guard= 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: the reason interpolated AWS's own message, and S3 words the AccessDenied for this exact population as User: arn:aws:sts::<account>:assumed-role/<role>/<session> is not authorized to perform: .... Pre-change that text was an ephemeral logger.warn; persisting it would have written the destroying principal's account id, role and session name into a durable artifact cdkd destroy does not sweep -- at a moment the ATTACKER picks, since they set the deny policy. Fixed with issue #2302's describeAwsFailure split (the error CLASS into the persisted reason and the warn, AWS's wording at debug), which is the same answer the SDK-routed twin of this guard already reached in s3-bucket-provider.ts's probeFailedCause. 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: wireBucketLocationError built a bare Error, and describeAwsFailure keys the redaction on the $metadata / $fault markers 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's cdkd events pointer on a recorder actually existing (cdkd state destroy threads none, so it was directing that caller to an empty command), switched withIndeterminateGuard's field-by-field rebuild to a spread so a future arm field cannot be dropped, corrected a doc claim that the row can accompany RESOURCE_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 in docs/_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, and lambda (2026-09-02T17:40:08Z, PASS, 190s) for the integ-broad gate that destroy-runner.ts activates -- 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 produced for 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 five provider.delete sites and the rollback executor's arms still discard the verdict -- filed as #2422. The same audit also found that cdkd state destroy threads no event recorder at ALL, so both copies of the RESOURCE_SKIPPED doc claiming it is "Emitted by cdkd 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, plus tests/unit/deployment/secret-redaction-derived-needles.test.ts (new), flipped residual assertions in tests/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 in tests/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-observed resolves nothing, and a plain cdkd deploy reaches 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 in state.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 where redactByPath ALREADY 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. deriveReadbackNeedles runs 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 to redactByPath as 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 made cdkd state refresh-observed and 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 / nestedStackParameterExpressions are WeakMaps 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): isSingleDynamicReferenceToken accepts 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 drift pins the same rule for its own registration path, and without this the two commands answer differently about one expression. Only at or above MIN_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 path cdkd drift --revert and resolveReplayProps re-resolve against the live resource. A MIXED leaf yields a needle too, which matters because this module calls that shape DOMINANT for CDK (Fn::Join around secretValueFromJson). 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@h inside postgres://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 --revert then pushes. That was caught by its own test before review, not reasoned about. ONE ANCHOR-GATE RELAXATION, in the direction that never guarded anything. anchorsCorroboratePairing compared object key COUNTS, so an AWS-added Arn / LastModified inside 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 of anchorSignature (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 into redactByPath) lets the scan rewrite a frame LITERAL that happens to embed a learned plaintext -- a coinciding anchor, exactly this issue's population -- after which unkeyedArrayPairsByAnchors, 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 turns postgres://appuser:{{resolve:secretsmanager:...}}@h/db into postgres://{{resolve:ssm:/app/db-user}}:{{...}}@h/db -- a FABRICATED baseline that cdkd drift --revert re-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 -- the ssm-secure: unsupported-service arm makes AWS echo the token literally, so source EQUALS bag. What ships asks the pass itself: refuseUncertifiedReadbackPositions runs a second time in mark mode, returning a POSITION_DECIDED sentinel wherever it decides, and preferPositionDecisions takes 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: and ssm-secure: say what they are, and a RECORDED verdict is a real GetParameter answer; a bare {{resolve:ssm: token with no verdict is accepted on the #1901 premise (a public String is persisted RESOLVED, so a token SURVIVING in a state bag is a SecureString) -- sound for the leaf ITSELF, and not a licence to rewrite every other leaf that merely CONTAINS the value. Measured: a bare ssm token whose value is production turned my-production-logs into my-{{resolve:ssm:/app/env}}-logs, which is the failure expressionMaySeedANeedle's own doc names -- and if the parameter is in fact public, cdkd drift --revert re-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 RECORDED SecureString verdict deliberately does NOT promote a bare ssm token, because recordedSecretExpressions is keyed on the bare expression and lives for the whole process: on a cdkd deploy --all a verdict pinned where the parameter is a SecureString is inherited where it is a plain String, and the skipDynamicReferences diff path skips the lookup on a true verdict 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 bare ssm from the seeding set instead, which would have failed exactly on cdkd state refresh-observed -- the process the issue is reported against. Its object arm is guarded on hasPlainPrototype because isPlainObject admits a Date and Object.entries(new Date()) is [] -- without it the merge itself rebuilt a readback LastModified as {}, 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 to refuseUncertifiedReadbackPositions. mixedLeafMayCarryPublicReference SPLITS on whether a map exists, and a derived map satisfies size > 0 while proving nothing was resolved — handing it over would read every {{resolve:ssm: mixed leaf as public and persist the decrypted SecureString, i.e. the regression the secrets-dynamic-ref integ 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 three RESIDUAL (issue #2012) cases (two in state-refresh-observed.test.ts, one in secret-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 that cdkd state refresh-observed leaves PUBLIC_URL as the EXPRESSION; that arm failed against fixed code AND against main on the pre-merge run (rc=1 at exactly that line), and Phase 1g's CONTROL 3 -- claimed as issue-#2036 coverage -- passed identically on main, i.e. discriminated nothing. Both had the same upstream cause: properties holds a public ssm String RESOLVED by construction (#1901), so the POSITION SOURCE carries no reference and the mixed-leaf arm is never consulted. A new Phase 1f3 stamps the cdkd import warn-path shape into properties (the same S3 write/restore idiom Phases 1f / 1f2 already use) and pins the needle's BLAST RADIUS -- once PUBLIC_URL's source carries the reference, the needle learned from it also rewrites SSM_VALUE, which holds the same resolved value of the same parameter. That is the arm that DISCRIMINATES; main leaves SSM_VALUE resolved. 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 POSITIVE provenPublicExpressions store, written from the resolver's own pinSecretVerdict retraction 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 a cdkd deploy --all spanning regions a verdict recorded where the parameter is a plain String un-redacts a SecureString of the same name in another region -- Server=db;Password={{resolve:ssm:/app/token}}; became Server=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 through redactSecretsForState and 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 in mixedLeafMayCarryPublicReference'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 two RESOURCE_FAILED records still persist an UNMASKED message, and the coupling is bounded by what a delete actually sends: provider.delete receives resource.properties and never observedProperties (which is read only by countProtectedResources, for two protection flags). properties is redacted against the TEMPLATE source, so isReadbackProjectedFromState is 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 in observedProperties too.

  • 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. deleteLock was a bare DeleteObject, so on the versioned state bucket every prior lock.json version stayed readable: renewal writes one version every two minutes at the default 30-minute TTL, deleteState never sweeps the lock key, and nothing purged it -- the chain was monotonic in stacks EVER deployed (452 versions on one measured key, invisible to aws s3 ls and still paged through by every ListObjectVersions the other purge sites issue). The shared purgeNoncurrentKeyVersions now runs at each of the four sites where deleteLock lands a delete marker: releaseLock's conditional delete and its unconditional escape hatch (one key-scoped purge in a finally covers both), forceReleaseLock (cdkd force-unlock, and cdkd state orphan), and acquireLock'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-VersionId design was built first and rejected on adversarial review, and the rejection is the interesting half. Threading each PUT's VersionId through HeldLock and issuing one DeleteObjects on 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 whenever DeleteObjects partially fails (a phantom holder with a live expiresAt), a per-id delete has no IsLatest guard so the try/finally shape sites 4 and 6 use would fire on releaseLock'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's IsLatest filter 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 profile docs/state-management.md already 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-key NoSuchVersion from DeleteObjects now 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. NoSuchKey is 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.md said purging on release "would make s3:ListBucketVersions / s3:DeleteObjectVersion required 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: getLockKey yields cdkd/{stackName}/{region}/lock.json, so lock.json is a key SUFFIX interleaved with state.json under one prefix, and S3 lifecycle filters support only Prefix / Tag / ObjectSize; the one expressible prefix rule (cdkd/) would expire state.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 -- deleteRollbackJournalBestEffort fires from BOTH deploy-success arms, so a ListObjectVersions goes out on every successful deploy including the common case where no journal ever existed. Sites 1-3 (state.json and 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-command step 4b2 and gc-custom-asset-names Phase 4b both asserted lock.json kept 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 to state.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, and noncurrent == 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.sh carried the same control in prefix form, and re-pointing it exposed that its premise was gone -- cdkd import calls saveState once, which is one CURRENT version and zero noncurrent, so after site 5 a noncurrent >= 1 floor 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 to all >= 1 was tried and rejected, because assert_state_present has already run a head-object on state.json a few lines above, so all >= 1 is 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 that state.json keeps one, which fences sites 1-3's deliberate non-purge directly and which a purge that widened its prefix and lost its IsLatest filter 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 to DeleteObjects (the current delete marker must survive -- taking it would undo the release), the key-scoped Prefix with a <lock key>.bak sibling row in the listing so the wanted membership filter is actually fenced, ExpectedBucketOwner on every call, the finally behaviour 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 own objectDescription, the legacy region-less key, a current-version BODY (the shape the foreign-lock arm really has, where the IsLatest filter 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 parses lock-manager.ts and fails if a member calling this.deleteLock does not also call this.purgeLockVersions (and vice versa), with the member set pinned as a literal so a query that silently stopped matching cannot satisfy itself. Four cases in s3-noncurrent-version-purge.test.ts cover the NoSuchVersion carve-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 that NoSuchKey is 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: report is handed to the shared helper as its warn SINK and the helper calls that sink outside any try, while logger.debug reaches console.debug on stdout -- which this repo has measured throwing EPIPE synchronously under --verbose | head. Since every call site is a finally, that throw would have REPLACED the LockError the release was raising; report is now total. The AST fence attributed calls only through MethodDeclaration, so a class-property arrow or a constructor calling this.deleteLock was 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 to lock-manager.test.ts was NOT added to its sibling lock-renewal.test.ts, whose ~40 releases were therefore running the purge's error path on every one, green only because release-path failures go to debug. The population of suites that construct a real LockManager is three, and all three now carry the arm.

  • A removed Cognito Policies sub-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}. Deleting Policies.SignInPolicy (or PasswordPolicy, or the whole Policies container) from a deployed pool's template used to deploy green while AWS kept the old policy: UpdateUserPool preserves an omitted sub-key (measured us-east-1 2026-08-19, issue #1968) and toSdkUserPoolPolicies forwards 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 permanent cdkd drift difference; MEASURED live (us-east-1, 2026-09-02, stack Cdkd1979LiveVerify) there is none, and the prediction is withdrawn rather than shipped -- update() refreshes observedProperties from the post-update readback, so the retained sub-key lands in the drift baseline too, the announcing deploy CONVERGES, and cdkd 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 the toSdkUserPoolPolicies docstring 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 PUBLIC update() (warn iff not sent). The announcement reaches every update() caller truthfully: cdkd deploy warns on the deploy carrying the removal (later deploys are honest NO_CHANGEs, matching CFn's converged stack -- both measured live), cdkd drift --revert reaches it only in one narrow shape -- the revert's desired side starts from the LIVE bag (buildRevertNewProperties) and a record with no observedProperties merges AWS's untemplated sub-keys back in, so what actually fires is a record WITH observedProperties plus a sub-key added out-of-band after that capture -- and the rollback executor's two arms differ from each other, the revert arm warning when rolling back to a record that lacked the sub-key while revert-failed-update passes the FAILED attempt's bag and so can name a sub-key that never reached AWS. Each call site's exact condition is documented at warnOnUnremovablePoliciesSubKeys, 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 an in-based presence predicate (disagreeing with the wire on a declared null) reds the agreement table by name. The cognito integ gains a PolicyRemovalUserPool UPDATE 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-declared PasswordPolicy kept forwarding, and a fired-call companion (AutoVerifiedAttributes added 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 ClientToken rationale 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, plus tests/unit/provisioning/efs-access-point-idempotency-token.test.ts (new). CreateAccessPoint sent no ClientToken, so a 500 whose request had actually SUCCEEDED server-side re-entered create() through the engine's outer retry and minted a SECOND access point with no state entry -- invisible to cdkd destroy, billing until found by hand. The field was not simply absent from the wire: EFS models it with Smithy's idempotencyToken trait, 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-efs 3.1018.0 by capturing the serialized body of two identical sends: 846a541f-... then d84cafec-..., while a caller-supplied value went out verbatim. That measurement also falsifies the unhandledByDesign rationale this file has carried for ClientToken ("AWS SDK manages this idempotency token internally ...; no user-supplied value is honored"), which is rewritten in the same change; ClientToken STAYS 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 from acquireIdempotencyToken (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's CreationToken. The two are not interchangeable, and the argument deliberately does NOT rest on a retirement window: EFS publishes no retirement period for a CreateAccessPoint ClientToken (the "one minute" in the EFS User Guide's "Creation token and idempotency" section is about file-system CREATION tokens, and the CreateAccessPoint reference 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 --replace delete-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 with AccessPointAlreadyExists, the first attempt's access point is live AND orphaned with a failed deploy on top, so createOrAdoptAccessPoint adopts it -- the same shape route53-provider.ts uses for the same issue family -- after confirming via DescribeAccessPoints BOTH that its ClientToken is 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 @throws list 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, since isTransientServerError walks .cause, which holds the 409 conflict rather than the 5xx. An AccessDenied is NOT retried: that grant is absent rather than propagating. That retry is interrupt-threaded with a per-WAIT startInterruptWatch disposed in a finally (never on this -- 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.ts enforces this and caught the omission, since an un-threaded withRetry leaves 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: withRetry throws InterruptedWaitError out of its backoff, which lands in the same catch as a lookup failure, and decline sets cause to the CreateAccessPoint conflict -- ERASING the interrupt from the chain isInterruptedWaitError walks, so deploy-engine would miss it and automatically roll the whole stack back on Ctrl-C, the exact outcome interrupt-watch.ts exists to prevent. The catch now rethrows an interrupt first, matching dynamodb-globaltable-provider.ts and elbv2-provider.ts. The decline is additionally markNonRetryabled rather than trusting its wording, because the message interpolates a user-chosen logical id and RETRYABLE_ERROR_MESSAGE_PATTERNS holds whitespace-free entries that such an id could contain. Every way the confirmation can still fail DECLINES: it warns, and rethrows a ProvisioningError naming the token collision and carrying the AWS error as cause. AWS-authored text is withheld from that thrown message via describeAwsFailure (summary names the failure CLASS, detail goes to debug), for two reasons that happen to coincide: the message reaches the durable deployments/{runId}.jsonl store, and the headline population of this arm is a missing elasticfilesystem:DescribeAccessPoints whose AWS wording names the account id, the deploy role and the session; that same wording contains not authorized to perform, an IAM_PROPAGATION_ERROR_MESSAGE_PATTERNS entry, so interpolating it made a permanently missing grant retry on the dense IAM cadence -- 27 attempts re-issuing CreateAccessPoint. The decline is now terminal, which is correct for an absent grant. Adoption also refuses an access point reported as deleting / deleted, and the decline path no longer double-wraps (if (error instanceof ProvisioningError) throw error, matching route53-provider.ts and fsx-filesystem-provider.ts). The wrap is load-bearing rather than cosmetic -- DeployEngine's replacement path classifies with isNameCollisionError, which matches the raw conflict's already exists wording, 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 if setSystemTime ever no-ops both attempts land in the same millisecond, a Date.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: describeAwsFailure keys on exactly those marker fields, so a redaction assertion written against a bare new Error(...) holds vacuously -- it did, until the assertion caught it -- and smithy's ServiceException[Symbol.hasInstance] matches on $fault && $metadata && name === this.name rather than prototype identity, so the name === fallback beside instanceof is 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, the name === fallback, and the deleted and error lifecycle clauses. A sibling sweep of the CREATE-shaped SDK command constructions under src/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 (EFS CreationToken, FSx ClientRequestToken); the remaining token-carrying models are non-defects (S3's Token on PutBucketReplication / PutObjectLockConfiguration is an Object-Lock confirmation token; the Scheduler / Secrets Manager creates are NAMED, so a replay collides rather than duplicating; and cloudfront-oai-provider.ts / agentcore-runtime-provider.ts derive 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 --json surfaces now put the payload and nothing else on stdout, closing the issue-#2230 class (issue #2280) -- src/cli/commands/state.ts (the four state {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, plus tests/unit/cli/{state,list,events}-json-stream.test.ts (new). Each command now calls the module-level reserveStdoutForPayload() the #2230 lane added, once, when --json is in effect and BEFORE any collaborator can print -- ahead of applyRoleArnIfSet (whose Assumed role ... INFO line fires on any --role-arn / CDKD_ROLE_ARN run) and, for cdkd list, ahead of the synth (whose app-executor.ts re-emits the CDK app's stderr at INFO on a DEFAULT run -- the worst of the six, corrupting cdkd list --long --json | jq with no --verbose involved). No edits in role-arn.ts / s3-state-backend.ts / app-executor.ts were needed: their prose flows through child() 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, matching drift (cdkd diff keeps its pre-existing demote-to-warn suppression; the re-derived population confirmed the other three of state.ts's seven un-gated setLevel('debug') sites sit on non---json subcommands and are untouched). The three new suites mirror drift-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---json controls stay green. Live before/after against a stderr-chattering fake CDK app: released 0.284.85 cdkd list --long --json puts the chatter on stdout and json.load rejects it; this branch's binary parses, chatter on stderr. The same defect class on stdout payloads with NO --json flag (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, honouring NO_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 (an agent-base subclass evaluating NO_PROXY per request via proxy-from-env, inner agents cached per instance, destroy() forwarded) plus an injected defaultProvider credential 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 because NodeHttpHandler.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 in tests/aws-client-defaults-allowlist.json (shrink-only, enforced by the new AST checker + audit:aws-client-defaults:check CI step) and empty out in the follow-up PR. NO_PROXY semantics (exact hostname unless the entry starts with . or *; CIDR silently ignored), NODE_EXTRA_CA_CERTS against TLS-terminating proxies, and the Docker-daemon egress caveat are documented in docs/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 under src/ (the aws-clients.ts literal-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 NOT proxy-agent — its PAC support drags vm into 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-asset integ fixture waits for the image function to leave Pending before invoking it (issue #2403) -- tests/integration/docker-image-asset/verify.sh. cdkd deliberately does not wait for State=Active after CreateFunction, and a container-image function takes 10-60 s to activate, so the fixture's immediate invoke raced activation and failed with ResourceConflictException (two consecutive live failures on 2026-09-01). A bounded aws lambda wait function-active-v2 between 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 defaulted CommaDelimitedList reaches consumers as a LIST, and an AWS::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. resolveParameters writes parameters[name] at THREE sites and only ONE -- the user-supplied value -- asked the type coercion anything. The other two were the LITERAL Default and the SSM-resolved default, so a parameter declared Type: CommaDelimitedList with Default: "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 hits CommaDelimitedList and List<Number>, the two list types the switch recognised all along, which makes it the older and more reachable half. Fn::Select over such a parameter threw Fn::Select: list must be an array, got string and the deploy hard-failed; Fn::Join refused it the same way; a bare Ref was the SILENT arm, handing a provider a comma-joined scalar where the resource schema declares a list. CloudFormation's own worked example in parameters-section-structure.html is exactly this pairing -- VpcAzs: {Type: CommaDelimitedList, Default: "us-west-2a, us-west-2b, us-west-2c"} read with Fn::Select. ONLY A STRING DEFAULT IS COERCED, and the shapes were MEASURED rather than assumed. ParameterDefinition.Default is typed unknown because it is whatever the template parser produced, and both parsers cdkd feeds this from pass the literal through: aws-cdk-lib's CfnParameter._toCloudFormation emits Default: this.default with no conversion, so {type:'Number',default:42} synthesizes the JSON NUMBER 42 and 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 a String() normalization. THE SSM SITE NEEDED THE INNER TYPE, NOT THE DECLARED ONE, which is why it is not the same one-line edit. isListParameterType deliberately answers false for every AWS::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 SHARED src/utils/parameter-types.ts as ssmResolvedValueType, beside isListParameterType, and src/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.html defines Value<List<String>> / Value<CommaDelimitedList> as "a Systems Manager parameter whose value is a list of strings ... corresponds to the StringList parameter 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 types Parameter.Value as string and states "if type is StringList, 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 scalar Value<...> forms peel to a scalar inner type and coerce to themselves, so Value<String> is untouched. The change sits STRICTLY AFTER the issue #1002 referencedNames skip, which continues, so it cannot make an unreferenced parameter resolvable again -- pinned by its own case. Off the documented type space, Value<Number> newly coerces to Number(resolved) -- NaN for 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, which aws-cdk-lib's own StringListParameter.fromListParameterAttributes also emits (default: attrs.parameterName, a single name, for a Value<List<String>> parameter) -- CONTRADICTS a comment already in this repo. src/synthesis/macro-expander.ts states the supplied value for Value<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 with Parameter ... 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 in ssmResolvedValueType'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 value GetParameter has ALREADY returned, which is downstream of whatever the supplied key meant. resolveSSMParameter's existing single GetParameter is 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 / bare Ref) because a test that only checks resolveParameters returned something passes on the broken code. Four mutations, each restored and re-verified by grep -c: reverting the Default site reds 9 cases including the literal Fn::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 with String(defaultValue) reds exactly the non-string case, on expected [ '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, on expected [ '/app/a', '/app/b' ] to be '/app/a,/app/b', which is a Parameter Store KEY shredded on a comma. Fn::Split over such a parameter is NOW REFUSED, and that is a behaviour change worth stating. A template that wrote Fn::Split over 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, so resolveSplit's ALREADY a list arm refuses it -- matching CloudFormation, which rejects Fn::Split over a list too, and matching what a user-supplied value has always done. A case pins the refusal AND its source.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 claimed Number "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 discriminating Number polarity is the STRING default, and what fences the guard is the comma-bearing array. The malformed-Value<...>-spelling case is the same: Value fails startsWith whatever else changes, and Value<> 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, so ResourceState.properties for any resource fed by such a parameter changes shape. That is a real change, not a display artifact: the next cdkd deploy issues an UPDATE, and where the parameter feeds a create-only / immutable property it is a REPLACEMENT -- deploy-engine.ts sets needsReplacement from change.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. Review cdkd diff before the first deploy after upgrading if any template declares a defaulted CommaDelimitedList / 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 a Ref to a List<Number> as returning a list of STRINGS ("80,20" -> ["80","20"]), while coerceParameterTypedValue returns 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 other List<...>) nested-stack child parameter resolves to a LIST instead of a comma-joined string, because the two contradictory answers to "is this Parameter Type a 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. coerceParameterTypedValue named exactly two list types in a switch (List<Number>, CommaDelimitedList), so the nine List<AWS::...> types CloudFormation defines fell to default and came back as the raw comma-joined STRING; stringifyParamDefault in src/synthesis/macro-expander.ts tested inner.startsWith('List<') || inner === 'CommaDelimitedList', the wider and correct view. aws-cdk-lib's own CfnParameter.isListType agrees with the second, so a CDK app could legally declare List<AWS::EC2::Subnet::Id>, call valueAsList(), and have cdkd hand the child a string. Both sites now call isListParameterType. THE CONSEQUENCE WAS NOT COSMETIC. Fn::Select over such a parameter -- the pattern AWS's own Parameters documentation gives for list types -- failed the deploy with Fn::Select: list must be an array, got string, and Fn::Join refused it the same way. A bare Ref used as a property value was the silent arm: a provider whose schema declares SubnetIds a list received one scalar string. Fn::Split over one moves the OTHER way and is now REFUSED as "ALREADY a list", matching CloudFormation and the existing CommaDelimitedList behaviour; 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.html lists the base Type values as String, Number, List<Number>, CommaDelimitedList plus the AWS-specific and Systems Manager families -- a bare List<String> is not among them, and appears only as the inner shape of AWS::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 is startsWith('List<') && endsWith('>') rather than aws-cdk-lib's indexOf, which would also match the Systems Manager OUTER form AWS::SSM::Parameter::Value<List<String>> -- and that must NOT coerce, since its value is a Parameter Store KEY rather than the resolved list. NestedStackProvider.extractParameters had 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 passing Parameters: { SubnetIds: {Ref: SubnetIds} } for a list-typed parameter went from deploying to dying. It now joins an ARRAY with ,, copying the shape resolveChildImportParameters in src/cli/commands/export.ts already used for the same wire question, down to String()-ing each element so a List<Number> array survives. The ELEMENTS are checked, which is the one place it deliberately does NOT copy export.ts: an unchecked String(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.ts keeps the unchecked form because a failure there degrades the parameter to stillSkipped and 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 own CommaDelimitedList has 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 by refuseCoercedInheritedSecret like CommaDelimitedList already was, so a comma-bearing secret in one is refused by name while a comma-free one still resolves with the secret element intact. And Fn::Equals over 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-typed Ref; the cost is that filterResourcesByCondition prunes a condition-gated resource state still holds, so the next deploy deletes it, bounded by cdkd diff previewing that delete. The predicate lives in src/utils/, not beside either consumer: hosting it in src/deployment/ gave the tree its first src/synthesis/** -> src/deployment/** import, inverting the documented layer order. src/utils/ip-protocol.ts is 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 in src/deployment/intrinsic-function-resolver.ts named "CommaDelimitedList / List" as the list-source SET -- three of them user-facing message text (Fn::Join's refusal, and both Fn::Split remedies, the second of which a user NOW hits with a List<AWS::EC2::Subnet::Id>) and three comments or docblocks; a seventh docblock (inheritedSecretsCarriedBy) said "a CommaDelimitedList parameter arrives as an array" and was widened in the same pass. TWO more -- docs/cli-reference.md and tokenValueForComparison's docblock in src/cli/commands/diff-recursive.ts -- were written DURING this change and were already incomplete on arrival: they spelled out "CommaDelimitedList, the nine List<AWS::...> types and List<String>", which omits List<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), restating isListParameterType'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 to return [type]. NOT closed here: the SSM default path at resolveParameters assigned the resolved value without calling the coercion at all, so an AWS::SSM::Parameter::Value<List<String>> parameter still resolved to a string. Different root cause, filed as issue #2367 -- which found the LITERAL Default path uncoerced too, and closed both (see that entry). The count was the point: resolveParameters writes parameters[name] at THREE sites and this change touched the ONE that already called the coercion. No live coverage either: no integ fixture declares any List<...> 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).

    deleteRollbackJournal is the site that mattered, and the only one with a MEASURED disclosure. cdkd bootstrap turns versioning ON for the state bucket, so its bare DeleteObject wrote a delete marker and left every earlier journal body readable through GetObject with a VersionId. failedOperations[].attemptedProperties is the properties of the FAILED write verbatim: measured 2026-08-20 on CdkdDeletionPolicySnapshotHeavyExample as four surviving versions each carrying a literal "MasterUserPassword": "Cdkdcf2f..." after cdkd reported the state deleted. It now rides the shared purgeNoncurrentKeyVersions through S3StateBackend.purgeNoncurrentVersions, on all three of its delete paths (successful deploy, clean cdkd 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 NoSuchKey arm, 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's IsLatest filter makes the outright-failure case safe too: a key whose delete failed keeps its current version and loses only its history.

    state.json was 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 that state-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. deleteRollbackJournal runs on every SUCCESSFUL deploy, so cdkd now issues a ListObjectVersions per 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 exclude lock.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.json holds owner / timestamp / expiresAt / operation and 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.json carries 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 a ListObjectVersions + DeleteObjects round trip to every lock release and require s3:ListBucketVersions / s3:DeleteObjectVersion for 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.ts and retire-cfn-stack.test.ts mock @aws-sdk/client-s3 with only PutObjectCommand / DeleteObjectCommand; the shared helper catches its own construction failure and warns, so the purge would have silently no-opped there while toEqual(['PutObject', 'DeleteObject']) kept passing. Both mocks now carry ListObjectVersionsCommand / DeleteObjectsCommand and 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. NoncurrentVersionPurgeOptions gained an optional objectDescription, threaded through S3StateBackend.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 and cdkd gc share 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.ts drives 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 and gc-custom-asset-names's Phase 4b both prove the object is gone with head-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 to all == 1, noncurrent == 0: exactly one surviving row, which can only be the CURRENT delete marker cdkd's own delete wrote, since the purge filters on IsLatest and 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) so 0 versions and 0 leaks cannot read as a pass, and each carries a NEGATIVE CONTROL on lock.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: fixed all=1 noncurrent=0 (passes), reverted all=3 noncurrent=2 (fails), died-early all=2 noncurrent=1 (fails), already-swept all=0 (fails).

    rollback-command also gained the success-path version sweep it never had. Sourcing s3-versions.sh put it in the population of tests/unit/scripts/integ-s3-versions-harness.test.ts, which immediately failed it for disarming its EXIT trap without a following s3_assert_versions_swept -- correctly, because its step 8 removed the sidecars with aws s3 rm and proved it with aws s3 ls, the vacuous shape issue #2096 was raised about. It now purges all and 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 grepping verify.sh files while this fixture drives from run.sh. bin/app.ts names "the large (>51,200B TemplateURL) path" and lib/migrate-large-stack.ts puts the template at "about 67-69 KB in practice". Its existing assert_migrate_tmp_empty uses aws 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 by preflight_clean, so absolute counts would be measuring older runs) and asserts delete markers +1 or more, noncurrent +0. The positive marker is delete markers rather than all rows because only cleanup()'s own DeleteObject can 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. The export fixture 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 shared s3_count_versions cannot answer this question: its _s3v_check_prefix guard hard-requires a cdkd/<stack>/<region>/ shape and refuses cdkd-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's cleanup() placed s3.destroy() after the purge's catch arm rather than in a finally under 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.ts reproduces it by making both the version listing and the logger's warn throw.

  • CloudControlProvider confirms the state record's region before EVERY mutation, not only on the NotFound branch (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 to delete(), keyed on AWS::S3::Bucket. The GENERAL check underneath it -- assertRegionMatch, comparing the CLIENT's region against the STATE record's -- still ran in ONE place: the NotFound catch arm. A wrong-region call usually never REACHES that arm, because a Cloud Control Identifier is normally a NAME and the same name commonly exists in the client's region too (the same stack deployed to two regions; cdkd's own resource-name.ts deriving an identical name from an identical stack plus logical id), so the call SUCCEEDS against the wrong resource instead of erroring. delete() and update() now run that comparison unconditionally, for every Cloud-Control-routed type, ahead of every mutating step -- the --remove-protection flips, the SDK delegations, the #2283 probe, and the DescribeType behind the update path's write-only-property lookup -- and refuse non-retryably. update() gained the context?: UpdateContext parameter it never declared (the interface has carried it since issue #1732), and UpdateContext gained expectedRegion, threaded by the deploy engine, both rollback replay arms and drift --revert from the same values those callers already pass to their delete sites. User-visible change on cdkd 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 --revert is 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' own update() region hole is issue #2245. The refusal is fenced against being read as "already deleted", which is the third member of a family. Both destroy-runner.ts and 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 named HandleNotFoundException -- 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 check isMarkedNonRetryable first, 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. assertRegionMatch grew a RegionCheckPhase that changes only the MESSAGE ('not-found' remains the default, so every existing provider call site is untouched), and all three uses in CloudControlProvider -- both pre-flights and the NotFound arm -- 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 of US-EAST-1, reachable from a profile's region = US-EAST-1 (which foldRegionOption does 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 denying s3:GetBucketLocation produces -- cdkd still proceeds with a logger.warn and nothing durable. Routing that into the deployments/ events store needs a NEW DeploymentEventType, which is a persisted user contract with its own cdkd events rendering, 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 -- Data included -- to <responsePrefix>/<requestId>.json in cdkd's state bucket. cleanupResponseObject then issued a bare DeleteObject. cdkd bootstrap enables VERSIONING on that bucket, so the delete writes a DELETE MARKER and every prior version stays readable through GetObject with a VersionId: cdkd reported the response object cleaned up while a handler-minted secret (a generated password, an issued API key) remained retrievable by anyone holding s3:GetObjectVersion. Both paths that delete one of these objects now purge its NONCURRENT versions. cdkd gc is the SECOND path, and fixing only the first would have shipped the same over-stated invariant the issue is about. gc collects the ABANDONED placeholders -- the ones no run ever polled -- through stateBackend.deleteRawObjects, which sends DeleteObjects with { Key } and no VersionId. 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. gc now 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. purgeNoncurrentKeyVersions is a leaf module under src/state/: the provider calls it directly, and gc reaches it through the new S3StateBackend.purgeNoncurrentVersions, which supplies the bucket, the region-corrected client and ExpectedBucketOwner. A leaf module rather than a method on the backend because the provider is one of the two callers and src/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 the intrinsic-function-resolver.ts:4115 shape breaks it — types/assembly.ts:165 and appsync-provider.ts:1867 carry 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 same parseDiagnostics discipline as scripts/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 }) — a SpreadAssignment, 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 (a SpreadAssignment whose parenthesis-unwrapped expression is a BinaryExpression on && with an ObjectLiteralExpression right operand, over the 331 files git 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 generic keys: 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 ZERO import * as X from '@aws-sdk/...' and ZERO export { ... } 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 into deleteRawObjects, whose six call sites (measured: grep -rn '\.deleteRawObjects(' src/) include four in deployment-events-store.ts; tests/integration/s3-versions.sh records those objects as deliberately NOT delete-markered by cdkd 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, and gc's response sweep is today's one opt-in. The purge is scoped to a KEY SET and to what is not IsLatest, and both halves are load-bearing. CUSTOM_RESOURCE_RESPONSE_PREFIX is a TOP-LEVEL prefix -- a SIBLING of cdkd/, not a sidecar under a stack's own cdkd/<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. ListObjectVersions takes a Prefix, not an exact key, so every returned entry is re-checked against the requested set before anything is deleted; that is what lets gc pass 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 carrying IsLatest: 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 needs s3:ListBucketVersions (bucket-level) and s3: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 in Resource covers 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 for s3:ListBucket, and the /* ARN covers the object-level one exactly as for s3:DeleteObject). The same conditionality is recorded in tests/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 its s3:* catch-all (already covers both) -- as were docs/cross-stack-references.md's read-only cross-account consumer policy and docs/testing.md's CDK fixture snippet. Nothing in src/ mints a GRANT: the only policy cdkd writes is buildDenyExternalAccessPolicy, a pure Deny of s3:* 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 from finally arms and from the timeout arm; gc runs 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, DeleteObjects reports per-key failures in response.Errors instead of throwing, and with Quiet: true that array is the only signal there is — an earlier revision discarded the response, so a principal holding s3:ListBucketVersions but NOT s3: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, a Deny SCP and SlowDown reach the same state. The repo already documented the trap one file over, on deleteRawObjects. Second, the warning counted listing PREFIXES, not keys: 3000 unpurged gc keys reported 1 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. The gc call also moved into a finally, because deleteRawObjects throws 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.sh asserted as settled fact that custom-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_versions cannot even be pointed at that prefix, since _s3v_check_prefix refuses anything not shaped cdkd/<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 which Bucket / 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: RAISING DELETE_BATCH_SIZE is green because stale comes from a SINGLE ListObjectVersions page and MaxKeys caps 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 new src/state/ module is not added to the .claude/rules/layout-misc.md inventory: 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 and layout-misc.md already covers src/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 noncurrent state.json versions 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 both deleteState arms, deleteRollbackJournal (whose journal stores attemptedProperties verbatim — s3-versions.sh records a measured literal "MasterUserPassword": "Cdkdcf2f..." across four versions of one), lock-manager.ts's deleteLock, 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 through deleteRawObjects with no purge. That file does contain ListObjectVersionsCommand, but for emptyBucketAllVersions on 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 two CommaDelimitedList parameters 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 declared Type: String. docs/cli-reference.md names CommaDelimitedList as an equally ALLOWED spelling for a secret-bearing nested-stack parameter, and coerceParameterTypedValue turns 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 reached redactByPath as 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. resolveReplayProps re-resolves what is persisted, so a rollback -- or cdkd drift --revert over a baseline projected from that record, which listStacks enumerates 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 under STATE_DERIVED_RULES, and the diff. The paths with NO sourceProperties -- cdkd state refresh-observed and cdkd 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, and drift.ts's revertBaseline = observedProperties ?? properties means 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 that cdkd drift --revert could 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. certifiedExpressionForLeaf owns the question; certifiedListForLeaf applies it element-wise; positionByCrossStackSource (persist, scalar), positionListByCrossStackSource (persist, list) and inheritedParameterExpression (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. inheritedParameterExpression now returns string | unknown[] | undefined and answers per element for a list. Its second caller changed with it: IntrinsicFunctionResolver.recordInheritedParameterSecrets writes a Map<string, string> keyed by PLAINTEXT, so it now asks per carried plaintext rather than per parameter VALUE. The old spelling gated the override on plaintext === 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 --recursive was wrong for this shape independently of all of the above, and its comment is why it stayed wrong: tokenParameterNames restored 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 -- a CommaDelimitedList parameter'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 new tokenValueForComparison binds the token to the shape state holds, MEASURED from the real coercion rather than enumerated beside it (the discipline parameterTypeMayLoseSecretIdentity adopted 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, so String and Number / List<Number> are untouched while CommaDelimitedList splits. List<String> was NOT among them at the time, and read as though it should be: measured against coerceParameterTypedValue AS IT THEN STOOD, its switch NAMED only Number, List<Number> and CommaDelimitedList -- String was grouped with default, and every other type fell there too -- so List<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 whole List<...> family, so List<String> no longer returns a string and tokenValueForComparison splits it. This paragraph describes the state of the tree at #2327 and is left in the past tense for that reason. The parameterTypeMayLoseSecretIdentity warning'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.extractParameters refuses a non-scalar parent-side parameter value outright ("Parameters must be scalars"), and it runs immediately after that recorder -- so an array resolvedValue there can never reach a child engine. The parent always hands the child a STRING; the array is produced INSIDE the child by its own Type coercion. 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 through plaintextIndexOf, 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: with EXPR_A also recorded against a different plaintext (the issue #1933 two-regions shape), the persist side certified EXPR_A while 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, and resolveReplayProps re-resolves it. recordNestedStackParameterExpressions now 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 that crossStackSourceKey can 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 in secret-redaction-nested-parameter-list.test.ts, two in intrinsic-resolver-inherited-parameter-secrets.test.ts (25 total) and two in diff-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 -- ignoring plaintextIndexOf's CONFLICTING_PLAINTEXT symbol -- 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 way recordInheritedParameterSecrets builds them; the helper used to hand the persist side new 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 by diff against 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 (toEqual over an identity claim, now toBe); and the empty-string case, whose "premise" assertion was a second undefined -- 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 --recursive section told a reader the redacted token is left uncoerced rather than cast by the child parameter's declared Type, which after this change is false for a list type -- and the parenthetical naming Type: Number / NaN was 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, so CommaDelimitedList splits (matching the ARRAY state holds) while List<String>, Number and List<Number> stay raw, and the split falls there because cdkd's secret redaction is string-keyed end to end. The doc's paraphrase of the cdkd diff warning 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 be Type: String" subsection was UNDER-stating in the same direction: it named CommaDelimitedList as allowed with no caveat, while a comma-bearing secret in one IS refused (fenced by intrinsic-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 on tests/integration/nested-stack-secret: ONE AWS::Events::Rule in the child whose EventPattern.detail carries listA / listB (two CommaDelimitedList parameters, one plaintext under a new list secret key) plus listPublic as 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 Default values its SIBLINGS declare during cdkd import, so a Fn::Sub built 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. resolveParameters is ALL-OR-NOTHING: it throws on the FIRST parameter declared with no Default and no supplied value, and cdkd import supplies none and has no flag that could. resolveImportedProperties caught that throw and continued with an EMPTY bag, so a template mixing VpcId: {Type: String} with Stage: {Type: String, Default: 'dev'} lost Stage's default too and recorded {'Fn::Sub': 'app-${Stage}-topic'} into the imported resource's properties as the literal STRING app-${Stage}-topic -- which then becomes the desired bag of the next cdkd deploy and the bag cdkd destroy hands a provider. (Note the parameter NAMES are reused across the two entries in opposite roles: here Stage is the DEFAULTED parameter and VpcId the required one, matching issue #2321's own repro, while the issue #2285 entry below uses Stage for the REQUIRED parameter, matching that change's tests. The names are the issues'; the roles are not the same.) Issue #2285's structural Fn::Sub refusal cannot close this and must not be widened to: Stage HAS a Default, so isUnboundTemplateParameter correctly answers false and 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, resolveImportedProperties now RETRIES resolveParameters over a Default-only VIEW of the Parameters section (new defaultOnlyParameterTemplate helper) -- every parameter carrying a Default, nothing else. The retry cannot hit the same throw by construction, since isUnboundTemplateParameter is false for exactly the population that survives the filter; re-entering the SAME method rather than hand-binding each Default keeps 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 outside Parameters is carried through untouched because resolveParameters walks Resources to decide which SSM defaults are worth a GetParameter call. A Default-only retry that fails on its own terms (an SSM default whose GetParameter is 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's Default exclusion 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. isUnboundTemplateParameter answers false for a Default-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's declares parameter(s) with no 'Default' that an import cannot bind (...) clause, while ${Stage} now resolves to dev. Neither of the two call sites is affected either -- both await resolveImportedProperties(...) for its void mutation of stackState.resources and 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 records app-dev-topic, and a second template pairs that with a resource built from ${VpcId} as a NEGATIVE CONTROL, asserting the raw Fn::Sub object still reaches state and parsing the warn clause to VpcId exactly (a substring match would pass on a list that grew to VpcId, Stage). The four added: Default: '' must record app--topic and a PRESENT Default: undefined must record app-undefined-topic, which together fence the 'Default' in definition SPELLING -- review measured that BOTH Boolean(d.Default) and d.Default !== undefined passed all 88 cases before them, and the first re-opens this very bug for the standard CFn optional-String idiom; an AWS::SSM::Parameter::Value<String> default must record the LOOKED-UP value with ssmSend actually called, which fences the { ...template, Parameters: defaulted } spread and with it the whole reason for re-entering resolveParameters instead of hand-binding; a rejected GetParameter must still reach saveState, which fences the inner catch; and a NESTED CHILD template carrying the same mixed parameter shape must record child-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 !== undefined reds exactly 1 -- the undefined-key case, which is what isolates it; dropping the spread reds 2, on expected '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 defaultsErr in place of the empty-bag fallback reds 1 with Error: 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, on expected '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 VERBATIM app-${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 on isUnboundTemplateParameter described the live caller in the present tense as one that "continues with an EMPTY bag", and the second was the stated JUSTIFICATION for excluding a Default-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.ts omits the parameters key entirely when the bag is empty, so the context arrives with parameters: undefined, and the predicate answers 'Default' in definition BEFORE it ever consults the bound bag -- so a Default-carrying parameter is unbound, unrefused, and its Fn::Sub is 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 in import.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-cloudformation and still carried the same unproved hard-fail consequence. Two further corrections went in with it: the caller is cdkd import in EVERY mode, not only --migrate-from-cloudformation (resolveImportedProperties sits on importCommand's unconditional flow, and the new tests reach it with no migrate flag), and the resolver JSDoc's example parameter is renamed ${Tier} because import.ts's #2321 comments use ${Stage} for the OPPOSITE role -- the one that DOES carry a Default -- 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 CreateBucket LocationConstraint cast 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.create sends LocationConstraint: canonicalRegion as BucketLocationConstraint (:6227), and the as asserts 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-s3 3.1018.0, CreateBucketConfiguration at dist-types/models/models_0.d.ts:1581 still declares LocationConstraint?: BucketLocationConstraint | undefined. (2) Widen the local parameter type to string and drop the assertion -- this does not compile. With the local bag's field typed string and the as removed, tsc fails at new CreateBucketCommand(createParams) with TS2769 ("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 omit CreateBucketConfiguration, which makes S3 create those buckets in us-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 (:1898 this.s3Client = awsClients.s3; getRegion() reads this.s3Client.config.region()), so a ca-west-1 create is issued against the ca-west-1 REGIONAL endpoint, where an omitted LocationConstraint answers IllegalLocationConstraintException -- the deploy FAILS. The us-east-1 default is documented by the SDK as a property of the GLOBAL s3.amazonaws.com endpoint (CreateBucketCommand.d.ts), which this path never uses; src/assets/asset-storage.ts:775 already states the rule ("For regions other than us-east-1, LocationConstraint is required") and .claude/rules/asset-bucket-region.md already names the exception. Measured, closing the last escape hatch: a genuinely region-less client THROWS rather than resolving empty -- new S3Client({}).config.region() with AWS_REGION / AWS_DEFAULT_REGION / AWS_PROFILE unset and both config files pointed at /dev/null gives Error: Region is missing, so getRegion()'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 named ca-west-1, mx-central-1, ap-east-2 and eusc-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-lib 2.244.0's 46-region RegionInfo.regions minus the enum's 33 members) reports fourteen absent, of which us-east-1 is absent BY DESIGN. The other thirteen are ap-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').partition is aws-eusc, its own partition, which PARTITION_TABLE in src/utils/aws-partition.ts lists beside the us-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 single canonicalRegion !== '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: every us-east-1 and eu-west-1 case stays green while CreateBucketConfiguration is 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.ts and state-migrate.test.ts had 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 stubbed CreateBucketCommand without ever looking at its input. asset-storage.test.ts is the worst case because it looked covered: its passes LocationConstraint for non-us-east-1 regions row pins ap-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 the us-east-1 negative 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.each expands): 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-cased CA-West-1 to arrive as ca-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-existing us-east-1 rows 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 because config.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 in us-east-1 silently, 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; at asset-storage it reds 2 of 112 (the new row, plus the incidental uppercase-region row described above); at bootstrap 1 of 23; at state-migrate 1 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 raw region instead of canonicalRegion reds 4 including the mis-cased row (which is how that row is shown to discriminate on its own axis rather than duplicating the eu-west-1 rows); 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's ASSET_STORAGE_FOREIGN_REGION_BUCKET refusal and S3BucketProvider's create / update / delete wraps each interpolated a raw AWS failure message into a THROWN error. That is a different surface from the terminal warns PR #2290 fixed: extractDeploymentEventError captures a thrown message into deployments/{runId}.jsonl, which outlives the run and is explicitly restricted to error plus metadata. On the headline population -- a principal without s3:GetBucketLocation / s3:CreateBucket, or a bucket policy that Denys it, the latter settable by anyone holding s3:PutBucketPolicy on the target -- S3 words its AccessDenied as User: 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 --verbose pointer, and AWS's own text goes to logger.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-policy Deny, 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. describeAwsFailure keys on the smithy marker fields ($metadata / $fault / $response) -- the same AWS-shaped test extractDeploymentEventError already uses -- rather than on "cdkd did not author it, i.e. it is not a CdkdError". The four sites are BROAD catch blocks, and s3-bucket-provider.ts alone raises six cdkd-authored plain Errors inside them: the BucketEncryption / OwnershipControls array refusals, the EventBridge and inventory Enabled refusals, the destination-shape refusal, and deleteBucketWithEmptyRetry's non-empty-bucket refusal, whose message IS the CloudFormation-parity remediation docs/troubleshooting.md quotes verbatim. Reducing those to the token Error would 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 every Error reds 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. withRetry classifies 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 on S3BucketProvider.create before the fix: not authorized to perform went from retryable-on-the-DENSE-IAM-cadence to NON-retryable, and S3's conflicting conditional operation (OperationAborted, a real race for cdkd's concurrent DAG) from retryable to non-retryable. retryClassificationText joins the message chain, and the classifiers read it instead of the top-level message, which stays what the warn / debug lines 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: isMarkedNonRetryable and isThrottlingError already walk the same chain for the same stated reason, they now share one MAX_CAUSE_CHAIN_DEPTH so 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 on main copies its cause's message. That claim is FALSE -- custom-resource-provider.ts's describeWaiterFailure already withholds the raw waiter payload -- and the shape it was covering for is not rare. A sweep of src/** for constructions that attach a cause but do NOT carry its text enumerates 18 sites, listed in full in the PR body; the decisive one is deploy-engine.ts:3355, the outer per-resource wrap, whose whole message is Failed 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-literal cause: property or a constructor parameter named cause -- 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 flips false -> true for a DependencyViolation cause and for a does not exist cause -- a broad retry-semantics change with nothing to do with this redaction. So a redacting throw now STAMPS itself with markRedactedCause (the same non-enumerable-symbol idiom as markNonRetryable, chain-walked to the same depth) and retryClassificationText returns 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 by retry.ts. destroy-runner.ts's delete-retry loop calls provider.delete DIRECTLY, so it classifies on its own and the retry.ts fix does not cover it. Both of its arms now read the chain text. OperationAborted is HTTP 409 with a non-throttle name, so isThrottlingError and isTransientServerError both 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 $metadata is LOST across the wrap, leaving the message as its only carrier. dynamodb-delete-budget.ts's isTerminalDeleteFailure is 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.each expands, so a source-level it( count disagrees): vp test run <file> reports 9 + 6 + 12 for tests/unit/utils/aws-failure-text.test.ts, tests/unit/assets/asset-storage-thrown-identity.test.ts and tests/unit/provisioning/s3-bucket-provider-thrown-identity.test.ts, and the two existing suites add 7 and 3 under vp test run tests/unit/deployment/retryable-errors.test.ts -t retryClassificationText and vp 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 reading Access Denied cannot 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; debug and the default levels (info / warn) are separate spies, so "logged somewhere" cannot stand in for "logged where it is not persisted"; and a ThrottlingException arm in both files stops AccessDenied from 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 every Error reds the 2 negative controls, removing either debug line reds 3 and 4, reverting the asset-storage throw reds 3, un-redacting the non-Error branch reds 1, reverting retryClassificationText to the top-level message reds 2, reverting retry.ts's hand-off reds the wiring case, reverting destroy-runner.ts's reds both of its arms, dropping either markRedactedCause stamp 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-Error link on depth === 0 reds 1, dropping the $fault / $response arms reds one case each, and dropping the empty-name fallback 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: reverting retry.ts's hand-off initially reddened NOTHING, because the classifier cases pinned the functions rather than the call site (a withRetry wiring case was added); and the 'Too Many Requests' destroy-runner case first passed under its own revert, because its fixture carried a 429 on $metadata and was therefore satisfied by isThrottlingError rather than by the arm it claimed to pin -- the cause now carries no $metadata and a non-throttle name, so the withheld message is the only surviving signal. A third, the redacted gate on the asset-storage debug line, 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 a throw, 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.ts is now at 0. src/provisioning/providers/s3-bucket-policy-provider.ts's three Failed to {create,update,delete} S3 bucket policy wraps 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.

  • CreateBucket now 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.create decided whether to attach a CreateBucketConfiguration with a case-SENSITIVE region !== 'us-east-1' over the value getRegion() reports, so both halves of the decision were wrong on an unfolded region. The issue reported the GATE: a mis-cased US-EAST-1 took the branch and sent a LocationConstraint for 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-1 interpolated the raw spelling too, so it sent LocationConstraint: 'EU-WEST-1'. Neither is a valid region NAME, so real S3 rejects the CreateBucket outright 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 by foldRegionOption at src/cli/commands/deploy.ts:182 before the client bag is built and folded AGAIN by AwsClients' constructor at src/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 shape src/cli/region-options.ts:142 already documents) -- so the SDK's own chain answers from the selected profile's region = line, and nothing on that chain folds. Measured against this repo's @aws-sdk/client-s3 (3.1018.0), a profile holding region = US-EAST-1 gives config.region() === 'US-EAST-1', which is exactly what getRegion() reads. So the population is a mis-cased ~/.aws/config on a command that names no region, plus library callers constructing AwsClients with no region at all -- not users passing --region. AWS_DEFAULT_REGION is NOT a second door despite namedCliRegion declining to read it: measured, the JS SDK v3 region chain does not read it either (AWS_DEFAULT_REGION=EU-WEST-1 resolved to the ambient profile's us-east-1, identical to the no-env baseline), so a mis-cased value there reaches nothing. The as BucketLocationConstraint cast 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, excludes us-east-1 by design, and ALSO excludes the real regions ca-west-1, mx-central-1, ap-east-2 and eusc-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: createParams is a create()-local literal built at one site, mutated twice and sent once (grep -n createParams over the provider gives exactly four lines), LocationConstraint is not a CFn property of AWS::S3::Bucket so it never enters state, effectiveProperties, drift or a readback, and the only test in the tree asserting an uppercase LocationConstraint (tests/unit/assets/asset-storage.test.ts) pins the SIBLING defect in src/assets/asset-storage.ts rather than this provider. region is 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: removing canonicalizeRegion from 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 at CreateBucket before reaching the steps it exercises; the caveat is now retired and replaced by an assertion that the raw US-EAST-1 create carries NO CreateBucketConfiguration, so the premise is pinned rather than asserted in prose. Nine unit cases, every one asserting the input the stubbed CreateBucketCommand ACTUALLY 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 no CreateBucketConfiguration, three eu-west-1 spellings that must all send the canonical eu-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 pinning getRegion()'s || 'us-east-1' fallback, which a reviewer found completely unfenced and which was re-measured here: with return region || 'us-east-1' replaced by a bare return region, the WHOLE of tests/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 shipped LocationConstraint: '' undetected. That last pair fences a CONTRACT rather than a live path, and the file says so: @smithy/config-resolver's checkRegion throws rather than resolving empty, so no real SDK client reaches the fallback today. The mocked config.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 *Once responses at all: the us-east-1 rows issue a pre-flight GetBucketLocation the 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, dropping getRegion()'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 raw region !== 'us-east-1' PATTERN appears at src/assets/asset-storage.ts:736, src/cli/commands/bootstrap.ts:272 and src/cli/commands/state-migrate.ts:309, but only asset-storage can be handed a raw value: bootstrap.ts:392 passes it rawRegion ?? region deliberately, 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 own region is effective.region from resolveEffectiveRegion, which canonicalizes on every branch INCLUDING the profile one (region-options.ts:195), and its comment at :82-87 names this exact defect class as the reason it scoped the raw spelling to that one argument; state-migrate.ts's comes from namedCliRegion (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::Sub over 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 in tests/unit/cli/import.test.ts. #2270 stopped resolveSub laundering a structural failure into literal text, but its predicate asked only about RESOURCES; a placeholder naming a template Parameter that is DECLARED, carries no Default, 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.resolveParameters has always raised Parameter <name> is required but no value was provided and no default exists for exactly this population, and deploy-engine.ts calls it unguarded at its step 2.5 -- ahead of every resolver context the deploy builds. So a plain cdkd deploy never reaches resolveSub with 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 a Default-carrying parameter by "those deploys succeed today, and refusing them would be a hard-failure regression". resolveParameters merges every Default it sees, so no deploy reaches the resolver with a Default-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-cloudformation is 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 NOT bestEffort. Every Fn::Sub over a template parameter then kept its placeholder, and the resulting ${Stage} was PERSISTED into the imported resource's properties in state.json -- which is the desired bag a subsequent cdkd deploy diffs against and the bag cdkd destroy hands 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 under template.Parameters, no Default key, and no bound value -- is now consulted by BOTH resolveParameters's required-parameter throw (hoisted above the branches that used to define the population implicitly) and by the renamed subPlaceholderNamesADeclaredTemplateEntity, which is what rethrowStructuralSubFailure calls. Writing a second spelling was the alternative and it disagrees at a real edge: a key PRESENT in the bag with an undefined VALUE is not a binding -- resolveParameters falls such a key through to the Default check -- so the obvious !(name in bound) paraphrase would have made the two sites answer differently for it. That edge has its own case. A Default-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 its markNonRetryable -- 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" plus cdkd state orphan. Both are useless for an unbound parameter, and cdkd import has 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 SAME isUnboundTemplateParameter rather 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 declares Stage under Parameters while Resources holds only an unrelated Bucket, 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 with expected 'topic-${Stage}' to deeply equal { 'Fn::Sub': 'topic-${Stage}' }, which is the literal that used to be persisted; removing the Default exclusion 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 the undefined-value edge reds two, also one per site; removing the upfront resolveParameters throw reds the two agreement cases; dropping the bestEffort early 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 BEFORE resolveSub'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 in resolveRef and 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 the undefined edge respectively. A related pre-existing behaviour is deliberately NOT changed: for the BARE ${Stage}, resolveRef tests logicalId in context.parameters and so returns the undefined VALUE rather than throwing, and the placeholder becomes the literal text undefined. That is resolveRef's own contract, on a hot path, and out of scope here.

  • A Cloud-Control-routed AWS::S3::Bucket delete 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 in S3BucketProvider, on the SDK route. A bucket recorded as provisionedBy: 'cc-api' never reaches that provider -- ProviderRegistry.getProviderFor step 2 (the sticky rule) hands it to CloudControlProvider BEFORE the SDK provider is consulted -- and that provider's only region check is the assertRegionMatch on its NotFound branch, 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 its DeleteResource land on the live bucket in the other region, unrecoverably. delete() now issues one GetBucketLocation before every mutating step -- ahead of the --remove-protection flips 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-only getProvider call sites (destroy / drift / state-refresh)"; all three of those in fact use getProviderFor WITH provisionedBy (destroy-runner.ts:1284, drift.ts:2122 / :3930, state.ts:2340 / :2394), and drift / state-refresh never call delete() at all -- the real getProvider( sites are import.ts, deploy-engine.ts and canonicalize-properties.ts. The genuine population is (a) destroy-runner.ts:1336, which spreads expectedRegion only when state.region !== undefined, so a PRE-v2 record (where region was not yet part of the key layout) arrives with none, and (b) any caller threading an EMPTY region string -- deploy-engine.ts types its stackRegion as string, 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 neither null nor undefined, 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 a delete(), the destroy path's own loop at destroy-runner.ts:1328 (FOUR attempts -- attempt runs 0..maxAttempts and maxAttempts is 3 at :1326 -- with the marker gating both retryable arms at :1372) and the deploy engine's withRetry (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 denying s3:GetBucketLocation -- settable by anyone holding s3:PutBucketPolicy on 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 the try), 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 existing NotFound / assertRegionMatch handling, 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 omits ExpectedBucketOwner, unlike state.ts and utils/aws-region-resolver.ts which 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::Bucket plus TWO control types (AWS::SQS::Queue, AWS::DynamoDB::Table) are driven through delete() end to end and must issue DeleteResource with 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, '', and null -- because AWS's own API documentation calls a us-east-1 bucket's constraint null (verbatim in @aws-sdk/client-s3's models_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, at models_0.d.ts:6810, is GetBucketLocationOutput.LocationConstraint?: BucketLocationConstraint | undefined. null is 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 is CreateBucketConfiguration -- the CreateBucket INPUT -- and placed it "above" rather than below.) A fixture that can only express null shares 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-1 must not read as the legacy EU alias). 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... for cloud-control-provider.ts and ef6f2822692d... 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 on removeProtection !== true 3, downgrading the indeterminate warn to debug 2, treating a null constraint as the only absent spelling 2, and 1 each for: moving the guard BELOW all three --remove-protection blocks, making the injected protection entry inert, dropping markNonRetryable, collapsing absent into indeterminate, matching absence by message instead of wire code, dropping the EU fold, overfolding eu by prefix, dropping the region canonicalization, dropping the empty-region normalization, hoisting the client-region await out of its try, 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-protection flips 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::Bucket has no cc-protection-properties.ts entry 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 a ccProtectionProperty entry for the bucket type (test-side only, no production routing changed) so the delete has a real UpdateResourceCommand to 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-lifecycle phase 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 answers OperationAborted for 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 runs s3-lifecycle -- and changelog-ledger-citation.test.ts correctly 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 against docs/_generated/integ-last-run.tsv's current s3-lifecycle row 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-wide state.json count 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 said ResourceProvider.update "carries no DeleteContext, 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-800 DOES give update() a sixth context?: UpdateContext parameter (this provider's own update merely does not declare it); what is missing is the FIELD, since UpdateContext (resource.ts:502, which also extends SecretMaskingContext) carries no region field at all while expectedRegion is declared on DeleteContext alone (region-check.ts:27). The remedy is therefore one optional field plus threading it from callers that already hold state.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. parseLayerVersionArn matched /^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 in aws-iso, aws-iso-b, aws-iso-e, aws-iso-f or aws-eusc did not parse -- and the caller HARD-THROWS on an unparsed ARN, so cdkd local invoke / local start-api on 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 in state-file-keys.ts, which rejects the European Sovereign Cloud partition's four-letter eusc-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 rejects eusc-de-east-1, and fixing the region shape alone still rejects arn:aws-iso:lambda:us-iso-east-1:.... The partition is now DERIVED from the region through derivePartitionAndUrlSuffix (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, because derivePartitionAndUrlSuffix answers aws for a region it does not recognise: a charset would make arn:aws:lambda:notaregion:... parse. It is still much wider than the set of real regions -- garbage-junk-1 matches where the old pattern refused it (measured) -- and that is deliberate, since the segment's POSITION inside an arn: 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 NOT isClientSafeRegion from src/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 hardcoded aws before 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 at lambda:GetLayerVersion -- aws-cn and aws-us-gov included, 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 own derivePartitionAndUrlSuffix knows 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 with cdkd 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, function where layer belongs) whereas a mismatch looks ordinary. The sentence has TWO arms, because derivePartitionAndUrlSuffix answers aws both for a genuinely commercial region and for one no PARTITION_TABLE prefix 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 that arn: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_TABLE holds seven rows and none of them is aws -- the commercial partition is the fallback return, not a row -- so the MISS arm is the arm every genuinely commercial region takes, and the HIT arm can never render aws at all. Its first wording, "no partition prefix cdkd knows matches that region", was true of the mechanism and read as cdkd failing to recognise us-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 from origin/main rather 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 from split(':') / split('-') plus a hand-written prefix table (pinned against PARTITION_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 one eusc- 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 plus eusc-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 exported classifyLayerVersionArn now 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 old parseLayerVersionArn export 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 no src/ caller left, and scripts/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 the T | undefined shape its cases were written against became a three-line adapter inside tests/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. The local-invoke-layers integ fixture gains a MismatchedArnLayerHandler carrying arn:aws-cn:lambda:us-east-1:... and a fourth verify.sh test 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 SHIPPED dist/ bundle, where a broken src/local -> src/utils import would show up. It is discriminating rather than decorative: pre-#2143 that ARN parsed and cdkd went on to attempt a lambda:GetLayerVersion download, 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.md is 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 the local invoke and the local start-api flag 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-wide const junk = { maskLeaf: (t) => t } bought both maskDeep(p, junk.maskLeaf) and junk.maskLeaf(p) a masked verdict; 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 own maskSecrets on any receiver (that IS the bound, and stays self-probed), a DERIVED name only off this or a chain rooted at it — and the bound is restated in both artifacts. Free on the real tree, whose only property-access maskers are this.maskErrorMessage (18) and this.maskedRetryLogger (5). (2) FILE-WIDE IDENTITIES. identities was one name pool, so a class whose create() binds const mask = maskerOrIdentity(context?.maskSecrets) and whose delete() binds const mask = maskerOrIdentity(undefined) had BOTH arms read raw — a false POSITIVE on the correct sibling, dodged in #2265 only by spelling the delete arm DELETE_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, since names stays a file-wide pool, so maskersAt subtracts 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 bare logger.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 required new, so the 33 messages this corpus throws through this.wrapError(...) (24) and this.wrapUpdateError(...) (9) sat outside the count while the prose called the zero measured, and throw this.wrapError(JSON.stringify(properties)) ran at exit 0. Two receivers were missed the same way — options.warn(...) and getLogger().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: 331 JSON.stringify calls 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, 10 returns, 6 arrow bodies, one ??, one =, one Message: JSON.stringify(request) on an SNS PublishCommand at custom-resource-provider.ts:2182, one createHash('sha256').update(...) at cloudwatch-anomaly-detector-provider.ts:421 and one Buffer.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 by isMasked, 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 added truncatedFiles. Two more escapes were closed — a cast argument (logger.warn(JSON.stringify(v) as string)) counted zero because the parent walk stripped parentheses only while unwrap strips casts everywhere else, and wrapDeleteError escaped the NAME list, now matched by a ^wrap[A-Za-z]*Error$ pattern whose durable half is an ENUMERATING test over every throw <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, so maskDeep(v, undefined) stays refused); maskDeep(v, flag ? a : b) classified raw for want of a ConditionalExpression arm (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 string EXEMPT is keyed on; and the dead report?.filesScanned is 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) made maskLeaf a masker on BOTH sides of the change, because wrapsFirstParameter read 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.ts runs the frozen origin/main classifier (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 the ts.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 (the Buffer.from refuse case rejects on the METHOD name and never reaches it) and the wrapsFirstParameter copy of the trailing-option guard unfenced (relaxing it left all 92 tests green while making const maskLeaf = (v) => maskDeep(v, undefined) a masker — a wrapper laundering a no-op). --json stdout is now pure data: the human summary moves to stderr under that flag, matching the sibling critic check-local-reachability.ts, so --json | jq no longer fails on trailing prose.
  • Two nested-stack Parameters resolving to ONE plaintext now keep DISTINCT {{resolve:...}} expressions across the parent -> child handoff (issue #2291) -- src/deployment/secret-redaction.ts and src/deployment/deploy-engine.ts, plus tests/unit/deployment/secret-redaction-nested-parameter-source.test.ts, tests/unit/deployment/deploy-engine-nested-stack-shared-parameter-plaintext.test.ts and a new arm on tests/integration/nested-stack-secret/. A parent stack resolves its child's Parameters block, 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's RecordedSecretValues bag 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 -- and resolveReplayProps re-resolves what is persisted, so cdkd 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 an AWS::CloudFormation::Stack row) -- 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 (redactParametersForDiff now 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: crossStackSourceKey refuses a non-dotted Fn::Sub placeholder, so Fn::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.recordInheritedParameterSecrets now 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 against main (f56c2cf9) and against this branch: on main both 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 whichever Ref resolved 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, since perResourceSecrets is 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 its Fn::Sub leaf 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 a cdkd drift --revert inside that window pushed the WRONG secret version to the live child resource. They now record from the JOURNAL (the uncollapsed side) under STATE_DERIVED_RULES, and only from the DESIRED side, which is the generation NestedStackProvider forwards as the child's Parameters. (c) A fourth refusal was added after a probe showed the recorder could not tell a CERTIFIED leaf from a value-scan FALLBACK: an ssm reference whose SecureString verdict is unpinned takes the public-reference branch under TEMPLATE_DERIVED_RULES and 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 Data never reaches --verbose output (issue #2250) -- src/provisioning/providers/custom-resource-provider.ts, plus tests/unit/provisioning/custom-resource-provider-response-body-log.test.ts. Each poll of the pre-signed S3 response key used to log body.substring(0, 200) of the CloudFormation custom-resource response document. Data is the documented place a handler returns a GENERATED VALUE -- including a generated secret -- so behind a short PhysicalResourceId those 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 to state.json, so not a new channel) and Object.keys(Data) -- never a Data value, and never Reason, which is free-form handler text that can quote them. Every one of those fields is HANDLER-CONTROLLED, so each goes through displaySafe (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 where substring(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 forged ERROR [cdkd] line into a CI transcript. Dropping the substring removed the bound as well: a 5000-character id with 300 Data keys 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 / null gets 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 to state.json; the second half is FALSE and was relayed here without being re-derived. cfnResponse.Data becomes attributes (custom-resource-provider.ts:1128 / :1208) and is persisted into ResourceState.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 --verbose channel, not the exposure as a whole. gc's placeholder sweep really is metadata-only. The four new cases assert the RENDERED line (captured at the console.debug boundary of a REAL ConsoleLogger at debug level, not a vi.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 --json now puts the payload and nothing else on stdout, so --accept / --revert output parses (issue #2230) -- src/utils/logger.ts, src/cli/commands/drift.ts, docs/cli-reference.md, plus tests/unit/cli/drift-json-stream.test.ts and tests/unit/utils/logger-stdout-reservation.test.ts. logger.info / logger.debug resolve to console.info / console.debug, which write to STDOUT -- the stream writeJsonReport prints 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 --accept over a stack with one unreadable resource emitted 692 bytes on stdout and JSON.parse rejected it with Unexpected 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 in logger.ts) routes info / debug to console.error for the rest of the process; drift.ts calls it once when options.json, BEFORE applyRoleArnIfSet. It is module-level rather than an instance field because child() hands out fresh ChildLogger instances, and the lines a per-call-site fix in drift.ts structurally CANNOT reach live in exactly those: applyRoleArnIfSet's Assumed role ... (src/utils/role-arn.ts:286, unconditional on any --role-arn run) and S3StateBackend.saveState's legacy-migration notice (src/state/s3-state-backend.ts:448, reachable on --accept over a pre-v2 record). The plan printers and the confirmation prompt call process.stdout.write / readline directly, which no logger flag reaches, so they take a per-call-site HumanTextSink instead -- 13 process.stdout.write calls in printAcceptPlan / printRevertPlan plus confirmPrompt's readline output. The lines are MOVED, never suppressed: an operator still sees the plan, the prompt, the --dry-run notice, No drift detected -- nothing to accept., the Comparison INCOMPLETE block (issue #2208), State updated and Revert summary, and --verbose debug 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 only drift opts in, so no other command's output contract moves -- cdkd diff already solved its own instance differently (diff.ts:89-101 demotes the logger to warn under --json, which SUPPRESSES rather than moves). The new drift suite deliberately does NOT mock the logger, unlike the four sibling drift suites: a vi.fn() standing in for getLogger() answers the stream question by construction, which is why drift.test.ts's two existing --json cases pass over the defect. Vitest also intercepts console, so its output reaches neither process.stdout.write nor process.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 own SyntaxError) while both no---json controls 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 --json commands 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 (--verbose un-gates helper debug onto stdout, and role-arn.ts:286 fires unconditionally), cdkd list (worst -- src/synthesis/app-executor.ts:165 re-emits the CDK app's stderr at info level on a DEFAULT run), and cdkd events (--json -v only). cdkd diff is clean.

  • cdkd destroy / cdkd state destroy no 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 in tests/unit/cli/destroy-runner-sigint.test.ts. The per-stack prompt was a bare await 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 of state.ts's (the state rm prompt and confirmRefresh), orphan.ts, import.ts, export.ts, drift.ts, retire-cfn-stack.ts and state-migrate.ts -- NINE sites -- all still take a bare rl.question with no guard, and this entry does not close that class. The shared confirm-prompt.ts is deliberately NOT among them despite being a bare prompt: its only caller (deploy.ts) short-circuits on !process.stdin.isTTY before reaching it, so it cannot hang on EOF today -- the same reason events.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.question never 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 --all and cdkd state destroy <stack> without --yes / --force therefore 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-version pins (and 24.19 before it) against real node:readline/promises: echo y | resolves "y", while printf 'y' | (a real answer with no trailing newline) and < /dev/null both 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 BEFORE createInterface so there is no window a never-settling question can be awaited in. Two decisions were settled rather than assumed. REFUSE, not auto-confirm: deploy.ts takes 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.ts and bootstrap-destroy.ts throw CdkdError with NON_INTERACTIVE_CONFIRM, while the other three throw a bare Error or a LocalMigrateError -- 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 / --force short-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 --yes case AND all seven pre-existing SIGINT cases (they run skipConfirmation: true on vitest's non-TTY stdin), which is the same CI population. Both entry points reach this one prompt (destroy.ts and state.ts each call runDestroyForStack), so fencing the runner covers both; nested-stack children are unaffected, since NestedStackProvider.delete recurses with skipConfirmation: true -- the parent already confirmed the cascade -- and state destroy --all passes 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 answering n still cancels and deletes nothing, and a --yes / --force run 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 MyStack succeeded before (piped stdin does settle rl.question when the input ends in a newline) and now exits 1 with NON_INTERACTIVE_CONFIRM (CdkdError carries no exitCode, so handleError maps 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 / --force on cdkd destroy) is the supported non-interactive path -- the same trade cdkd gc and cdkd state destroy --all each 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 new tests/unit/deployment/secret-redaction-anchor-pairing.test.ts, three flipped assertions and two new residual proofs in tests/unit/cli/state-refresh-observed.test.ts, and one flipped bound plus two repaired fixtures in tests/unit/deployment/secret-redaction-array-identity.test.ts. On the readback paths (cdkd state refresh-observed, cdkd drift's baselines, and a plain cdkd deploy through drainObservedCaptures) the secrets map is EMPTY by construction, so the value scan has no needle and POSITION is the only mechanism left. Where identityKeyFor found no identity field there was no position either, and an array-nested secret reached state.json and the drift baseline holding the DECRYPTED value -- ['--pw', '{{resolve:...}}'] and [{Field, Val: '{{resolve:...}}'}], both pinned as residual LEAK rows. refuseUncertifiedReadbackPositions now 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 the descendArrays: false order 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 a Name field AWS never reported, both of which cdkd drift --revert pushes 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. identityKeyFor still 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_KEYS used a single-element fixture that the anchor arm pairs, so it produced the same output whether or not Value sat in the list -- it now uses a two-element reordered array where a widened ARRAY_IDENTITY_KEYS mis-assigns and anchors refuse, which discriminates the rule it names. ARRAY_IDENTITY_KEYS itself 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.Users is that shape in the field -- no Name/Key, both Username and Password rendered through secretValueFromJson, and Groups: ['admin'] equal on every element -- so a DescribeBroker returning the users reordered recorded the admin credential at the app user's position, which cdkd drift --revert pushes 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 whose Groups were ['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. And deepEqualJsonValue equated any Date with {} and with any other Date, since isPlainObject admits class instances and Object.keys(new Date()) is [] -- reachable rather than theoretical, because an AWS SDK v3 readback reaching drainObservedCaptures is pre-JSON and carries LastModified / CreationDate; it now requires a plain prototype on both sides, so a Date anchor 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, so identityKeyFor answers 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 rides tests/integration/secrets-array-nested, behind CDKD_INTEG_ANCHOR_ARM=1 so the OFF polarity synthesizes byte-for-byte what that fixture shipped before: the existing rows all ride Environment[], which carries Name, so identityKeyFor answers and the anchor gate is never consulted -- the new code had no live coverage at all. An anchorprobe container adds two UNKEYED arrays (lists of plain strings, no Name / Key on 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 -- while EntryPoint: ['-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 answers true on this same fixture's Environment[], so it is not vacuous.

  • An Fn::Sub over 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, plus tests/unit/deployment/intrinsic-sub-nested-stack-outputs.test.ts (new, 17 cases), updates to tests/unit/deployment/intrinsic-split-list-value.test.ts and tests/unit/deployment/secret-redaction-cross-stack-source.test.ts, and a live arm on tests/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 -- threw Invalid Fn::GetAtt format. It now splits on the FIRST dot, which is the same split src/analyzer/template-parser.ts already applied to the identical Fn::Sub placeholder 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, so MyResource, .Attr and MyResource. are rejected exactly as the arity test rejected them. Attribute names containing dots are not special-cased to Outputs. -- Cluster.Endpoint.Address takes 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-raising IntrinsicResolutionRefusalError, but Invalid Fn::GetAtt format and Resource X not found for Fn::GetAtt are plain Errors, so the class test could not see them -- and in Fn::GetAtt position that throw was loud while inside Fn::Sub it 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 in context.resources OR in the template's Resources block, 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 a context.resources-only test would have left the reported shape unfenced. The ORIGINAL error is re-thrown UNCHANGED, neither wrapped nor re-worded, because retryable-errors.ts classifies by message SUBSTRING and markNonRetryable rides 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, a Ref to a resource in state, the 2-arg variable map (which short-circuits above the refusal), and every bestEffort caller. bestEffort is EXEMPT by name: it marks the diff / cdkd scrub paths 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 routine cdkd scrub case (it takes no --parameters)", but scrub.ts's resolverContext factory sets bestEffort: true in the same object literal that binds template and resources, 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 a template.Parameters arm would newly refuse is a placeholder naming a DECLARED parameter with no bound value, and that includes one carrying a Default the caller never merged into context.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 in tests/unit/deployment/intrinsic-sub-nested-stack-outputs.test.ts, two in tests/unit/deployment/secret-redaction-cross-stack-source.test.ts, and intrinsic-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's renders a dotted ATTRIBUTE whole case had PINNED the old unreachability with a note saying it would red the day nested-path Fn::GetAtt landed -- 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 through Fn::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: crossStackSourceKey in src/deployment/secret-redaction.ts had RE-SPELLED the resolver's arity rule (parts.length !== 2, under a comment reading "the string form only at exactly two dot-separated segments, resolveGetAtt throws 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 exporting Cur and Prev of one rotating secret has both outputs resolve EQUAL during the AWSPENDING window, so both parent properties persist the survivor's expression and resolveReplayProps applies the wrong stage on a rollback or a cdkd 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 in secret-redaction.ts because 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 too drives 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 the not.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 being undefined cannot 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 -- crossStackSourceKey refused every Fn::Sub, and intrinsicSkeletonPattern cannot 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, a CURRENT leaf redacted to the SIBLING's :AWSPREVIOUS expression, which resolveReplayProps then applies to the LIVE resource on rollback / cdkd drift --revert -- issue #2059's exact failure. crossStackSourceKey gains an Fn::Sub arm that normalizes a bare-string template of EXACTLY ONE non-escaped placeholder to the Fn::GetAtt key via splitGetAttStringForm. Keying it as Fn::Sub would have been useless: the WRITER is resolveGetAtt, which resolveSub calls with the bare placeholder text and which never sees the Fn::Sub wrapper, 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 format interpolate a template-controlled logical id, and isRetryableTransientError matches by BARE SUBSTRING over patterns including Throttling / SlowDown / DependencyViolation, so a construct named Throttling made a deterministic refusal read as transient -- and escaping Fn::Sub's catch is exactly what put it inside the parent's withRetry via NestedStackProvider.create. All three are now markNonRetryable AT THE THROW SITE, which needs no rewording (the re-throw must hand back the error object untouched) and leaves a transient SDK error surfacing from reresolveCrossStackValue retryable. (3) A missing nested-stack output shipped a synthetic ARN: constructAttribute has no AWS::CloudFormation::Stack case, so it fell to guardedPhysicalIdFallback, whose ARN-shape test is !physicalId.startsWith('arn:') -- and a nested stack's placeholder id DOES start with arn:, so the #1103 guard passed and arn:cdkd-local:... shipped into a free-text property. A child's outputs are known exactly, so an absent Outputs. attribute now refuses, naming the outputs that DO exist. (4) The string form drew no DAG edge: template-parser.ts and dag-builder.ts both required Array.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 from addCustomResourcePolicyEdges, not from extractDependencies, so the buildGraph case does not exercise it and neutering it alone reddened nothing until its own case existed. Also closed: the context.resources arm of the structural predicate was UNFENCED (every refusal case used an empty resources, so only the template arm ever ran, and neutering it reddened 0 of 1678 cases in that directory) -- a case with an EMPTY Resources and a populated resources now drives it alone. The integ arm gains the half it could not see: SubSecretPair reads two expressions of ONE secret that resolve EQUAL (:shared:: versus :shared:AWSCURRENT:, since an empty version-stage defaults to AWSCURRENT), each through its own single-placeholder Fn::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's Value and its Description): perResourceSecrets is 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 sharing stage: the first cut fed a second spelling of stage down through the child's Parameters, which made the pre-existing StageParam leaf 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's inheritedSecrets bag is Map<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 as Parameters: 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-ARG Fn::Sub FORM 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, so resolveSub falls through its variable map to the same-stack lookup, reaches resolveGetAtt and IS keyed by the writer -- while the reader refused the whole form on typeof 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 when Array.isArray(raw) && raw.length === 2 && isPlainObject(raw[1]) AND the placeholder name is NOT an own key of the variable map. The hasOwn half is not optional: a BOUND variable wins in resolveSub and therefore never reaches resolveGetAtt, 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 because resolveReplayProps re-resolves the persisted expression and cdkd drift --revert PUSHES that baseline to AWS. A malformed 2-arg form (wrong arity, non-object second element) stays refused, matching resolveSub, which throws on it. The NESTED_STACK_RESOURCE_TYPE conjunct of the new refusal was unfenced -- deleting it reddened 0 of 1689 -- so an Outputs.-prefixed attribute on ANY type would have hard-failed, reachable through a Custom:: resource whose Data carries 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 separate SHARED_PW_VALUE != SECRET_STAGE_VALUE assertion 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. Finally src/utils/error-handler.ts is 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-placeholder Fn::Sub (pre-${Child.Outputs.Cur}-post) keep the same positioning gap, architecturally out of reach here because recordCrossStackExpression is whole-token only, and Fn::Join carries the identical residual on main today. No plaintext leaks in that case -- the value scan still redacts.

  • A COMPLETED destroy no longer reports itself unfinished, and cdkd state destroy --all no 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 terminal if (runInterrupted()) is a LIVE read, so a signal landing in the TAIL window -- after the last (or only) stack returned cleanly, during eventRecorder.finalize / purgeEventsAfterDestroy, both real S3 round-trips -- threw Destroy 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-sampled result.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: a stoppedEarly flag set ONLY where a break genuinely 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 against stateRefs rather than counting INDEXES, because under --stack-region a later name whose only record is in another region is warn-and-skipped and was never going to be destroyed; destroy.ts needs no counterpart, its candidateStacks already being pre-filtered by the set of names that have state. The terminal read is interrupted || (interruptWatch.interrupted() && stoppedEarly), and the loop breaks KEEP the live read -- that question is still "has the user asked to stop". PER-STACK, "did THIS stack finish?", is DestroyRunnerResult.interrupted's, and it had the SAME defect one frame inward: destroy-runner.ts's outer finally re-synced result.interrupted ||= draining AFTER deleteState, so a signal during renderer.stop() / the saveChain flush / the real deleteState round-trip / releaseLock flipped 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 measured exitCalls=[[2]] plus that same false sentence on a single-stack --all --yes run. That re-sync is now gated on statePreserved, published from the preserveState decision BEFORE the branch acts on it, so the fenced invariant is: a stack whose state was deleted never reports interrupted. ROUND 3 made that sentence TRUE, and it was not before: a THIRD write to the same flag sat in the resourceCount === 0 early-return branch (result.interrupted ||= emptyInterrupted), ~950 lines ABOVE the gate and outside the try/finally that carries it, so the identical symptom survived at the one branch that ALWAYS deletes the state record -- exit 2 with State preserved -- re-run 'cdkd destroy' to finish over a stack whose state file was gone, plus a skipped --purge-events for 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 --all run -- is served by the command-scoped watchCommandInterrupt both 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.ts replaces the human enumeration with a mechanical one: it derives the constructing files from the source, matches <identifier>.interrupted followed 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.ts hands purgeEventsAfterDestroy that 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 reading interrupted alone missed: a user answering n at the per-stack prompt makes the runner return {cancelled: true, deletedCount: 0}, and with errorCount and skippedCount both 0 the run maps to SUCCEEDED with interrupted false -- so cdkd destroy --purge-events DELETED the event history of a stack that still exists (probed: two declined stacks, pruneRuns called twice). The gate now reads stackInterrupted || stackCancelled, fenced in both directions. (2) state.ts's --all batch prompt await 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 or kill HUNG FOREVER where it previously exited 130. At a TTY readline intercepts ^C itself, so the reachable population is the piped / non-interactive shape, i.e. exactly the population issue #1342 exists for. watchCommandInterrupt now holds an AbortController, aborts it in every branch that records a signal, and exposes readonly signal; state.ts passes it to rl.question(prompt, { signal }) and treats the abort as cancel + exit 130. rl.close() runs in a finally for every path that UNWINDS, and explicitly before the process.exit(130) as well, because process.exit is synchronous and never unwinds -- so on the one path this fix adds, the finally does not run at all. The same prompt also gained an EOF path: rl.question never settles when stdin ends (measured still pending after 1500 ms under both < /dev/null and echo -n "" |), so cdkd state destroy --all without --yes hung 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 own close event so EOF read as "N"; ROUND 3 WITHDREW that race in favour of this repo's existing non-interactive convention -- guard on process.stdin.isTTY !== true BEFORE prompting and throw CdkdError(..., 'NON_INTERACTIVE_CONFIRM'), as five sibling prompts already do (gc.ts, bootstrap-destroy.ts, recreate-confirm-prompt.ts, prefix-migration-check.ts, migrate-command.ts all test isTTY before creating the interface) -- though only gc.ts and bootstrap-destroy.ts share the CdkdError + NON_INTERACTIVE_CONFIRM SHAPE this one copies, the other three throwing a bare Error or a LocalMigrateError. The race lost three ways, each measured against real node:readline/promises on Node 24.15.0: printf 'y' | (a real answer with no trailing newline) DECLINED, silently discarding the answer, because rl.line is '' at close; (sleep 0.3; echo y) | DECLINED, a delayed answer simply losing the race; and at a TTY readline consumes ^C itself and calls close(), 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 --yes short-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 --all succeeded before (piped stdin does settle rl.question when the input ends in a newline) and now exits non-zero with NON_INTERACTIVE_CONFIRM. --yes is the supported non-interactive path, the same trade cdkd gc made one entry above. The SIGINT/abort arm from round 2 is correct and is KEPT. destroy.ts owns no prompt; destroy.ts owns no prompt, and destroy-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 --yes on an absent stdin still parks there, the same shape one layer below, and deploy.ts already 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: the state.ts runStack bracket was INERT-able with all 63 cases green -- the exact hole #2117's own round 2 closed for destroy.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 calls forwardSigtermToSigint(), 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 hedged cdkd force-unlock <stack-name> line, because destroy-runner.ts catches a failing releaseLock and 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 --all run 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.ts x2, src/provisioning/interrupt-watch.ts, src/cli/commands/destroy-runner.ts), each now naming the grep -rn "process.on('SIGINT'" src/provisioning/providers/ that regenerates the closed set -- today custom-resource-provider.ts, cloudfront-distribution-provider.ts and acm-certificate-provider.ts, and Route53's provider registers none. isPromptAbortError gains a case driving the REAL rejection out of an aborted readline/promises question (Node 24.15.0: name === 'AbortError' AND code === '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-secret joins the sweeping fixtures, and generateSecretString is 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; no src/ change. (1) #2106 -- the helper is now executed, not just reviewed. tests/integration/s3-versions.sh is sourced by sixteen fixtures and nothing in CI ever RAN it: the only automated coverage was bash -n plus 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/bash against a fake aws on PATH, backed by a local version store that replays real ListObjectVersions shapes and applies --query per 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, the 1000\n189 two-line length() 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 injected SlowDown listing failure (which must FAIL rather than read as zero), the noncurrent / all mode split, delete-marker-latest, exact-key scoping against state.json.bak / .tmp siblings, a None version id, per-object DeleteObjects errors under Quiet: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, the if is therefore false, and the assertion announces OK: 0 surviving object versions while 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 existing integ-s3-versions-helper.test.ts rather 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 spreading process.env, pinning PATH / HOME / TMPDIR / BASH_ENV / ENV / LC_ALL / LANG / TZ / cwd and forwarding no AWS_* at all. Every pin is READ BACK from inside a real child and asserted by value, so deleting any one of them from childEnv fails by name; PATH, the HOME/BASH_ENV/ENV trio and the AWS_* omission additionally carry adversarial probes that plant a decoy aws on the ambient PATH, a hostile .bashrc defining an aws function, and AWS_DEFAULT_OUTPUT=yaml. That by-value layer was added after review measured the earlier claim UNEARNED: deleting HOME, BASH_ENV, ENV, LC_ALL, LANG or TZ was green, only PATH and TMPDIR were load-bearing, and the decoy aws was created but never placed on any PATH. Measured negatives record that the helper shells out to no jq / python3 / date. Its scratch root honours CDKD_TEST_SCRATCH_DIR and prints a receipt naming the directory it used. (2) #2225 -- the purge mode now follows the destroy. local-run-task-from-state/verify.sh ran its destroy as ... | tail -5 || true and then chose the version-purge mode from rc, the SCRIPT's status on entry to cleanup. A run whose assertions all passed and whose destroy then FAILED therefore took the all branch and deleted the current state.json -- the one record a later cdkd state destroy would have worked from, leaving orphan resources unreachable. It now captures destroy_rc=${PIPESTATUS[0]} (not $?: the pipe to tail is what hid the failure, so the wrong status would reproduce the bug inside its own fix) and gates on both. The rule is documented in s3-versions.sh beside the existing mode comment so it stops being per-fixture folklore, and is enforced by a lint scoped to cleanup() -- the other fixtures purge all only on the success path after an unpiped set -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 own cdk 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 a generateSecretString entry in SECRET_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. SecretsManagerSecretProvider mints the value locally from a CSPRNG, hands it to CreateSecret, returns attributes: { Id } only, never issues GetSecretValue, and lists both SecretString and GenerateSecretString in getDriftUnknownPaths(); 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 a verify.sh -- to grow one for a non-problem. The shape is instead recorded in a new EXEMPT_SHAPES list 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-secret resolves {{resolve:ssm:...}} against a SecureString -- the shape that makes cdkd issue a real GetSecretValue on 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 on rc plus 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 .every captured 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 single sawDestroy flag, its marker list gained the unbraced $CDKD / $CLI / $LOCAL_DIST forms (unbraced $CDKD is live in acm-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. The generateSecretString exemption fence moved from watching one assignment spelling to asserting the create/update RETURN SHAPE against an allow-list, because effectiveProperties: { ...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's noncurrent arm was INERT: two unconditional aws s3 rm --recursive calls 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 (the None case also gained the realistic literal "null" version id, which must be deleted rather than skipped); s3_purge_key_versions's noncurrent arm -- 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-import now sweeps the shared exports index cdkd/_index/<region>/exports.json -- a sibling prefix no s3_stack_prefix reaches, holding RESOLVED Output values -- key-scoped and noncurrent only so a concurrent lane's live index survives, from sweep() so it runs on EVERY path rather than only after the trap is disarmed, and ASSERTED via a new s3_assert_key_versions_swept whose mode defaults to noncurrent (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-pasting destroy_rc=$? are reported as a CLOBBER instead of satisfying .every; the purge's guard is the depth-aware ENCLOSING if, so an all purge after a closed if … fi is unguarded rather than adopting that block's condition; the GetSecretValue premise matches the call rather than one constructor spelling; attributes.Id is pinned to the physical-id EXPRESSION rather than to the key name; the aws s3 rm check keys on the state BUCKET rather than a literal cdkd/ path; each falsified-claim record carries its own retired sample; and childEnv'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 over docs/**, .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-1 legacy 200. The partial-create cleanup of issue #376 is gated on createdNewBucket, which was set from a 200 on CreateBucket. That is proof in every region but one: @aws-sdk/client-s3 dist-types/models/errors.d.ts documents on BucketAlreadyOwnedByYou that 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 in us-east-1 an ADOPTED bucket was marked as created, and a later sub-config failure (a malformed lifecycle rule, a policy the account rejects, a throttle) fired DeleteBucketCommand at a PRE-EXISTING user bucket -- precisely the outcome #376's gate exists to prevent. DeleteBucket refuses a non-empty bucket, so a bucket holding objects survived; an EMPTY one did not. It is not an explicit-BucketName edge case: generateResourceName produces {stackName}-{logicalId} with no region or account in it. create() now runs a pre-flight GetBucketLocation ONLY when the target region canonicalizes to us-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 so createdNewBucket stays 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 names aws s3api delete-bucket for 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 the CreateBucket call: CreateBucket is the authoritative OWNERSHIP oracle (a bucket held by another account fails it with BucketAlreadyExists) while GetBucketLocation can 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 the CreateBucket are two calls, so a name that is free when asked can be taken before it is used, and in us-east-1 the legacy 200 then adopts that bucket with createdNewBucket === 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; a HeadBucket retry 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 identical Refusing to adopt existing S3 bucket error 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. assertExistingBucketRegion is 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 whatever physicalId the record named. delete()'s assertRegionMatch fires only from the NoSuchBucket branch, which a cross-region DeleteBucket never 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 a markNonRetryable error 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 with cdkd 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 the DeleteBucket: 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 GetBucketLocation answers -- from any endpoint in the same partition (measured 2026-08-13, recorded in src/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 call s3:GetBucketLocation on 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 as s3:GetBucketLocation on the target bucket, which a bucket POLICY can also Deny -- and a Deny is 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 at warn, not debug: cdkd's default level is info, 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 at debug, 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-Deny path (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 on DeleteContext.expectedRegion being present, which is assertRegionMatch'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 defaulting expectedRegion to the client region in destroy-runner would fabricate an expectation the record never made while also changing assertRegionMatch on the NoSuchBucket path. Cost on a real destroy is therefore one read-only GetBucketLocation per 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) and AuthorizationHeaderMalformed, 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 its x-amz-bucket-region names 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-policy Deny population 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. carriesBucketRegionHeader is 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 same resolveOwnedBucketRegion the #2227 guard already used -- its createError parameter is now optional, because a PRE-FLIGHT caller has no AWS error to read x-amz-bucket-region off and only the GetBucketLocation half applies -- and the two #2245 sites share ONE assertStateBucketRegion, differing only in the operation-specific clause. The empty / null LocationConstraint fold to us-east-1 and the legacy EU fold to eu-west-1 therefore cannot diverge between them.

    Wire shapes are MEASURED, not inferred from a command's declared throw list. An earlier revision of this entry claimed GetBucketLocation does not deserialize its 404 into the modeled NoSuchBucket class "the way DeleteBucket's is", reasoning from GetBucketLocationCommand.d.ts declaring only @throws {@link S3ServiceException}. That is false, measured 2026-08-26 by feeding canned 404 XML through the real client: both operations yield ctor=NoSuchBucket, name=NoSuchBucket, instanceof NoSuchBucket === true, status=404. DeleteBucketCommand.d.ts declares 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 path absent is the answer that ENABLES the cleanup's DeleteBucket, so a 404 from a corporate proxy, an AWS_ENDPOINT_URL override 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 becomes indeterminate, which is every caller's non-destructive branch. An instanceof NoSuchBucket arm was written and then REMOVED: the generated class assigns name = '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::Bucket declares silent-drop properties (AccessControl, which CDK's L1 still emits for accessControl:), so provider-registry.ts auto-routes such a bucket to CloudControlProvider and pins provisionedBy: 'cc-api' stickily -- the type is not in STICKY_CC_MIGRATION_EXEMPT. On that route S3BucketProvider.delete never runs: no probe, no refusal, no warning at all, and CloudControlProvider does only the client-region assertRegionMatch, 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-DeleteBucket line (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 of reason. On the very population these warnings exist for -- a bucket policy denying s3:GetBucketLocation -- S3's text reads User: 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 to debug and the line points at --verbose, the answer dynamodb-index-busy-delete.ts already reached for the same class. Terminal-only, so the issue #2179 redaction surface is not in play. carriesBucketRegionHeader was tightened the same way in the same pass: it matches the wire error CODE alone and no longer accepts a bare httpStatusCode === 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 catch now re-throws a ProvisioningError before anything else, so the classifier keying on createError.name === 'BucketAlreadyOwnedByYou' || message.includes('you already own it') can never swallow the foreign-region refusal raised in the same try into 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 for update().

    Four existing suites had their PREMISE repaired rather than their assertions relaxed, because the us-east-1 mock they shared encoded a combination AWS cannot produce. s3-bucket-provider-already-owned-region.test.ts and s3-bucket-provider-partial-create-cleanup.test.ts now mock eu-west-1: a us-east-1 client receives BucketAlreadyOwnedByYou ONLY for a bucket that lives elsewhere, so three cases pairing that 409 with a us-east-1 client and a us-east-1 readback were unreachable. The ADOPT side of the empty-LocationConstraint fold moved to the new pre-flight suite, which is the reachable us-east-1 path to it; the REFUSING side stays where it was. s3-bucket-provider-container-shape.test.ts answers GetBucketLocation with its own client region rather than the blanket {} (an absent LocationConstraint IS a valid us-east-1 answer, so the blanket mock told the update guard the bucket was elsewhere), and s3-bucket-provider-per-item-string-replay.test.ts answers the pre-flight with a 404 so its creates are genuinely NEW buckets. The one PublicAccessBlockConfiguration: present -> absent issues NO call at all case 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 receive BucketAlreadyOwnedByYou for 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 two GetBucketLocation calls 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-1 case; removing the update guard reddens 5; removing the delete guard reddens 7 while leaving s3-bucket-provider-delete-data-guard.test.ts green (it passes no expectedRegion); making absent or indeterminate refuse reddens exactly the over-tightening cases written for them.

  • cdkd local start-service / start-alb no 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.yaml only; no src/** change. Issue #2189 fixed cdkd's own src/local/ecs-secrets-resolver.ts, and the entry below correctly notes that copy IS live -- but only for cdkd local run-task. src/cli/commands/ecs-service-emulator.ts imports runEcsServiceEmulator from cdk-local/internal, so start-service / start-alb delegate wholly to cdk-local and kept executing the leaking shape: the same catch interpolating JSON.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.6 while the lockfile pinned 0.147.6 and pnpm-workspace.yaml's minimumReleaseAgeExclude pinned cdk-local@0.147.6 exactly, 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 of node_modules/cdk-local/dist/*.js before the bump: 1 hit for but 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's resolveEcsSecrets with 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 answered Container '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. resolveEcsSecrets is not re-exported from cdk-local/internal, so the probe imported the shipped chunk byte-for-byte with one appended export line and nothing else edited. The version delta is exactly this one change: a text diff of the two published tarballs shows identical file sets, identical dependencies / peerDependencies, an unchanged lockfile entry count (1014 both sides, no new transitive package), an identical docker-cmd-*.js chunk, and in the main chunk two changed lines -- the EcsSecretsResolutionError throw and its new const 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, plus tests/unit/cli/gc-custom-resource-responses.test.ts, tests/unit/state/s3-state-backend.test.ts and tests/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 real getResponseKey output found zero misses, and a corpus of 7 colliding --state-prefix values 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) listRawObjects never asserted its Prefix: deleting Prefix: keyPrefix passed 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-prefix collision 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 from git ls-files 'src/**/*.ts', which returns 326 of 328 tracked files -- git wildmatch needs a literal / for the **/ segment, so src/index.ts and src/version.ts, one of them the library ENTRYPOINT, were exempt; planting the literal in src/index.ts left the fence green, and its > 200 floor 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 asserted not.toContain('placeholder(s) from'), a literal that exists only in the ERROR message and never reaches logger.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; listRawObjects dropped entries missing LastModified / Size silently, 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-run case 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's Math.random() returns k * 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 the RESPONSE_PLACEHOLDER_KEY JSDoc so it need not be re-derived. docs/cli-reference.md gains an upgrade note for CI: a non-interactive cdkd gc without -y that used to exit 0 on an account with nothing to collect now hard-errors NON_INTERACTIVE_CONFIRM once placeholders have accumulated, because "zero candidates" now includes them and the pre-prompt early return no longer fires.

  • cdkd bootstrap no longer adopts an asset bucket this account owns in ANOTHER region (issue #2240) -- src/assets/asset-storage.ts, plus tests/unit/assets/asset-storage.test.ts. S3 bucket names are globally unique while a bucket is regional, so BucketAlreadyOwnedByYou (409) and a cross-region HeadBucket redirect both report ACCOUNT ownership, not the bucket's region. (The same conflation in S3BucketProvider.create is issue #2227, still open and NOT on main at 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 BEFORE ExpectedBucketOwner is 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 because getCdkdAssetBucketName embeds 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: the BucketAlreadyOwnedByYou swallow is NOT how the ordinary case arrives, because the HeadBucket pre-check runs first and a cross-region owned bucket makes it fail with the SDK's synthetic Unknown / UnknownError at status 301. So the ordinary case already failed CLOSED -- but via normalizeAwsError, 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; and verifyAssetStorageExists did not even reach that, rethrowing the bare UnknownError with no context at all. The 409 swallow remains reachable through a HeadBucket-then-CreateBucket race, 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 answers 200 only on the us-east-1 legacy endpoint, while ap-northeast-1 and us-west-2 both answer BucketAlreadyOwnedByYou -- 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 one assertAssetBucketRegion guard that refuses with ASSET_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 status 301, or 400 with IllegalLocationConstraintException / a HeadBucket command) and is deliberately wider in two places -- it accepts a 301 with no header at all (the GetBucketLocation fallback resolves that case, and requiring the header there was a regression caught in review) and it drops the SDK's inner conjunct on the 400 arm, because that conjunct decides whether to silently RETRY elsewhere while the only thing done here is refuse. A status-301-only gate silently misses the 400 form; a bare header-present gate would read a 500 carrying a stray region header as a redirect, so both halves are pinned. The region is read from x-amz-bucket-region on the error itself (measured present on both the 301 and the 409 -- no extra call, no extra IAM), falling back to GetBucketLocation with the empty / legacy-EU constraints folded to us-east-1 / eu-west-1; deliberately NOT resolveBucketRegion, which never throws and returns its fallbackRegion on a failed probe and would turn a fail-CLOSED guard fail-OPEN -- an undeterminable region refuses too. --force does not license it: --force means re-apply configuration to the bucket you INTENDED, never write to one in another region. Contrast the STATE bucket, which bootstrap.ts deliberately re-points at the bucket's own region via rebuildClientForBucketRegion because 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 same HeadBucket, so a cross-region asset bucket died on the same misleading 301 wording. It now takes an OPT-IN expectedRegion and 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 with rebuildClientForBucketRegion, so a blanket refusal would turn --include-state-bucket into 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 with ListObjectsV2, whose 301 carries a real XML body, so the SDK raises a genuine PermanentRedirect with AWS's own actionable text rather than the synthetic UnknownError -- and normalizeAwsError passes 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, the verifyAssetStorageExists arm, 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 empty LocationConstraint folding to the TARGET region rather than us-east-1 (fail-OPEN, and the original arm could not see it because its target WAS us-east-1), a same-region redirect being read as "verification passed", the region canonicalization on the comparison's actual side, and the 400 spelling above. All five now go RED.

  • An optional DeploymentCircuitBreaker member 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, plus tests/unit/provisioning/ecs-deployment-configuration-subfield.test.ts, a live arm in tests/integration/ecs-service-update-props/, and the §2a checklist in docs/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.DeploymentConfiguration is forwarded to UpdateService as a recursive PascalCase -> camelCase flip, and for DeploymentCircuitBreaker's two OPTIONAL members the ECS API RETAINS an omitted value: a user who deleted ResetOnHealthyTask from their template kept the stale live false under cdkd deploy while cdk deploy gave them AWS's true, with cdkd diff reporting nothing. Measured us-east-1 2026-08-13 from an identical baseline with Rollback flipped 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 left false / {COUNT, 7} intact where CloudFormation produced true / {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 only MinimumHealthyPercent, 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 new ECSProvider.resolveDeploymentConfiguration reuses the shared clearOnUpdateRemoval helper 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 sibling Rollback reads as REPLACED, so one block holds members with different semantics); NOT the depth-1 siblings LinearConfiguration / CanaryConfiguration / a hook's TimeoutConfiguration, 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 the DeploymentCircuitBreaker block, since removing the whole DeploymentConfiguration resets 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 #1861 arms in ecs-deployment-configuration-subfield.test.ts, derived by listing the file's it( 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's true on every deploy of a template declaring ResetOnHealthyTask: 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 (ECSProvider is a singleton serving concurrent resources, so a by-reference default would be one mutable object across every in-flight UpdateService; the constant is now Object.freezed and spread at the use site). The fourth pins the LIMIT of the presence test rather than a desirable behavior: clearOnUpdateRemoval asks whether the previous BAG carried the member, which equals "the previous TEMPLATE declared it" only on the deploy path -- at drift --revert the previous bag is an AWS READBACK, so a member no template declared reads as a removal. That exposure is shared by every clearOnUpdateRemoval call 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.ts and lambda-function-provider.ts carry 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 used Enable: true, so a reset gated on enable === true passed 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 NAMED ResetOnHealthyTask property does discriminate it, no previous bag reachable from JSON.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 !x polarity slip reds three; handing the shared constant out by reference reds the aliasing arm; removing ONLY the Object.freeze while keeping the copy also reds it, which it did NOT before review, because nothing asserted Object.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 single TypeError, 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 ...desired spread from the rebuild reds the removal arm (that mutation drops every SIBLING top-level member from UpdateService on 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 on enable === true reds 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. The ecs-service-update-props integ 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 is Rollback flipped back on INSIDE the same block, asserts that flip actually applied, and requires the out-of-band false / {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 json plus an awk numeric compare rather than a --output text string compare, so a 7.0 rendering cannot fail a correct run; every read expression was executed against fixed, broken and omitted payloads before shipping. No new trap is registered: the out-of-band edit lands on the service the existing cleanup handler already tears down, and a second trap ... EXIT would 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 (Cognito Policies and 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, and cdkd 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, plus tests/unit/cli/commands/scrub-import-value-secret.test.ts, tests/unit/cli/commands/scrub-export-name-collision.test.ts tests/unit/cli/drift-secret-redaction.test.ts and tests/integration/secrets-dynamic-ref/verify.sh. makeCrossStackPrePass's readOne decides 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 CloudFormation ListExports fallback), the producerTemplates miss (the producer is not a stack of this app), a no verdict (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 healthy carriesDynamicReference(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 a logger.debug naming the reference, the producer and the reason -- with both identifiers MASKED, which the review round made the blocking finding: producer.key is recordedImports.at(-1).exportName, the POST-resolution export name, and resolveImportValue sets it from resolveValue(importValueArg), so an Fn::ImportValue over an Fn::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 why maskSecretsInError at scrubStack's boundary is not sufficient (allRecordedSecrets DROPS every needle below MIN_NEEDLE_LENGTH while the whole-value arm of maskSecretsInText matches at any length); a logger.debug line has no boundary net at all. producer.stack is 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 for Fn::ImportValue, whose producer name comes from the state-bucket listing, and is FALSE for Fn::GetStackOutput -- recordOutputRead(context, stackName, ...) is fed await this.resolveValue(args['StackName'], context), so an Fn::Sub / Fn::Join assembling a {{resolve:...}} into StackName makes 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.region IS the identifier that cannot carry one, and the comment now says why rather than asserting it -- Fn::GetStackOutput gates its region through isClientSafeRegion (/^[a-z0-9][a-z0-9-]{0,30}$/, which no {{resolve:...}} token satisfies) and throws rather than falling back, while Fn::ImportValue reads it from the same state-bucket listing. A dangling {@link} introduced by the same round is fixed too: the symbol is plaintextProducerCrossStackReadError, not crossStackProducerPlaintextError. The mask that was being reasoned about now has a TEST, which it did not before -- measured, replacing loggedStack with producer.stack left every test in tests/unit/cli/commands/ green, so the fence nobody could remove by accident was in fact removable in silence. Two cases pin it: an Fn::GetStackOutput whose StackName is itself a {{resolve:...}} reference, declining through the producerTemplates miss, 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 through maskSecretsInError at scrubStack's boundary on its way out, so with a 28-character name removing the builder's own mask left the suite green; allRecordedSecrets filters to MIN_NEEDLE_LENGTH (4) while the whole-value arm of maskSecretsInText matches 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), and exportAliasCollisionScrubWarning now 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 --accept summary's count also gained an arithmetic fence: replacing acceptedResourceCount++ with = 1 survived 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 the WHERE THE READ IS string is hoisted above the try so all nine sites share one spelling instead of four re-spelled copies. Pinned by an Fn::ImportValue whose 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 the drift side: the --accept summary counted drifted OUTCOMES rather than accepted changes, so a resource whose every change hit acceptRefusalReason still reported accepted drift on N resource(s) while the per-change warnings just above said the opposite -- it now counts resources that actually RECORDED something and says 0 resource(s) accepted explicitly, since the write still happens for the positioned re-redaction. The count is taken AFTER the not-recorded check, not at accepted.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 whose logicalId is absent from state.resources also contributes zero, silently); it says no drifted change was recorded, which is exactly what the count measures; printAcceptPlan announced update cdkd state for <stack> over a body that could be nothing but SKIPPED lines, and now says no 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 NOT NOTHING will be written, which the first cut said and which a --dry-run reader takes to mean state.json is untouched -- the real run over the same input takes the lock, bumps lastModified, 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 --accept into the all-refused state -- one drifted resource whose single change acceptRefusalReason declines -- 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 says no accepted values will be written and names the positioned re-redaction without printing the bare update cdkd state for header, and that the summary says 0 resource(s) accepted beneath a State updated line 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. The scrub debug lines are NOT covered there -- reaching them needs --verbose plus a cross-stack read this fixture does not have, so they stay on the unit suite and belong in tests/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 --accept does; the revert plan's two mirrored if (cannotMaskKeys ...) / if (!cannotMaskKeys ...) blocks became the if / else if its 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 ECS ContainerDefinitions[].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 unique AWS-authored tag(s) will be preserved; the --revert PLAN masks an AWS-authored key case now proves the unbaselined block printed instead of asserting one not.toContain; and the {{resolve:...}}-shaped-substring case pins that the resolve actually happened -- with BOTH a call count and the absence of the could not resolve failure 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 key case asserted a bare toContain(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-shaped Environment.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 -- scrubStack warns only for a name matching a DECLARED output name, and collectDeclaredOutputNames is Object.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 show printing output bag KEYS unstripped) was already fixed and fenced by earlier work, verified here by mutation: dropping stripControlChars from the Outputs key reds state-show.test.ts's STRIPS 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 -- an Fn::If export whose selected branch carries the secret, imported through an Fn::Join -- refuses correctly against the templates aws-cdk-lib actually synthesizes for tests/integration/cross-stack-secret-import/ and a deploy-shaped v9 producer record, which the new tests pin; and the from index lines the issue reads its evidence from cannot come from cdkd scrub, which supplies no exportIndex by design.

  • The pre-stringify secret walk is now a mechanical check, and eighteen raw sites were closed (issue #2178) -- scripts/check-provider-secret-mask.ts plus tests/unit/scripts/provider-secret-mask.test.ts, the audit:provider-secret-mask:check task in vite.config.ts, its ci.yml step, and the masker threading in appsync-provider.ts, budgets-budget-provider.ts, cloudwatch-anomaly-detector-provider.ts, custom-resource-provider.ts, kinesis-provider.ts, lambda-function-provider.ts and sns-topic-provider.ts. .claude/rules/provider-masking.md has required since issue #1932 that a provider interpolating a properties-derived value into a message mask it, and since issue #2176 that the walk run BEFORE JSON.stringify -- masking the finished message cannot recover the value, since JSON.stringify escapes " / \ / newlines so the secret no longer occurs in the line, and a message is always longer than the value inside it so only maskSecretsInText'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 on asg-provider.ts and cloudfront-distribution-provider.ts, which mask UPSTREAM of the stringify. The critic is therefore dataflow-aware, deriving the masker set per file (aliased maskDeep import, SecretMasker / MaskerFn annotations, 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 own context?.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-rolled function, 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 annotated MaskerFn, or a property literally named maskSecrets, 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.js sibling cannot lend its maskDeep the 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 where ImportInput carries 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 owned dynamodb-table-provider.ts, dynamodb-globaltable-provider.ts and s3-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 is custom-resource-provider.ts's payload refusal, SPLIT into a masked create / update arm and an unmasked DELETE arm so the path DeleteContext gives 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 to return true. The collapse-toward-green channel was ITSELF unfenced until review: the entrypoint did failures.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: runSelfProbes now reports how many probes it EVALUATED (counted inside the loop, so a constant cannot satisfy it), main takes an injectable runner so a probe FAILURE can be proven to reach the exit code, a MIN_SELF_PROBES floor refuses a run that evaluated almost none, and the count rides --json as selfProbesRun for a SPAWNED assertion. One independent guard did already cover part of it and is recorded rather than claimed away: auditExemptions reads each site's VERDICT, so an isMasked -> true collapse 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, appsync and lambda-function also gained the CreateContext / UpdateContext parameter 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, plus tests/unit/deployment/retryable-errors.test.ts, a re-pointed discriminator in tests/unit/deployment/deploy-engine-named-replacement-collision.test.ts, and a live arm in tests/integration/custom-resource-provider/. Previously isNameCooldownError matched 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-name CreateStateMachine with StateMachineDeleting: State Machine is being deleted, which matched nothing: a cdkd destroy followed by a prompt cdkd deploy of any stack carrying a custom_resources.Provider waiter 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 exported NAME_COOLDOWN_ERROR_MESSAGE_PATTERNS list and gained both Step Functions forms plus S3's conflicting 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 into RETRYABLE_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-schedule withRetry inside 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.ts passes 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's DuplicateLoadBalancerName and 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.sh phase 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 plus Deployment 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 --verbose and the phase greps for a retry line quoting the AWS cooldown message, printing OK: COVERED or INCONCLUSIVE. Exit 0 alone is a confluence point: a fully-closed window produces it too. Tagged ordinary-create-name-cooldown in the scenario taxonomy, distinct from the existing rollback-reverse-replacement-name-cooldown which covers the sites where cdkd itself just deleted the name holder.

  • A --remove-protection destroy 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 in tests/unit/provisioning/providers/dynamodb-remove-protection-compensate.test.ts. #1978 added a compensating re-enable of DeletionProtectionEnabled when a --remove-protection delete fails terminally, latched per table in ProtectionFlipRegistry. That registry measured an entry's age from FIRST acquisition, so a --resource-timeout overshoot past the 30-minute DELETE_BUDGET_REUSE_WINDOW_MS let a long retry sequence age out its OWN record mid-flight: acquire dropped it, handed back a fresh flippedOffByThisRun: false, and the next attempt's pre-flip DescribeTable saw 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 race assertRegionMatch throws inside delete()'s ResourceNotFound branch BEFORE that branch reaches its protectionFlips.release, so the record is still latched and the compensation runs against a table that is already gone; its UpdateTable then got its own ResourceNotFoundException and 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 at warn with the claim dropped rather than at error with it asserted, and deliberately NOT at debug: DynamoDB returns ResourceNotFoundException from UpdateTable both for a table that is gone AND for one whose status is merely not ACTIVE (its own SDK model says so), and the second is reachable -- a GlobalTable whose waitForReplicaGone times out is still live, still unprotected, and deleteAccepted is 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 a describe-table check 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: acquire runs 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 deploy no longer adopts an already-owned S3 bucket that lives in another region (issue #2227) -- src/provisioning/providers/s3-bucket-provider.ts, plus a new tests/unit/provisioning/s3-bucket-provider-already-owned-region.test.ts (16 cases) and an updated primer in s3-bucket-provider-partial-create-cleanup.test.ts, plus a Phase 0 / Phase 0b arm pair in tests/integration/s3-lifecycle/verify.sh. CreateBucket answers BucketAlreadyOwnedByYou on 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-BucketName edge case: generateResourceName produces {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 the x-amz-bucket-region header on the 409 ITSELF -- no extra API call, no extra IAM -- falling back to GetBucketLocation, folding an empty/null LocationConstraint to us-east-1 and the legacy EU to eu-west-1, comparing through canonicalizeRegion, and refusing with a markNonRetryable error naming both regions and the two remedies. Deliberately NOT HeadBucket: it 301s cross-region and SDK v3 turns the empty-body HEAD response into a synthetic UnknownError (src/utils/aws-region-resolver.ts already 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's resolveBucketRegion either -- 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 literal does not exist, a member of OTHER_TRANSIENT_ERROR_MESSAGE_PATTERNS, which would otherwise have it retried for the full budget. Measured on real AWS: a bucket created in us-west-2 answers BucketAlreadyOwnedByYou to a CreateBucket in eu-west-1, us-east-1 and ap-northeast-1, always carrying x-amz-bucket-region: us-west-2. The same measurements FALSIFY the mid-delete mechanism the issue was filed for: a DeleteBucket followed immediately by a CreateBucket of 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::Bucket is the only affected type, every other hit being either a regional namespace or a global namespace over a global resource, and CloudControlProvider having no adopt arm at all. src/assets/asset-storage.ts:548 carries 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, where us-east-1's legacy CreateBucket 200 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 the BucketAlreadyOwnedByYou catch.

  • cdkd drift --accept / --revert no longer report "No drift detected" over a stack they never compared (issue #2208) -- src/cli/commands/drift.ts and docs/cli-reference.md, plus new cases in tests/unit/cli/drift-per-resource-failure.test.ts and tests/unit/cli/drift-cross-region-secret.test.ts. anyIncomplete was 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 -- printed No drift detected — nothing to accept. and exited 0. Nothing drifted only because nothing was read: the false reassurance #2135 made notCompared a 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 stays 0: the remediation modes' exit codes are a documented contract that #2108 scoped its 2 away from on purpose ("changing them would alter what a remediation run means"), the population is anyone whose CI runs --accept on a stack that can hit a throttle, and --accept / --revert already 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 prints Comparison 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, as 2. 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 is outcomeExitSignal's incomplete, 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: M is the total outcome count and N is everything not compared, so unsupported and skipped land in N with their own phrase rather than silently inflating only the denominator. That makes N WIDER than the report's NOT fully compared heading (which counts the reference / read population and reports drift unknown separately) and matches the question #2154's glyph asks, "was everything actually compared". Counting only the clearable reasons printed 1 of 3 on a stack the report a few lines above called 2 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 exhaustive Record<NotComparedCause | 'unsupported' | 'skipped', { kind; phrase }> (the shape notComparedReason already uses), so a cause added later is a COMPILE ERROR rather than an else branch describing it as a dynamic-reference problem -- which matters here because outcomeExitSignal routes 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 the kind half of that record. The line used to end ..., so cdkd does not know whether they drifted for everything in N, which is true of a throttled read and FALSE of a Custom::* resource: issue #323's position there is that drift is not APPLICABLE, not that cdkd is unsure -- so a stack with one throttle and five Custom::S3AutoDeleteObjects claimed uncertainty about all six. readFailed / refused / unresolvedToken now report under cdkd does not know whether these drifted, and unsupported / skipped under Not 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 a Custom::* 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-0 row 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 / --revert row says so, points CI gates at the detection-only run or at --json's notCompared[].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 "dropping unsupported / skipped from the tally" had only ever exercised the unsupported half while BOTH skipped branches 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 the readFailed phrase 5, blanking the refusal phrase 2 (both in the cross-region suite, the only harness that can produce a refused outcome -- every fixture in the per-resource suite produces readFailed, so that arm would otherwise be unfenced), widening the TRIGGER to the whole notCompared roll-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 the unsupported tally bump 1, blanking the unsupported phrase 1, no-op'ing the skipped tally bump 1, blanking the skipped phrase 1, moving skipped's kind to unknown 1, 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. The skipped arm is fenced by a Custom::S3AutoDeleteObjects member in the five-fates mixed fixture -- via that CDK helper it is the COMMONEST member of N in 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 fourth NotComparedCause leaves vp test run reporting Type Errors no errors with every test passing, and only vp run typecheck reds it (measured: TS2741: Property 'probeCause' is missing ... in type 'Record<UncomparedReason, { kind; phrase }>'). A green test run does not cover it.

  • cdkd gc now 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 (new listRawObjects), src/cli/commands/gc.ts, src/provisioning/providers/custom-resource-provider.ts, docs/cli-reference.md, plus tests/unit/cli/gc-custom-resource-responses.test.ts and tests/unit/state/custom-resource-response-prefix-sync.test.ts. CustomResourceProvider PUTs an empty object at custom-resource-responses/{requestId}.json in the STATE bucket before each invocation so the handler has a pre-signed URL to write to. cdkd gc scanned 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 CloudFormation Data payload 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 own LastModified, 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 in src/state/state-prefix.ts for the layering reason that file already records and re-exported from state-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 --all now stop on a Ctrl-C that lands in a window the per-stack runner does not span (issue #2117) -- src/utils/interrupt-signals.ts (new watchCommandInterrupt), src/cli/commands/destroy.ts, src/cli/commands/state.ts, plus tests/unit/utils/command-interrupt-watch.test.ts, tests/unit/cli/destroy-command-sigint.test.ts and tests/unit/cli/state-destroy-command-sigint.test.ts. Previously neither destroy command registered a SIGINT handler of its own -- deploy.ts has had one since issue #1348 -- so the ONLY channel carrying "the user interrupted" from runDestroyForStack back to the multi-stack loop was DestroyRunnerResult.interrupted, a boolean assigned once inside the runner's try after its level loop. The runner's outer finally re-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 its return, 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, --all went 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.interrupted stays the PER-STACK outcome (it decides that stack's state preservation, its summary line and its --purge-events skip), 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-qualified cdkd force-unlock recovery 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-observed still 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.parseJson no 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 -- a catch interpolating 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 for cdkd local start-api -- but not by the edit to this file. src/local/http-server.ts is a shim re-exporting startApiServer from cdk-local/internal, so the command runs cdk-local's implementation; evaluateVtl here is reached only from cdkd's own rest-v1-integrations.ts, whose sole live import is warnSsrfRiskyUri. The user-facing fix is cdk-local's src/local/vtl-engine.ts (cdk-local PR #556), shipped in cdk-local 0.147.6, and this change bumps cdkd's cdk-local dependency 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-api serving a MOCK REST v1 request template that calls $util.parseJson on a header holding hunter2-my-password answered 502 with "reason":"$util.parseJson: the argument is not valid JSON (SyntaxError; argument length 19). The parser detail is withheld..." -- no hunter2, no Unexpected 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 answered 200), 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's src/local/ is dead code", and the difference matters: ecs-task-runner.ts is a REAL implementation imported live by src/cli/commands/local-run-task.ts, so #2189's own fix in ecs-secrets-resolver.ts IS 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(...) -- and vtlFailure copies 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 carries err.name plus 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: coerce is typed => string but RETURNED undefined, because JSON.stringify returns undefined (it does not throw) for a function, a symbol, or an object whose toJSON yields undefined, and TypeScript hides it because JSON.stringify(unknown) selects the any overload. It is reachable from an ordinary template -- $input.json / $input.path / $input.params are own-property FUNCTIONS on the object buildVtlInput returns, so $util.parseJson($input.params) with the call parens forgotten threw a TypeError instead of the diagnosis, and what escaped evaluateVtl was then not the VtlEvaluationError callers match on. ?? '' closes it in coerce and in safeStringify, 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 a VtlEvaluationError, 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 ), since argument length 4 is a substring of argument 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.parse failure 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 #664 block above it records that the commonest handler shape in the wild -- streamifyResponse plus setContentType / write, never calling HttpResponseStream.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 hands preludeBytes a 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 answers Unexpected token 'c', "customer-d"... is not valid JSON. Like the sibling entry above, this file is cdkd's MIRROR -- invokeRieStreaming has no caller in cdkd's src/ (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 the JSON.parse failure is suppressed, and getting that wrong was a real regression the review caught: parseStreamingPrelude throws 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 call HttpResponseStream.from which it had already called. The throw is now gated on err instanceof SyntaxError and the other three stay verbatim, with three cases at the invokeRieStreaming level fencing the gate -- they have to be at that level, because the suppression lives at the CALLER and the pre-existing parseStreamingPrelude cases 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 #664 block 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 at STREAM_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, since 26 bytes before is a substring of 126 bytes before. Eleven mutation probes red, including the gate probe (widening instanceof SyntaxError to instanceof Error reds all three legibility cases) and the inflated-count probe. The remediation assertion needed anchoring to its whole clause: a bare toContain('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 recording plaintext -> {{resolve:...}} expression and the deploy engine reading that at its state-save choke point. A nested stack broke the chain: the PARENT resolves the child's Parameters block, 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's recordedSecretValues came out empty and the child's state.json persisted 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: NestedStackProvider hands the child engine a SEED map (DeployEngineOptions.inheritedSecrets) — the parent's own per-resource bag, which is exactly the secrets resolved while resolving that AWS::CloudFormation::Stack row's Parameters. The child engine seeds it into every fresh recordedSecretValues, so the ordinary VALUE-based redaction finds the plaintext wherever the parameter landed, including EMBEDDED in an Fn::Sub-built connection string. The map reaches the provider through an AsyncLocalStorage in its own leaf module src/deployment/resource-secrets-scope.ts (withCurrentResourceSecrets / getCurrentResourceSecrets) rather than a new field on CreateContext, deliberately: .claude/rules/providers.md already records why SecretMaskingContext carries 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: redactSecretsForState substring-matches at or above MIN_NEEDLE_LENGTH, so a child resource that never referenced the parameter but happened to spell my-production-bucket while the secret was production had my-{{resolve:...}}-bucket persisted — a rewrite the desired side never mirrors (redactParametersForDiff rewrites 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, by IntrinsicFunctionResolver.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, where perResourceSecrets is keyed by logical id. The rollback executor binds the same store (#2086). src/deployment/rollback-executor.ts drives 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 of updateWithRollbackRetry (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. resolveReplayProps has already re-resolved the journal's {{resolve:...}} back to plaintext at every one of them, so the exact bag was in hand. Standalone cdkd rollback is unaffected: it builds a destroy-mode context, so requireDeployContext throws before any child engine is built. The diff half had to land WITH it, not before. cdkd diff --recursive's resolveChildStackParameters set neither skipDynamicReferences nor recordedSecretValues, 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 an Fn::Equals would flip a condition. Outputs OUT (#2055). The mirror image, and a third reader of the redacted bag in the #1934 class: NestedStackProvider reads the CHILD's persisted outputs and surfaces each as the parent's Outputs.<Name> attribute, so a parent property spelled {"Fn::GetAtt": ["Child", "Outputs.DbPassword"]} reached AWS as the literal {{resolve:secretsmanager:...}} token. Re-resolution reuses #1934's reresolveCrossStackValue / resolverForProducerRegion rather than a fourth copy of the walk, and it lives at the READ site — IntrinsicFunctionResolver.resolveGetAtt's flat-key attribute hit — NOT in buildOutputsAttributes. That seam choice is load-bearing rather than stylistic: re-resolving at attribute-BUILD time (the easy option, since readChildOutputsAsAttributes is already async) puts the plaintext into the parent's recorded attributes, and every CONSUMER resource reading it through Fn::GetAtt then has no entry in its OWN recordedSecretValues, so the save choke point has nothing to redact back and the consumer's state.json persists 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 the arn:cdkd-local:<childRegion>:... physicalId NestedStackProvider synthesizes (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. Under skipDynamicReferences the 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 (real AwsClients with 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 becoming NaN under a Type: Number child 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, the MIN_NEEDLE_LENGTH floor bound to the exported constant, Fn::Sub / Fn::Join / CommaDelimitedList reach, and the #2087 scope control that resolving a DIFFERENT parameter records nothing) and tests/unit/deployment/rollback-executor-nested-stack-secret-scope.test.ts (all four rollback binding sites, the single-shot disableOuterRetry branch separately from the retried one because that is the branch NestedStackProvider actually 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 — RecordedSecretValues is a plaintext-keyed map, the recording scan walks strings and string array elements, and redactSecretsForState rewrites string LEAVES — and two paths escaped it. (a) resolveParameters COERCES by declared Type, so a Type: 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's state.json kept the DECRYPTED value verbatim with cdkd diff --recursive reporting a change forever. It is now REFUSED, naming the parameter (IntrinsicResolutionRefusalError, code NESTED_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 by String(n) === plaintext, which both under-covers ("007" round-trips to "7") and over-covers (a numeric secret like 8080 whole-value-matches every unrelated port, #2087's class on a path where MIN_NEEDLE_LENGTH does 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 as Type: String, so no CDK app is affected; cdkd diff WARNS instead of throwing, since a plan must stay best-effort. (b) The child engine is the ONLY place context.parameters holds decrypted plaintext, and the resolver's two parameter debug lines print a parameter VALUE — Resolved Ref to parameter (which runs BEFORE recordInheritedParameterSecrets puts anything in the bag) and Parameter <name>: using user-provided value. stringifyParameterForLog redacts only on the template author's NoEcho, which a CDK-synthesized nested-stack parameter never carries, so cdkd deploy --verbose printed the secret. maskSecretsForLog now masks against context.inheritedSecrets as well as recordedSecretValues, which covers every log seam in the child resolver rather than the two that were found, and resolveParameters masks its own three lines against the same bag. (c) crossStackSourceKey gained an Fn::GetAtt <logicalId> <attributeName> arm, so the nested-stack-output read site above records a #2059 POSITION association instead of passing undefined: without a key its leaves fell to the plaintext-keyed value scan, and a child exporting Cur (:AWSCURRENT) and Prev (:AWSPREVIOUS) of one ROTATING secret has both outputs resolve EQUAL during the AWSPENDING window, so both parent properties persisted the survivor's expression and resolveReplayProps applied 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 --recursive skipped condition evaluation whenever ANY bound parameter was token-valued. That is not merely conservative: with the condition map left undefined, resolveIf warns and takes the FALSE branch for every Fn::If in every property value, so a condition-true property diffs as a spurious UPDATE (perpetual, and --fail exits 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 same Conditions map 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.cacheFallback in src/analyzer/orphan-rewriter.ts is a SECOND reader of a cached attribute that may legitimately hold an unresolved {{resolve:...}} since #2055 — it has no resolver context, so under cdkd orphan --force it splices the token verbatim and now WARNS about exactly that, and buildOutputsAttributes's "there is exactly one reader" note is corrected to "one RESOLUTION path". Real-AWS arm: the new tests/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 reaches NestedStackProvider.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' --verbose output for either plaintext, and which checks the perpetual-UPDATE class by the EXIT CODE of cdkd diff --recursive --fail rather than by its text.

  • The --remove-protection flip 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. ProtectionFlipRegistry entries are RETAINED on a throw by design — that is what carries "an earlier attempt already flipped the guard off" across the re-entered delete() issue #1978's round 2 fixed — and until now the ONLY things that dropped one were release() 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 same region\0name key IN THE SAME PROCESS — a rollback, a sibling stack, a re-run destroy, or two state records naming ONE physical table after a cdkd import adopted it into a second stack, all reaching the same provider INSTANCE through the ProviderRegistry that served them (destroy-runner.ts builds 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) — inherited flippedOffByThisRun: true and answered its own terminal failure with an UpdateTable(DeletionProtectionEnabled: true) the user never asked for. The fix is one guarded release() in delete()'s catch, gated on isTerminalDeleteFailure(error) AND on what the compensation reported. The predicate is the same one the compensation consults — the one that MIRRORS destroy-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 with flippedOffByThisRun and !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 a catch whose outcome defaults to failed, so an unknown outcome RETAINS the record. It is a catch rather than a finally because a finally REPLACED the delete error on its way out when a logger threw inside the compensation, so throw error was never reached and the caller saw the logger's message instead of the delete's; measured with a probe that made logger.warn and logger.error throw. The three routes out of that catch answer 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): isInterruptedWaitError makes 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, because src/deployment/resource-deadline.ts rejects the OUTER promise without cancelling what it wraps, so no ResourceTimeoutError ever enters this catch (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 compensating UpdateTable itself 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, record flippedOffByThisRun: false and 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 once cdkd import has 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), and ElapsedBudgetRegistry'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 — the GlobalTable one carries a twelve-line header of its own, calls deleteGlobalTableResource, and passes typeLabel: 'GlobalTable' — so what is shared is the mechanism, kept in step deliberately: the compensation suite is a describe.each over 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 composed isInterruptedWaitError -> release), a REGION-QUALIFIED key (every other case left expectedRegion undefined, 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 in tests/unit/provisioning/providers/, beside the sibling it shares its subject with. (It is NEW against main; an intra-branch git mv is why the diff shows no deletion.) ElapsedBudgetRegistry's reuseWithinMs gains 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 from ProtectionFlipRegistry, 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: reuseWithinMs was 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 EDITING src/utils/elapsed-budget.ts opens, 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 in dynamodb-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 passed expectedRegion: undefined and a wrong region source in the release stayed green. A fifth probe, in dynamodb-delete-budget.ts, makes the ResourceNotFound arm report not-applicable instead of failed and reds 3: both providers' #2224 registry assertions and the direct-unit outcome assertion. What is NOT probed, and is said rather than implied: the catch plus the 'failed' default is defence in depth with no reachable red for the RELEASE, because compensateRemovedDeletionProtection'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 four reuseWithinMs cases 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, plus tests/unit/cli/drift-per-resource-failure.test.ts and 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 --all every remaining STACK -- left unchecked, over one bad resource. Measured live on 2026-08-21 against the committed cloudwatch-anomaly-detector fixture, where the Cloud Control fallback threw UnsupportedActionException and the stack's ordinary SQS queue was never looked at, with exit 1 -- 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, #1945 calculateResourceDrift), but the defect is a CLASS: the reachable sites are provider.readCurrentState, ccApiFallback.readCurrentState, getDriftUnknownPaths / getDriftUnorderedPaths, canonicalizePrincipalUniqueIds, canonicalizeIpProtocols, provider.canonicalizeDriftProperties, calculateResourceDrift and redactDriftChanges, 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. NotComparedCause gains readFailed, and the taxonomy question #2151 raised (read-failure vs no-read-path) is settled by whether a RE-RUN CAN CLEAR IT: isNoReadHandlerError routes UnsupportedActionException / TypeNotFoundException -- matched through the cause chain, since CloudControlProvider.handleError re-wraps them, and bounded at depth 10 so a self-referential cause cannot hang the guard -- to the existing unsupported outcome at exit 0, because that is the same condition the fallback otherwise signals by returning undefined and AWS picks which spelling it sends; everything else is readFailed at exit 2, distinct from the 1 that means drift. outcomeExitSignal is rewritten as an EXCLUSION of unresolvedToken rather 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. DriftComparisonRefusedError is renamed DriftComparisonIncompleteError (private; no import site outside the file) since refused is no longer its only cause. --json notCompared entries gain a cause key (refused / unresolvedToken / readFailed) and their referencesUnresolved stops being the literal true -- it is false for a readFailed entry, whose references are beside the point; notCompared.length === 0 remains the documented "everything was actually checked" predicate and is unaffected. notComparedOutcomes now 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 no readFailed entry 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 / --revert need no filter: both iterate the drifted outcomes only, so a readFailed resource 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 with checked === 0 and at least one outcome now prints ⚠ ... NOTHING was compared instead of ✓; this covers the skipped-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 hazard unresolvedToken is 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, forcing isNoReadHandlerError false reds 2 and true reds 6, mapping readFailed to none in the exit signal reds 4, disabling the #2154 branch reds 1 here and 1 in drift.test.ts, dropping the empty-stack conjunct reds 1, and removing the cause-chain walk reds 1.
  • AWS::Region and the Fn::GetAZs region 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. effectiveAccountInfoRegion returned overrideRegion || process.env['AWS_REGION'] || 'us-east-1' unfolded, and it is the source of accountInfo.region -- which is what AWS::Region returns, what every Fn::Sub in a USER template interpolates, and what resolveGetAZs sends to EC2 as a region-name filter when the template names no region. It now folds through canonicalizeRegion once, 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 held AWS::Region raw 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) and CloudFormationClient({region:'US-EAST-1'}).send(ListStacks) are both refused with SignatureDoesNotMatch: Credential should be scoped to a valid region, as is the mixed-case Us-East-1. So the two obvious routes are closed BEFORE this function -- --region / AWS_REGION fold at the CLI boundary (issue #2065), and a CDK app declaring env: { region: 'US-EAST-1' } fails at app.synth() with CloudAssemblyError: Unable to parse environment specification, since EnvironmentUtils.parse is 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 own parseEnvironment accepts any region text, so a Cloud Assembly carrying a raw region -- hand-authored, from a non-CDK toolchain, or a cdk.out left by a synth that threw AFTER writing the manifest -- reaches stackInfo.region unfolded and deploy.ts passes it on as the resolver's region. That deploy SUCCEEDS, because AwsClients' constructor folds the region its clients sign with, so SigV4 never sees the raw spelling; every ${AWS::Region} a user's Fn::Sub interpolates then inherits it, producing arn: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 from stackRegion, which this does not touch. The EC2 filter is the more dangerous of the two consumers because it fails SILENTLY rather than loudly: EC2 matches region-name literally and answers a non-matching filter with success and an EMPTY list, measured live the same day as region-name=us-east-1 -> 6 AZs against region-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-range Fn::Select or 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 keys cachedAvailabilityZones, so two spellings were two entries for one region. The consumer-side canonicalizeRegion calls this subsumes -- constructAttribute's destructure fold, s3-endpoints.ts's entry fold -- are deliberately LEFT in place, since double-folding is a no-op. Only s3-endpoints.ts's is still reachable from a caller that does not come through here (S3BucketProvider.buildAttributes hands it s3Client.config.region()); constructAttribute has a single caller which always passes getAccountInfo(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 from client.config.region()) and issue #1888 (the S3 CreateBucket LocationConstraint comparison, via getRegion() -> s3Client.config.region()). Both read the SDK CLIENT's own resolved region, and AwsClients' constructor folds that for a bag CONFIGURED with a region -- tests/unit/utils/aws-clients-region-fold.test.ts fences 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 from AwsClients, and cdkd gc deliberately 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 pinning AWS::Region RAW 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); the Fn::Sub path asserted separately from the bare Ref, since Fn::Sub substitutes rather than returns and a fold covering only the Ref would 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 whole overrideRegion || AWS_REGION expression, so it has TWO input paths, and every case written with the fix supplied an override -- the resolver hands resolverRegion in 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.resolveSyntheticStackId passes undefined for a region-less client bag, and cdkd gc deliberately skips foldRegionOption), and reaching it in a test needs a direct getAccountInfo() 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 compares region -- folded whenever the template names one, and {"Ref": "AWS::Region"} is such a case -- against resolverRegion, which keeps the caller's spelling because it keys getState / saveState and must not move. For a mis-cased resolver region the test became 'us-east-1' === 'US-EAST-1', the refusal stopped firing, and a cfnFallback read could land on a same-named CloudFormation stack. Round 2 folded ONE side, which was not enough -- see the round-3 paragraph below. And resolveSyntheticStackId (src/provisioning/providers/custom-resource-provider.ts) turned out to be the ONLY reader of accountInfo.region in the repo with no local canonicalizeRegion -- the cloud-control, SSM and AppSync ARN builders all fold at the interpolation -- so the StackId handed 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 called accountInfo.region "folded nowhere" and claimed cdkd deploy --region US-EAST-1 SUCCEEDS 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. region reaches that comparison folded when the template names a Region and RAW when it does not (it defaults to resolverRegion), so folding one operand repaired the first branch and broke the second: a mis-cased resolver region with no Region argument 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 -- region is passed on to getSameAccountStackState / 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-Region case, folding neither reds only the Region case, 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 because AwsClients folds 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 two mockResolvedValueOnce answers 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 leak vp run test:once-leak exists to catch (issue #1618). One primer, consumed by the one call, asserting the positive marker. The standing hazard behind all of it -- resolverRegion raw 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, in cloud-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 from cdkd/<stack>/US-EAST-1/state.json to .../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 read process.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 a getAwsClients mock), 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 two Fn::GetAZs cases was VACUOUS and the probe is what caught it: they wrote process.env['AWS_REGION'] around an already-constructed resolver, but resolverRegion is captured once in the constructor and then passed to getAccountInfo as 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-task no 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. The jsonKey branch interpolated the raw JSON.parse failure into its own error message, and V8 embeds a prefix of the PARSED INPUT in SyntaxError.message -- the parsed input being the secret itself. Reproduced on the pinned Node 24.15: JSON.parse('supersecretpassword12345') yields Unexpected 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, so shortpw was quoted in FULL. Reaching it needs no hostile input -- a plain-string secret plus a :json-key: ValueFrom is an ordinary user mistake. The message now carries err.name (input-independent) instead of err.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 shape src/deployment/intrinsic-function-resolver.ts had already settled on for the equivalent {{resolve:secretsmanager:...:SecretString:jsonKey}} path. The sweep the issue asked for is recorded rather than assumed: secret material enters src/local/ in exactly two files (ecs-secrets-resolver.ts and ecr-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's docker login failure was checked against src/utils/docker-cmd.ts rather than assumed -- the password goes over --password-stdin and SpawnError.message is built from child stderr/stdout only. rie-client.ts and vtl-engine.ts carry the same SHAPE over a different population (a Lambda streaming-response prelude and a VTL $util.parseJson argument -- 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: restoring err.message reds both and reverting restores green. A live arm was added too, because no fixture reached the branch -- EnvTaskDef references the whole secret (no json-key) and MySecret'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's cleanup() ends with exit "${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 inside cleanup, which also widens it to every exit path. Review of the arm then found the helper was sourced RELATIVELY before the cd, so the repo-root invocation the script's own header documents aborted at that line; it is now sourced by absolute path, as docdb-neptune already does for the same reason. Two assertions were strengthened: the container-leftover count is taken BEFORE the docker 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 no generateSecretString pattern, 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 through tail with || true, so a run whose assertions passed and whose destroy then failed takes the all branch and deletes the state.json a later cdkd state destroy would need.
  • cdkd --version no 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.ts spawns 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 a check marker is about to be recorded, and the hardest shape to attribute because the test skipIfs itself when dist/ 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 for cdkd --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 is buildProgram() -- the remaining ~970 ms is spent before any cdkd statement executes. A CPU profile attributes it to Node's module loader (readFileUtf8 336 ms, wrapSafe/compile 296 ms, internalModuleStat 183 ms, readPackageJSON 112 ms, detectModuleFormat 86 ms), not to cdkd doing work at import time: src/cli/program.ts statically imports all 20 command modules, and deps.neverBundle in vite.config.ts keeps @aws-sdk/* external, so resolving and compiling that graph is the whole cost and every command pays it. index.ts now answers a bare version flag from the dependency-free src/version.ts BEFORE await 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 --version and cdkd --profile -V all 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, and cdkd deploy --help is unchanged at ~1000 ms (the broader lazy-loading of the command tree, which would move that number too, is NOT in this change). The typeof __CDKD_VERSION__ guard had been spelled three times in two different forms (=== 'string' in program.ts, !== 'undefined' in deployment-events-store.ts) with two copies of the '0.0.0-dev' fallback; src/version.ts is 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, not entry.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 the if block from index.ts while 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 --version still answers the right version while --help fails 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 for isVersionOnlyInvocation fails because rolldown renames the exports and a name match would not prove the branch PRECEDES the import; and importing src/cli/index.ts from a unit test runs main() as a side effect. Five mutations probed RED and restored GREEN: the naive argv.some(isVersionFlag) spelling reds 5 predicate cases, widening the accepted flag set reds the exact-set case, dropping the typeof guard 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.yml ran vp run test BEFORE vp run build, dist/ is gitignored and there is no artifact restore, so the whole file green-SKIPPED on every PR (measured with dist/ 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 when CDKD_EXPECT_DIST is set, which ci.yml sets on the build job's test step ALONE, so a future reorder cannot silently re-disarm it. Keying that on a bare CI was the first attempt and it reddened once-leak-detect and runtime-compat, which are separate jobs that legitimately never build -- CI says "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, which runtime-compat exercises, it otherwise fails to load); a claim that rolldown renames isVersionOnlyInvocation in 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 a readdirSync(sandbox) guard labelled anti-vacuity was itself vacuous, since writeFileSync had 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 in tmpdir() -- 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 --version to ~0.4-0.7 s, with only this test going red.
  • A --remove-protection flip 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-protection flipped DeletionProtectionEnabled to false, waited for ACTIVE (swallowing wait failures by design), then issued DeleteTable. 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's delete() is now a thin wrapper around the original body, holding a ProtectionFlipRecord and calling one shared compensateRemovedDeletionProtection from its catch before re-throwing. Three properties are load-bearing. It never masks the original error: the re-enable sits in its own try, never throws, and a failure goes to logger.error naming the physical id and the exact aws dynamodb update-table --deletion-protection-enabled command, while the ProvisioningError is re-thrown byte-identical -- deliberately un-annotated, because isRetryableTransientError SUBSTRING-matches the message and splicing text into it can flip a terminal failure into a retryable one. It compensates only what THIS run changed: a DescribeTable runs BEFORE the flip and flippedOffByThisRun is LATCHED only after AWS accepts the UpdateTable and 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 stays false (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-entered delete(), which is the issue's retry-then-fail case and the one a per-call record structurally cannot see: destroy-runner.ts re-invokes delete() 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 measured deleteCalls=2, reEnableCount=0. The record therefore comes from a ProtectionFlipRegistry keyed with the same deleteBudgetKey(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: deleteAccepted is recorded the instant DeleteTable resolves, because a throw after that point is a WAIT giving up -- waitForTableGone's Table X did not disappear within Ns matches no retryable pattern and is therefore TERMINAL by this module's own predicate, so ungated the compensation issued an UpdateTable(true) against a DELETING table 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 the DeleteTable try: any throw out of the post-flip region reaches it, including the interruptible waits go-to-k/cdkd#2053 introduced. isTerminalDeleteFailure tests isInterruptedWaitError FIRST and that clause is not redundant -- the interrupt message embeds user-chosen names, and DependencyViolation is both a legal table-name fragment and a RETRYABLE_ERROR_MESSAGE_PATTERNS entry, so a purely message-based classification reads a Ctrl-C as a throttle and skips the compensation. The terminal-only gate mirrors destroy-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 an UpdateTable pair per retry against a table AWS is already throttling); the per-resource DEADLINE route is unreachable by design, since resource-deadline.ts rejects the OUTER promise without CANCELLING what it wraps, so no ResourceTimeoutError ever enters delete()'s catch -- fixing that means making the deadline cancel, a change every provider shares, so it is recorded rather than attempted here; and the compensating UpdateTable is 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 before DeleteTable was 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 in dynamodb-delete-budget.ts prices 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 via describe.each, so a half-applied fix fails visibly. Tests: 10 cases x 2 providers, each asserting WHICH UpdateTable went out with WHAT input (this path issues UpdateTable for 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 sibling Table provider has no step after an accepted DeleteTable that can throw -- deleteTableWithIndexBusyRetry's body ends there -- so that half is fenced type-independently by four direct ProtectionFlipRegistry / compensateRemovedDeletionProtection cases instead). The re-entry regression is driven as two real delete() 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); setting observedProtectionOn = true in the failed-observation catch, and dropping the observation gate outright, each red the failed-observation case per provider (4); dropping the deleteAccepted assignment or its gate reds the accepted-delete case (2); moving the flippedOffByThisRun write above the flip send reds the rejected-flip case per provider (2); adding an unconditional DescribeTable to 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 is opts.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 outlives DELETE_BUDGET_REUSE_WINDOW_MS -- reachable with a --resource-timeout past 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 into protectionFlips.acquire for 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: removing flip.deleteAccepted = true from the Table provider 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 an AccessDeniedException reading not authorized to perform, which IS a RETRYABLE_ERROR_MESSAGE_PATTERNS entry, so the test was exercising the wrong arm; it was replaced with the issue's own ResourceInUseException example, 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::ImportValue state 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.outputs has always held plain Output names AND Export.Name aliases 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, and rebuildFromStateBackend) the resolver's state.json fallback scan (exportName in state.outputs), and the cdkd local commands' --from-state fallback scan in local-state-loader.ts (the same spelling) treated EVERY key as an export. A plain CfnOutput('VpcId') in an unrelated stack was indexed as the producer of export VpcId, applyStackUpdate overwrote the real producer's entry with no check and no log (last writer wins), and a consumer's Fn::ImportValue: VpcId bound 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's isExportAliasCollision; the cross-stack version had no guard, and no reader COULD have one, because the record did not carry the information. Schema v9 adds StackState.exportNames -- exactly the alias keys resolveOutputs wrote, 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 unlike imports / outputReads an 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 carry outputs: currentState.outputs forward spread exportNamesCarriedFrom(currentState) beside it, and the existing source-scan fence in deploy-engine-cross-stack-read-writers.test.ts now 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 (an importableOutputKeys(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, where Export.Name equal to the output key rewrites the same key with the same value so the bag is byte-equal but exportNames flips (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-lastModified producer 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 import over an existing record carries the set with the outputs it already carried. Tests: a v8→v9 unit contract in tests/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's exportNames: [] 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 new schema-v8-to-v9-migration integ deploys Producer (exports X), Decoy (plain output named X, 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 asserts version: 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 bare join(', '), 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, and docker-runner.ts since issue #2184) now render keys with JSON.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 (sidecarEnv carries four hardcoded literals and SENSITIVE_ENV_KEYS is disjoint from the denylist, which a constants test pins), so nothing asserts it and reverting that half stays green. Stated rather than implied. isMalformedEnvKey is now literally the complement of a single WELL_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: partitionSensitiveEnv only collides keys in the sensitive set, and the sidecar passes SENSITIVE_ENV_KEYS (three well-formed AWS credential names), so a malformed name is unreachable there. Records the local-run-task-from-state real-AWS run that gated issue #2183's merge in the integ ledger.
  • cdkd local run-task no longer leaks AWS credentials or resolved Secrets Manager / SSM secret values on the docker run argv (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>/cmdline to any local process; the Lambda runDetached path already avoided this via docker's value-less -e KEY form (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 spawn env. --env-file was rejected because docker truncates a multi-line value (PEM keys) at the first newline while the env channel preserves it. dockerSpawnEnvWithSensitive keeps 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 — an SSH_ PREFIX was the #2186 round-3 blocker, since it dropped realistic secrets like GitLab CI's SSH_PRIVATE_KEY), and the AWS credential-helper family the client's docker-credential-ecr-login reads 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); the LD_* / DYLD_* dynamic-loader families and the per-service AWS_ENDPOINT_URL_* endpoint family (aws-sdk-go-v2 honours it, so AWS_ENDPOINT_URL_ECR would walk around the exact entry — #2186 round 4) are additionally caught by PREFIX in isDockerClientEnvKey, 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 named PATH=/tmp/evil: would otherwise poison the docker client's own PATH (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 ''; dockerSpawnEnvWithSensitive carries a belt-and-braces copy of the guard, which the follow-up #2187 is set to adopt for runDetached (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 shared partitionSensitiveEnv, which -- per the #2186 review blocker -- drops a colliding key ENTIRELY (no -e flag at all): emitting a value-less -e DOCKER_HOST for 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's HTTPS_PROXY credential, or a macOS PATH inside a Linux image). The dropped key is reported in collisions and 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 named DOCKER_HOST, a table-driven membership+behaviour fence over all 47 DOCKER_CLIENT_ENV_KEYS spelled as LITERALS (a set-driven table cannot detect a deletion) plus the same literal fence over DOCKER_CLIENT_ENV_PREFIXES (exported for exactly this — a hardcoded copy made the anti-shadowing fence one-directional), the collision logger.warn, and confluence-point controls; all mutation-probed. Follow-ups: the sibling runDetached hijack (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. runDetached already 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_HOST etc.) still emitted a value-less -e KEY that docker resolved against the client's OWN env, handing the container the host's value. It now partitions through the shared partitionSensitiveEnv (from go-to-k/cdkd#2183), which drops a colliding key ENTIRELY and reports it in collisions for the warn. Tests: a sensitiveEnvKeys-extended run with a DOCKER_HOST secret asserts no -e flag for it, the client's own DOCKER_HOST stays 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 SSM SecureString, 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 literal ssm-secure: spelling is deliberately NOT resolved by cdkd and is out of scope -- verified live during this work, where an ssm-secure reference logged Unsupported dynamic reference service and reached the provider unresolved, matching docs/scenario-coverage.md. DeployEngine masks its three sinks -- the error line, the durable deployments/{runId}.jsonl event via maskSecretsInEvent, and the re-thrown cause via maskSecretsInError -- but only at the MESSAGE level, and that has exactly two holes, both measured on this tree rather than argued: JSON.stringify escapes " / \ / 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 only maskSecretsInText's SUBSTRING arm and a 3-character secret survived Value '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.ts carried 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 exported maskDeep, 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 spelled maskLeaf / maskLeafValue and 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 / packCompositeId quote the offending segment VALUE back, on a THROWN arm reaching the durable store and an onRefusal arm 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 are import() paths, which have no CreateContext to thread and no secret bag to build one from; they keep the back-compatible identity default. Two of the 15 (ec2-provider.ts's createRoute / 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, and createSecurityGroupIngress's ipProtocol is the one segment the file documents as accepting an arbitrary template string. Their UPDATE twins are threaded too, via a new UpdateContext on EC2Provider.update: those arms delete-and-RECREATE, so the masker has to reach the re-create that packs the id, and .claude/rules/providers.md notes 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.ts is threaded as the reference for the one-masked-sink shape, chosen because it is the provider whose Value property IS the secret (that property reaches no message site, and neither does secretsmanager-secret-provider.ts's SecretString; both were checked specifically). dynamodb-table-provider.ts had a single warn call 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 (coerceWarmThroughput admits only toFiniteNumber results, 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: every create() / update() message site across all ~80 providers was traced back to the properties bag, giving ~256 sites in the provider's own this.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.ts and cloudfront-distribution-provider.ts mask each element UPSTREAM of the JSON.stringify call); 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 to deployments/*.jsonl, and ACM's create() interpolates no property value at all -- perResourceSecrets is populated BEFORE resolve() 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 across tests/unit/provisioning/masked-retry-logger-mask-deep.test.ts, tests/unit/provisioning/composite-id-secret-masking.test.ts and tests/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, disabling maskDeep'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 exact update() sink bypass a reviewer found reds the SSM fence written for it.
  • An ACM certificate whose ISSUED wait 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. RequestCertificate materializes the certificate immediately and create() then polls for ISSUED (60 x 10s), a wait a DNS-validated certificate whose validation records are not live yet exhausts by construction. Both throw paths threw a plain Error naming the ARN only in the message TEXT, and create()'s catch passed undefined for physicalId, so the certificate stayed in AWS with nothing naming it: absent from state, invisible to cdkd state show, unreachable by cdkd destroy, and re-requested from scratch by the next cdkd 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 as physicalId (the reporter's own ask, and a path create()'s wrap used to drop for a non-CdkdError failure after the request). A cleanup that FAILS appends a line naming the survivor and the exact aws acm delete-certificate command rather than replacing the diagnosis. The replacement path is covered by the same change, since update() re-creates through this create(): 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 resource NO_CHANGE, cdkd deploy prints "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 has CDKD_NO_WAIT=true, which returns success with it recorded in state. Separately, RequestCertificate now sends a retry-stable IdempotencyToken (#2039's helper, which gained a charset: 'alphanumeric' option because ACM documents Pattern: \w+ and a 32-char max that the default cdkd-<hex> spelling violates), closing the in-process window where a transient 5xx whose request had landed made withRetry mint 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: waitForCertificateIssued latched validationOptionsLogged on having CALLED the printer, but logValidationOptions emits a record line only for a domain that already has a ResourceRecord. The first DescribeCertificate fires seconds after RequestCertificate, 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 names CDKD_ACM_POLL_ATTEMPTS / CDKD_ACM_POLL_INTERVAL_MS, and docs/troubleshooting.md says so too. Tests: 23 new cases in tests/unit/provisioning/acm-certificate-provider.test.ts and 2 in tests/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 a physicalId assertion that passed with the fix reverted); plus a new phase 0 in the ACM integ that forces a real timeout in seconds via CDKD_ACM_POLL_ATTEMPTS / CDKD_ACM_POLL_INTERVAL_MS and 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 -- grep found 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::Cluster and 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: acquireLock treats 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, because releaseLock was an owner-blind unconditional DeleteObject, deleted the SECOND process's lock, letting a third in. The holder now re-writes expiresAt at most every 2 minutes (or every quarter of the TTL, whichever is shorter) via a conditional PutObject carrying IfMatch with 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 is unrefed, so it can never hold a cdkd process open. releaseLock carries the same IfMatch, and a PreconditionFailed there 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 the s3:GetObject an ETag-conditional delete requires cannot strand a lock; cdkd force-unlock stays unconditional by contract, since it exists to remove a lock the caller does not own. The expired-lock takeover in acquireLock is 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 at warn naming 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 because forceReleaseLock had 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 with 409 while a 503 may 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 needs s3:GetObject; 501 from 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: isForeignLockError matched only the error NAME, so a bare S3ServiceException carrying 412 skipped "leave it alone" and reached the destructive fallback; isGoneError accepted NoSuchBucket -- 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 SECOND releaseLock for the same key was owner-blind (reachable, because the force-quit paths fire an un-awaited release while the main finally may 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; and isLockExpired trusted an expiresAt that arrives from the bucket unvalidated, so Infinity / 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, because acquireLock reaps an EXPIRED foreign lock and retries, so every message a user sees describes a LIVE lock, in practice a running cdkd deploy; the likeliest response, force-unlock then re-run, reproduces #2161's exact harm by hand. The recovery command carried only --stack-region, but cdkd force-unlock re-resolves the state bucket from the AMBIENT profile, so after cdkd destroy --profile prod the 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-effort getLockInfo rather 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: destroy deleted 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 orphan force-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; and deploy-engine.ts still had the EPIPE lock-strand that #2161 fixed in destroy-runner.ts, where renderer.start() sat after the acquire and outside the releasing try, so cdkd deploy | head stranded the lock for its full TTL. The drift --revert window between its acquire and its try is 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-level indexOf would 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-observed now 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.acquireLock returns Promise<boolean> and returns false -- it does NOT throw -- when a live, non-expired lock is already held by another owner. Seven call sites awaited it and DISCARDED the boolean, so the surrounding try/catch never fired and each command proceeded as though it had acquired. Because releaseLock is an owner-blind unconditional DeleteObject, the command then deleted the OTHER process's lock on the way out. The worst case was destroy: with a concurrent cdkd deploy holding the lock, it deleted the live AWS resources, deleted state.json, and deleted the deploy's lock -- reproduced against real AWS in the issue. cdkd deploy was never affected (it uses acquireLockWithRetry, which checks the boolean and throws), and top-level cdkd export was already correct. Each site now checks the boolean and throws before any lockHeld flag, lock-releasing try, state write, or provider call, so the foreign lock is left untouched; the message names cdkd 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 for force-unlock to infer the region from. Two consequences worth stating: cdkd import --dry-run now aborts on contention (import deliberately takes the lock even in dry-run so its plan cannot lie about live AWS, unlike orphan --dry-run which skips acquisition), and destroy --all stops at the first contended stack rather than continuing. Separately, the fix made runDestroyForStack throw EARLIER than the try/finally that restored the process-global AWS_REGION / AWS clients for a cross-region stack, which exposed a family of pre-existing leaks at every exit before that finally; all of them now route through one idempotent restoreBaseRegionAndClients() helper (cross-region setup failure, lock-acquisition failure, strong-ref refusal, a rejecting releaseLock, a throwing destroy(), and a throwing renderer.start()), each fenced by a mutation-probed test in the new tests/unit/cli/destroy-runner-lock-contention.test.ts. Contributed by @nix-tkobayashi.

Recently Implemented (2026-08-21):

  • cdkd scrub no longer refuses two classes of secret reference it can now handle: an ASSEMBLED one, and a producer that declares an expression in an Fn::If branch 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 its state.json still 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 an Fn::Sub placeholder; that detection is unchanged (it is now the named predicate isAssembledSecretReference, carrying the same four measured rows), but a hit returns the leaf BY IDENTITY so the PRIMARY resolver classifies the reference once resolveSub / resolveJoin have assembled it. Issue #2134 is what made that safe: the region question is now asked inside resolveDynamicReferences, 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 own DYNAMIC_REFERENCE_REGION_AMBIGUOUS and 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 on main and 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 inside scrubStack's best-effort catch { logger.debug }, so a producer region answering AccessDenied -- or an Fn::Sub placeholder scrub cannot evaluate, which resolveSub warn-and-KEEPS without throwing at all -- becomes a verbose-only line under a No plaintext secrets found summary. 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 loud SCRUB_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 unrelated Ref failure sharing the bag. Keying on "the raw leaf text survived" missed a leaf a downstream intrinsic rewrote without resolving (measured: an ssm-secure: reference assembled from a resolvable Fn::Sub placeholder reports zero findings while logging Unsupported dynamic reference service), broke on JSON escaping, and fired PERMANENTLY on prose that merely mentions {{resolve:secretsmanager: -- the unactionable-refusal class SECRET_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: classifyReplaySecretRegion verdicts an ARN-form token named-region regardless 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: classifyReplaySecretRegion verdicts an ARN-form token named-region whatever 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. The SCRUB_SECRET_REFERENCE_UNCLASSIFIABLE code is retired with its only throw site, foreignProducerRegions leaves CrossRegionSecretContext (its last reader was that gate), and the leafPath argument leaves resolveForeignRegionTokens (its only consumer was that message). #2150 -- producerPublishesSecretExpression's literal scan now makes the same Fn::If selection its hop walk does. The scan was JSON.stringify(subject).includes('{{resolve:'), which sees BOTH arms, so a producer whose output is {"Fn::If": ["IsProd", "{{resolve:...}}", "plain"]} deployed with the condition false verdicted declared; the discriminator then read the stored value plain, found no expression, and raised SCRUB_CROSS_STACK_PRODUCER_PLAINTEXT -- unclearable, because no cdkd scrub of that producer can turn plain into an expression. Issue #2146 had fixed the sibling instance in collectReExportHops and deliberately left this one, so the two halves of one question disagreed. They now share ONE selectTakenConditionalBranches, which rebuilds onto Object.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 (mirroring resolveIf'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 inside collectReExportHops could 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.ts re-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 unlocked Fn::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.ts adds 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 the collectReExportHops refactor 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 to false had left 15 496 of 15 496 green -- every other assembled fixture yields ZERO tokens, which reaches the same identity return through the empty-verdicts path, so only a leaf splicing a WHOLE token beside a split opening can tell the two apart), the Object.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 rides tests/integration/cross-stack-secret-import/: the producer gains a conditional export whose Fn::If condition is a literal-false Fn::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's Description -- deliberately not a second resource, since every assertion there counts RESOURCE records. Step 8, which has nothing to do with conditionals, is the discriminator: on main it exits 2 with SCRUB_CROSS_STACK_PRODUCER_PLAINTEXT and 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's ifTrue arm must actually carry a {{resolve:secretsmanager:...}} expression (nothing for a both-arms scan to have found otherwise). #2157 rides tests/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 once outputReads is seeded with a foreign region -- required, since the relaxed guard was gated on exactly that evidence -- the existing stacks' region-less leaves would classify ambiguous and 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 AND Scrubbed 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.
  • cdkd now 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 in cdkd scrub over the RAW template leaf. A reference whose opening or whose tail is contributed by a Ref / Fn::Sub / Fn::Join / Fn::FindInMap part 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 / resolveJoin then re-entered resolveDynamicReferences with 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 got Incorrect 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 the ambiguous refusal covers. (Measured for ssm; the secretsmanager endpoint's behaviour on a foreign ARN was not measured here, and the unit matrix covers its ROUTING with fakes rather than its rejection semantics.) resolveDynamicReferences now classifies each matched token itself, which is the first point at which the complete expression exists. A named-region verdict (the id is an arn: naming a different region) is delegated -- the single TOKEN, not the whole string -- to resolverForProducerRegion, the pre-existing per-region sibling cache that marks its siblings producerRegionGuest so a foreign region cannot pin an ssm type 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 returns local. The ambiguous REFUSAL is opt-in via a new ResolverContext.producerRegions, and cdkd deploy deliberately does not supply it -- the evidence is per-STACK rather than per-reference, so arming it there would refuse the ordinary CDK secretValueFromJson shape in any stack that also holds one cross-region import, and templates that deploy today would stop deploying. cdkd scrub supplies it, because for scrub a wrong-region answer is a silent miss and failing closed is the point; cdkd drift and 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 INSIDE resolveDynamicReferences, and scrub wraps each resolution pass in a best-effort catch { logger.debug } -- so the refusal was downgraded to a verbose line, no needle was recorded, and the command reported No plaintext secrets found and 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 a DynamicReferenceRegionAmbiguousError re-raised by cause-chain walk at all three catches, mirroring how the pre-pass refusals are placed outside them. (b) reresolveCrossStackValue handed 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 verdicted ambiguous; a siblingContext helper 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 exercised scrubStack, 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: producerRegions is 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, so reresolveCrossStackValue strips 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 !== undefined while resolverForProducerRegion spells 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: loadIndex parses the stored file with an unchecked JSON.parse, so a producerRegion the file holds arrives verbatim, and rebuild writes ref.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 the skipDynamicReferences arm 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 doubled Fn::Join part 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 optional readCurrentState is reported unsupported (? <logicalId> (<type>), "drift unknown"), and no comparison is attempted for it at all -- yet it incremented inspectedCount, 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 - skippedCount included 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 word checked is the one CI users grep for and it is not the half that is wrong, and --json already reported the resource under its own notSupported array rather than as anything checked, so excluding it is what makes the two renderings agree. The unsupported arm of the exhaustive matchOutcome pass now increments nothing, joining skipped (#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 -- so inspectedCount counts exactly the outcomes a comparison was attempted for. User-visible number change, both renderings: the stack above now prints no drift detected (0 resources checked, 1 unsupported), and the partially-compared spelling's denominator drops the same resource (1 of 3 resources fully checked, not 1 of 4). Nothing is lost -- the total is still printed on the same line from unsupported.length, and --json is unchanged. The ⚠ spelling is also REGROUPED, because excluding unsupported broke its arithmetic: that line states a denominator, so its parenthetical reads as a partition of it, and 1 of 3 resources fully checked (2 only partially compared, 1 unsupported) sums to 4 against a stated total of 3. unsupported now sits outside the parens -- 1 of 3 resources fully checked (2 only partially compared), 1 unsupported -- so the parenthetical accounts for exactly the inspected - checked gap. 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 renders 1 resource checked, the ⚠ branch 2 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 every toContain fragment survives it.
  • cdkd scrub now 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 on producerPublishesSecretExpression, 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 the Fn::ImportValue. So for stack C importing B's re-export of A's secret-bearing export, the verdict was no, the pre-pass returned early, and with B's own state.outputs still holding the pre-#1899 plaintext, cdkd scrub C printed No plaintext secrets found and 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 --all masked it, because orderScrubTargets scrubs producers first and heals the chain before C is reached; the reachable population is the documented single-stack cdkd 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 its Fn::ImportValue (via an export-name -> owning-stackS index built once per scrub, keeping EVERY owner rather than the first: inferCrossStackStackDeps keeps 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 returns no and reports a stack clean over surviving plaintext) and its Fn::GetStackOutput (literal StackName / OutputName) into the next producer's template, walking the WHOLE value node so a re-export wrapped in an Fn::Join is 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 returns no, 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's Export.Name is 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 producer publishes '<key>' by RE-EXPORTING a value that '<head>' declares from a {{resolve:...}} expression instead 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 an Fn::If, breaking the mirror of resolveIf the 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 a chained verdict that NO cdkd scrub can clear (nothing turns that producer's non-secret value into an expression, and there is no bypass flag), where main answered no. The walk now takes the FALSE branch only, which is resolveIf'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 malformed Fn::If yields no hop at all. (2) The widened verdict 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; via is now carried on widened too 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.Name producer 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, since readOne runs 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 into scrubRefusalWording, where the CLAIM keys on kind (only a widened verdict may say "could not say which", and a chained one with an empty via no longer borrows that hedge) while WHAT it names keys on the chain; and via is 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 running cdkd scrub A two 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 (a Ref to a parameter, or a re-export of an export produced outside this app); a non-literal Fn::ImportValue argument or a non-literal / foreign Fn::GetStackOutput slot, which cannot be resolved statically; an Fn::GetStackOutput naming this app's stack in ANOTHER region, since the index and producerTemplates are keyed by stack name alone; and a secret reachable only through the TRUE branch of an Fn::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 the through 'A', 'B' list and the four-command remedy; both Fn::If polarities, since the branch scrub takes must still refuse; an Fn::GetStackOutput re-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 classified declared; 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 the Fn::GetStackOutput hop 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 both Fn::If arms reds the untaken-branch case while leaving the taken-branch one green, dropping via from widened reds the widened case, first-writer-wins reds the duplicate-export case, widening at a hop reds the no-widening case, deleting the Fn::GetStackOutput branch reds two, refusing on every chained verdict reds the resolve case, treating any Fn::ImportValue as evidence reds the two over-approximation controls, labelling a root match chained reds the direct-import case, dropping either de-duplication reds a repeating-chain case, letting a chained verdict fall through to the widened hedge reds the empty-via case, walking a malformed Fn::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, and CdkdCrossStackSecretChainConsumer imports 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's state.outputs seeded back to the plaintext must exit 2 with the re-export wording and the chain remedy, and must NOT be the declared verdict, 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::GetStackOutput source 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. intrinsicSkeletonPattern recognises only Fn::Join and Fn::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 :AWSCURRENT and :AWSPREVIOUS exports of one secret are momentarily equal during a rotation, so both were persisted holding whichever expression was recorded last, and resolveReplayProps then re-resolved the WRONG reference against the live resource on a rollback or a cdkd 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), while SKELETON_WILDCARD is [^}]* 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: reresolveCrossStackValue is the only point holding both the consumer's source leaf and the whole {{resolve:...}} token the producer stored, so it now records canonicalKey -> expression into recordedCrossStackExpressions, a process-wide store owned by secret-redaction.ts for the same reason recordedSecretExpressions is (that module is the LEAF, the resolver already imports it, and the reverse edge would close a cycle). The key comes from crossStackSourceKey, called by BOTH sides over the RAW intrinsic -- Fn::ImportValue <exportName> and Fn::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 BEFORE positionByIntrinsicSkeleton, 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 extracted plaintextIndexOf is now shared by both arms so the conflicting-plaintext poisoning has one definition. The associations live in a WeakMap keyed by the resolution pass's OWN recordedSecretValues bag, 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 (an Fn::ImportValue key carries no region and an Fn::GetStackOutput that omits Region keys it empty), so one cdkd deploy --all puts two stacks in two regions on ONE key — deploy.ts builds a resolver per stack region. A second stack whose producer still holds the PLAINTEXT (the #2133 / #2146 population, where carriesDynamicReference short-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's perResourceSecrets, rollback-executor.ts's secrets, 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. The WeakMap also 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. recordCrossStackExpression additionally REFUSES an expression that is not a whole {{resolve:...}} token — the two payload parameters are both string, so the type system cannot see a SWAPPED call, and the reader returns expression to be persisted, so a swap would write a SECRET into state.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 in recordedSecretValues proves the pass resolved this token to a usable needle — and is what stops a skipDynamicReferences comparison 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. isSecretExpressionByVerdictOrSpelling is the test about THIS token (secretsmanager by spelling, or an ssm reference PROVEN to be a SecureString), and it is required because recordedSecretValues is 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 in tests/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 scrub can now redact a secret that arrived through Fn::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 context scrubStack built omitted stateBackend, which both resolveImportValue and getSameAccountStackState require, so a cross-stack read threw for want of a dependency; the throw landed in the per-item best-effort catch { 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 entered recordedSecretValues, so the value-based scan had NO NEEDLE for it, so nothing removed it -- and scrub exited reporting no plaintext found over a state.json that 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 inside scrubStack rather than three (in fact four -- the evaluateConditions context 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. exportIndex is deliberately NOT supplied, because supplying it would let the scan arm call exportIndex.patchEntry -- an S3 WRITE from a command documented to perform no AWS mutation, --dry-run included. 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-effort catch KEEPS swallowing; the cross-stack class is lifted out of it instead by a pre-pass (resolveCrossStackReads, run over the resource Properties, intrinsic Export.Name and output Value bags) that refuses with SCRUB_CROSS_STACK_READ_UNRESOLVED (exit code 2, cause masked through maskSecretsInText) -- 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 "a Ref to 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-precedence Ref really 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 full listStacks plus up to N getState TWICE), 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; and Fn::If is walked the way resolveIf walks 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's Resolved Fn::ImportValue / Resolved Fn::GetStackOutput lines 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 through resolveValue, so an Fn::Sub-assembled name can itself be a resolved secret) go through maskSecretsForLog like 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 with SCRUB_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 own recordedImports / recordedOutputReads entry 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: reresolveCrossStackValue resolves 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, what secretValueFromJson produces) makes secrets.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 memoized getState promise). 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 records plaintext -> {{resolve:...}}, which is the mapping scrub needs to rewrite the consumer's leaf; an already-plaintext producer records nothing. scrub --all now orders producers before consumers (orderScrubTargets, using dependencyNames plus inferCrossStackStackDeps) so one real run normally resolves the expression instead of hitting the refusal at all. (3) CrossAccountSecretRefusalError -- the cross-account Fn::GetStackOutput of a redacted value, which cdkd declines BY DESIGN, and a new SUBCLASS of IntrinsicResolutionRefusalError precisely so this treatment cannot capture that class's five USER-FIXABLE siblings (a stale placeholder ARN, a fabricated account, an unenriched Fn::GetAtt, --strict-getatt, a malformed Fn::Split), each reachable here whenever a cross-stack node's argument is an Fn::Sub / Fn::GetAtt / Fn::Split since resolveSub re-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, --fail exits 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, missing cloudformation: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 the canRefuse test runs ahead of the by-design branch so such a position records no permanent FINDING either -- and a MALFORMED Fn::If is walked with refusals disarmed instead of falling through to the plain object walk. Suppression is decided by the condition AND by state, since conditions comes from template parameter DEFAULTS (scrub takes no --parameters) and degrades to {} on failure: a key present in state.outputs proves the deploy wrote that output. Three more identifier / key sites on this path stopped printing unmasked values: the re-resolution's own Re-resolving dynamic reference(s) in <origin> debug line (whose origin embeds the RESOLVED export / output / stack name), the DescribeStacks fallback warn (which prints at default verbosity and had never been given a context to mask against), and the Available outputs: enumeration in an Fn::GetStackOutput not-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-stack No plaintext secrets found in <stack> line is now gated on unverifiableReads === 0 as 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 unresolvable Ref alone, a resolvable import beside one, an unreadable import in a not-taken Fn::If branch, 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 on tests/integration/cross-stack-secret-import/.
  • cdkd drift models "this resource was not compared" as a FIRST-CLASS outcome instead of clean plus 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 was drifted / clean / unsupported / skipped, and a resource whose dynamic references cdkd could not -- or refused to -- resolve landed in clean carrying referencesUnresolved: true (plus a second comparisonRefused flag 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: --json reported it as plain clean; round 2: the exit code read the refusal as a pass). The union now carries a notCompared member holding a single notComparedCause of 'refused' (cdkd declined -- exit 2) or 'unresolvedToken' (a surviving {{resolve:ssm-secure:...}} token, a permanent pre-existing population deliberately kept out of the exit code), clean carries no completeness marker at all, and a drifted outcome carries the same cause or undefined. All eight consumers -- the notCompared roll-up, the exit-code decision, runAccept, runRevert, both --dry-run plans, the --json writer and the human report with its "N of M fully checked" counter -- route through one matchOutcome helper whose handler record is a mapped type over DriftOutcome['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, --json only: a not-compared resource now appears under notCompared and NOT under clean, notCompared entries carry referencesUnresolved: true, and clean entries are referencesUnresolved: false by construction. No key changed name or meaning, and the exit codes, the human report and the PARTIALLY compared block are unchanged. Fenced by tests/unit/cli/drift-outcome.test-d.ts (the mapped type must stay total; clean must reject a completeness rider) plus three runtime tests asserting the counter, the human block, the --json roll-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.ts and src/provisioning/interrupt-watch.ts (comments only), .claude/rules/code-layout.md, docs/provider-development.md. rollbackCommand's finally tore down every signal path BEFORE releasing the lock: unforwardSigterm(), then process.removeListener('SIGINT', sigintHandler), then releaseLock. 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 next cdkd rollback / deploy / destroy on that stack blocked until the 30-minute TTL expired (recoverable with cdkd 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 one destroy-runner.ts already 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 nested finally, because the .catch() on the release covers a REJECTION and not a synchronous throw -- defence in depth rather than a live leak, since releaseLock is async and nothing in LockManager reaches 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 that unforwardSigterm() (which since issues #2053 / #1952 also closes the interrupt-watch scope and removes that module's shared SIGINT listener) could empty the listener set before sigintHandler was 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 that unforwardSigterm() 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 (acquireLockWithRetry returns 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: if unforwardSigterm() 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 / orphan and state refresh (which releases a lock in state.ts but registers no handler at all) have nothing to unregister, destroy-runner.ts was corrected in the #2053 / #1952 lane, and deploy-engine.ts is 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.ts registers 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, and src/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 because rollback.ts still 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, with deploy-engine.ts as the one deliberate exception. It is NOT true that no stranding window remains anywhere -- import / export / scrub / orphan / drift and state refresh-observed register 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 of destroy-runner-lock-release-ordering.test.ts -- it observes the SIGINT and SIGTERM listener sets from INSIDE releaseLock, 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 leave unforwardSigterm() 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 .catch passed 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 regression destroy-runner.ts hit when a late flip left result.interrupted false and destroy --all deleted the next stack. rollback has no such consumer -- interrupted is read only inside the try, rollbackCommand returns void, and its one call site is withErrorHandling -- and by that point every operation has replayed, the journal is popped and state is saved, so raising a PartialFailureError would 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 nested finally reds the synchronous-throw case -- plus an inverted control on the acquire-failure path, which releases nothing and must still leak nothing.
  • cdkd gc and cdkd bootstrap --destroy now 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.json is 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 -- so eusc-de-east-1 was 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: describeStateKey falls 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 handed cdkd 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 literal eusc -- enumerated from aws-cdk-lib/region-info's AWS_REGIONS and @aws-sdk/util-endpoints' partitions.json: 46 REAL regions, first tokens all two letters or eusc, none three. (An earlier draft said 53, which counts the aws-global / aws-iso-*-global pseudo-regions whose first token is aws -- 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 named db-/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-1 all 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 separate eusc from demo; 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 as cdkd (demo-app-1), reporting the state PREFIX as the stack name, and gc then emits cdkd state show cdkd --stack-region demo-app-1 -- the #2001 defect reintroduced from the other side, wrong in both arguments. So describeStateKey no longer guesses on the ONE layout where depth can settle it: it takes an optional knownPrefix, 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, so cdkd/team-a/MyStack/state.json is a legacy key two segments deep and would have been split as team-a (MyStack) where main correctly said MyStack: 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. gc and bootstrap --destroy both configure their own state backend with the default prefix, so both pass it, and a new shared DEFAULT_STATE_PREFIX in state-file-keys.ts replaces 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-prefix DEFAULT, which is what actually decides where a user's state lands) and migrate-command.ts are 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-prefix cannot slip past a teardown guard, and for those keys the depth is genuinely unknown. The pattern stays SHAPE-based and deliberately does not reuse isClientSafeRegion, 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 "rejects eusc-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:356 and ecr-uri.ts:225 are permissive and clean, scripts/ and tests/ have none, and src/local/lambda-resolver.ts:878 carries 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, since src/local/** sits behind the integ-local merge 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 in tests/unit/cli/gc.test.ts asserting the recovery hint reads cdkd state show PaymentsApi --stack-region eusc-de-east-1, and two in tests/unit/cli/bootstrap-destroy.test.ts on 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 both gc consumers, widening to {2,} reds the upper-bound negative alone, and disabling the depth branch reds seven state-file-keys cases plus the bootstrap-destroy legacy 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 the DEFAULT_STATE_PREFIX argument from gc.ts's two sites or bootstrap-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: a knownPrefix spelled 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), and src/cli/commands/bootstrap.ts still 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 scrub now 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 one IntrinsicFunctionResolver built from the stack's own region, and resolveSecretsManagerReference builds 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 from rollback-executor.ts rather than re-spelled: producerRegionsFromState reads the per-stack evidence (state.imports[].sourceRegion + state.outputReads[].sourceRegion) and classifyReplaySecretRegion returns local (the stack's own region answers), named-region (an ARN names a FOREIGN region, so a resolver pinned to it answers) or ambiguous (a region-LESS name form with a foreign producer region on record -- refused). The refusal is a CdkdError that leaves scrubStack uncaught, 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. A named-region reference 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.ts fakes only the leaf SDK client classes (real resolver, real AwsClients) 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-string Fn::Sub placeholder ({{resolve:secretsmanager:${Env}-db:SecretString:password}}) and an Fn::Join split ('{{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 and resolveSub / resolveJoin then handed the assembled expression to the PRIMARY resolver, which is this issue verbatim with the refusal never firing. Two tests now raise SCRUB_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 an Fn::Sub). The ARN form of that third shape was previously safe only by luck -- it classified as named-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:, mirroring rollback-executor.ts's REPLAY_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 ordinary Fn::Sub templates. 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; an Fn::Join that splits BEFORE the service name; and -- the one that bounds the whole pre-pass -- a leaf whose {{resolve: opening is CONTRIBUTED by a Ref / parameter Default / Fn::FindInMap rather 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 --all a 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 this logger.error is 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 --fail the 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 carries exitCode = 2, since 1 is already spent on --fail's ScrubNeededError ("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 (the Export.Name warn at DEFAULT verbosity plus the two verbose-only partial-resolution debug lines), each of which is handed a bag pinCrossRegionSecrets may already have SUBSTITUTED a foreign plaintext into. Per-site masking is not sufficient on its own, so every error that ESCAPES scrubStack also passes through maskSecretsInError at that boundary: formatError renders a CdkdError's cause as Caused by: <message> and the CLI's top-level console.error walks the whole chain and every link's stack through util.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 onto TEMPLATE_SOURCED_RULES, so the two state.outputs writers agree again (issue #2099) -- src/cli/commands/scrub.ts, src/deployment/secret-redaction.ts. DeployEngine.redactOutputs moved off the default TEMPLATE_DERIVED_RULES for issue #1943; scrub's positionedOutputs call kept the default on the premise that the two constants' only difference (descendArrays) could not fire here, "because CloudFormation requires an Output Value to be a string or an intrinsic OBJECT". CloudFormation does require that; cdkd does not enforce it -- TemplateOutput.Value is typed unknown, the resolver walks an array elementwise with no string coercion, and StackState.outputs is 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 index i was rewritten to today's expression at index i (redactByPath returns a known-secret source leaf verbatim), and state.outputs is re-applied VERBATIM to consumer stacks by the exports index and by Fn::ImportValue. The trade is stated in place rather than re-decided: turning descendArrays off 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. The PathSourceRules generation table's cdkd scrub outputs row 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 drift no longer re-resolves a cross-region secret reference in the CONSUMER's region -- --revert could write a foreign region's same-named secret to a LIVE resource (issue #2108, the drift.ts half 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 own state.json. resolveStateSecretExpressions re-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 SSM SecureString) of the same NAME in two regions is two independent values. Three consequences, in severity order: (1) --revert handed desiredProperties straight to provider.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 receiving tokyo-password-2108 where the deploy had applied ireland-password-2108; (2) detection baselined against a plaintext AWS can never hold, so the property reported drift forever and no --accept / --revert could converge it; (3) the secrets needle 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 by classifyReplaySecretRegion -- IMPORTED from rollback-executor.ts rather 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 with DRIFT_SECRET_REGION_AMBIGUOUS before any lookup. The foreign-region evidence is producerRegionsFromState(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 before provider.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-form secretsmanager / ssm reference in a stack that has ANY foreign producer region on record -- including a purely LOCAL secret, which is the shape CDK's ordinary SecretValue.secretsManager('name') / secretValueFromJson emits -- 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 positional seededSecretPaths masks 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 --json therefore gained referencesUnresolved on every drifted AND clean entry plus a notCompared roll-up, and the human report gained a PARTIALLY compared block, so a CI job gating on drifted.length === 0 cannot 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 exits 2 (this repo's "work completed but something was SKIPPED" code) instead of 0. That direction PRESERVES what CI consumers had -- pre-#2108 that population reported phantom drift and exited 1, so 0 would have been the silent downgrade -- and drift still WINS, so a run that both detects drift and refuses a comparison exits 1 exactly as before and a gate keyed on === 1 loses nothing. --accept / --revert keep 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 as checked contradicted the PARTIALLY compared block printed two lines below it -- and keying the glyph on the narrower exit subset would reintroduce exactly that contradiction for the ssm-secure population). The exit trigger is a STRICT SUBSET of the notCompared roll-up, via a second outcome field comparisonRefused set only from the THROWN half of referencesUnresolved. 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 drift in CI forever over a defect this change did not introduce. Such a stack is still listed under notCompared and in the PARTIALLY compared block, and still exits 0. 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 as DriftSecretRefusalError and both message sites ask isDriftSecretRefusal(err). The two sites originally compared against DRIFT_SECRET_REGION_AMBIGUOUS alone, so DRIFT_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-region Fn::GetStackOutput on a stack last deployed under schema v7 has no outputReads at all, so producerRegionsFromState returns empty, every name-form reference classifies local, 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 leaf SecretsManagerClient / SSMClient so 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 surviving ssm-secure token with NO refusal, which must be reported and must still exit 0), 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), and tests/unit/cli/drift-leaf-region-walk-mirrors-replay.test.ts (the anti-drift fence for the deliberate second implementation: it READS rollback-executor.ts and 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-bearing Value left the resource clean, so runRevert returned 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 persisted recordedImports / recordedOutputReads; every other save wrote the PRE-deploy imports[] / 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) and persistStateAfterOutputFailure, 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 on main: (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 an Fn::ImportValue and then failed recorded the consumer's new resources with NO import, so findActiveImportConsumers (destroy-runner.ts, the pre-flight that refuses to destroy a producer while a consumer imports from it) found nothing and cdkd 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 / unionCrossStackReads now 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 missed persistStateAfterOutputFailure, 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.ts scans deploy-engine.ts for direct imports: / 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 by tests/unit/deployment/deploy-engine-partial-save-cross-stack-reads.test.ts, and by the new arm B of tests/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 --destroy now resolve their region from your AWS profile, and hold existing storage rather than stranding it (issues #2029 / #1820) — new resolver + reconciliation in src/cli/region-options.ts, wired through src/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 as new 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 the us-east-1 literal — and that value keys the bootstrap marker, keys the state file and answers AWS::Region in every user template. With region = ap-northeast-1 in the profile, no --region and no AWS_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_REGION is 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. resolveEffectiveRegion returns 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, because cdkd bootstrap writes the key gc and bootstrap --destroy read — 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 direction bootstrap-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 because GetCallerIdentity is 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 bag listS3Candidates / deleteS3Candidates use) are built afterwards. Live-verified against real AWS, both polarities, non-destructively: with profile ap-northeast-1 and a marker there, cdkd gc --dry-run collected 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 profile eu-west-1 and 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.ts and bootstrap-destroy.test.ts did 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 under AWS_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 (deploy and friends) deliberately still uses the literal — same migration question, its own integ, tracked as issue #2100.
  • --region US-EAST-1 no longer breaks every non-local command, and cdkd gc stops pinning a us-east-1 literal over the profile region (issues #2065 / #2029) — new src/cli/region-options.ts, 18 files under src/cli/commands/, src/utils/aws-clients.ts. Issue #1795 folded --region at the boundary of the four cdkd local * commands for a reason that was never specific to them: SDK endpoint resolution, SigV4's credential scope, this repo's own PARTITION_TABLE prefix 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-1 died 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 under src/cli/, 19 of them unfolded. All 19 now route through foldRegionOption(options) at the handler entry (above applyRoleArnIfSet, because STS rejects a non-canonical region too — SignatureDoesNotMatch: Credential should be scoped to a valid region) plus namedCliRegion(...) 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 building new AwsClients({}) with no region hands resolution to the SDK's own chain, which reads AWS_REGION / AWS_DEFAULT_REGION DIRECTLY — no fold at a cdkd read site can reach that read, so AWS_REGION=US-EAST-1 cdkd deploy was broken independently of the flag. It also canonicalizes the ~50 providerRegion = process.env['AWS_REGION'] captures across src/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, so client.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 unfolded cdkd bootstrap wrote (issue #1820), so deploy.ts / publish-assets.ts capture rawCliRegion(...) on the line ABOVE the fold and hand only that to AssetModeResolver.resolve; gc.ts and bootstrap-destroy.ts keep the rawRegion / region pair 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 to new AwsClients({ region }) unconditionally, so a user who named no region at all — no --region, no AWS_REGION, but a configured ~/.aws/config region — got us-east-1 pinned 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 a us-east-1 literal 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 gc and cdkd bootstrap --destroy now derive both from ONE value — aws-clients.ts is 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 bootstrap WRITES 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 gc would report "not opted in" and cdkd bootstrap --destroy would 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 one bootstrap-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-run plan) 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-bucket prints a custom name with no region anywhere, so -y deleted in a region the user was never shown. The same literal-vs-profile change is deliberately NOT made anywhere else: for deploy and friends the region value 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 empty LocationConstraint, 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 blanket value.replace(regex, ...). So a plaintext that happens to occur inside the TOKEN'S OWN TEXT was spliced into the reference: deploy 1 persists jdbc://appdb:{{resolve:secretsmanager:appdb/creds:SecretString:password}}@host, deploy 2 records an ssm SecureString whose plaintext is appdb, and the walk writes {{resolve:secretsmanager:{{resolve:ssm:/app/dbname}}/creds:...}}. The rollback executor's resolveReplayProps scans 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 scrub writes the same wreckage into properties and observedProperties. 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 of pw{{resolve:ssm:/a/b}}tail, the pre-fix code produced the expression and the two-rule form produced jdbc://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: buildNeedleRegex sorts 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 existing DYNAMIC_REFERENCE_TOKEN_SCAN rather than a new token grammar (issue #1936 keeps one spelling), read with matchAll after an explicit lastIndex reset - matchAll does not MUTATE the shared constant but it SEEDS its clone from lastIndex, so a dirty value would silently skip leading spans and restore the splice. It lands in the SHARED arm, so all five PathSourceRules constants and every sourceless walk - the journal's previousState, attributes, and redactByPath'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, and cdkd scrub cannot 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 and redactByPath'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.redactOutputs passed the default TEMPLATE_DERIVED_RULES, whose descendArrays: true asserts the bag was PRODUCED by resolving the source. Two of its three call sites cannot say that - redactStateForPersist redacts whatever state.outputs holds, and the no-change path persists persistedOutputs, the PREVIOUS deploy's bag, whenever a resolution failure keeps today's from landing - and the consequence is not merely a missed redaction: redactByPath returns a known-secret SOURCE leaf VERBATIM, so a carried literal at index i is rewritten to today's expression at index i, a value the stack never held, persisted into state.outputs and re-applied to consumers by the exports index. It now passes TEMPLATE_SOURCED_RULES. Reachable rather than theoretical, narrowly: TemplateOutput.Value is unknown and 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, and StackState.outputs is explicitly not string-coerced. The issue's premise was stale and is corrected here: it said cdkd scrub had already moved its outputs call onto a TEMPLATE_SOURCED constant. It had not - PR #1944 moved scrub's properties call 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 to Object.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 - so secret-redaction-proto-key-walks.test.ts adds 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's lastIndex reds the leading-span case, and reverting the constant swap reds the engine case with the fabricated :AWSCURRENT expression it would persist. The per-constant matrix is reported at the strength it has and no more - test review measured the rules argument 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 own state.json — the right thing to persist, and region-less. replayRollback rebuilt its resolver from ctx.region alone, so resolveReplayProps re-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 SSM SecureString) 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, since resolveSecretsManagerReference / resolveSSMReference build their client from this.explicitRegion and pass the id through opaquely. ambiguous — the reference names no region AND the stack has a foreign producer region on record, so the replay throws ROLLBACK_SECRET_REGION_AMBIGUOUS BEFORE 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 to resolveDynamicReferences WHOLE, 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 new RollbackExecutorContext.importedProducerRegions, derived by the new exported producerRegionsFromState(state) from StackState.imports[].sourceRegion + outputReads[].sourceRegion — but a rollback journal exists ONLY after a failed deploy, and all five non-success saves in deploy-engine.ts persisted currentState.imports / currentState.outputReads, the PRE-deploy snapshot, beside the POST-deploy newResources. So the deploy that INTRODUCES a cross-region read never recorded it, the list came back empty, and the verdict was local. Those saves now persist crossStackReadsForPartialSave — the union of the snapshot with this session's recordedImports / recordedOutputReads, deduplicated on a canonicalized region — and DeployEngine.rollbackExecutorContext(previousState) passes the same union, so the in-process automatic rollback has strictly more evidence than cdkd rollback can 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' persisted imports[] 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, and cdkd destroy on the producer sailed through the pre-flight; findDownstreamConsumers under-reported outputReads[] 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: an ssm dynamic reference CAN name a full ARN (resolveSSMReference rebuilds the name as parts.slice(1).join(':')), so the classifier mirrors that split instead of taking split(':')[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 fixture tests/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 FAILED is no longer recorded as deleted (issue #2054) — src/provisioning/providers/custom-resource-provider.ts. The terminal Status === 'FAILED' arm of CustomResourceProvider.delete warned and fell through to return undefined, which deleteSkipReason reads as DELETED: the state record was dropped, the row printed as deleted, and cdkd destroy exited 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 as skipped (...) and the run exits 2. UNCONDITIONAL, with no already-gone classifier: the handler's Reason is 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 the logger.warn beside it, because a reason is rendered into the Error the 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 deploy accepts --allow-unaddressed (issue #1960), while cdkd destroy has none and raises PartialFailureError unconditionally, so the remedy there is the one its own summary names: confirm the resource is gone, then drop the record with cdkd state orphan <stack>. Tests: tests/unit/provisioning/custom-resource-delete-failed-response.test.ts (the skip is asserted THROUGH deleteSkipReason, i.e. as a statement about the record, with the SUCCESS polarity and the already-gone-reason case both pinned).
  • The custom-resource synthetic StackId now carries the real account, region and partition (issue #1866) — src/provisioning/providers/custom-resource-provider.ts. Every handler received arn:aws:cloudformation:us-east-1:000000000000:stack/cdkd-<logicalId>/cdkd regardless of where the deploy ran, so a CloudFormation-authored handler reading event.StackId to re-derive its account / region, to build an ARN, or to name a log stream got a coherent-looking ARN addressing nothing. syntheticStackId now takes the whole AwsAccountInfo — deriving ONE segment in isolation is worse than deriving none, which is why issue #1815 deliberately left the hardcoded arn:aws: prefix alone — and CustomResourceProvider.resolveSyntheticStackId resolves it through getAccountInfo(), 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 than getAccountInfo's own 123456789012: StackId is 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 added await never leaves Ctrl-C dead during the 47.75s pre-delivery backoff. Consumes getAccountInfo, 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.update replaces DELETE-first and its SKIP arm already aborted; the catch around that delete only warned and fell through to create(), 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 is markNonRetryable-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 repeated Subscribe with identical attributes returns the SAME SubscriptionArn and leaves ONE subscription, and with DIFFERENT attributes it is refused (InvalidParameter ... Subscription already exists with different attributes). Those three fields are exactly the createOnlyProperties of AWS::SNS::Subscription, and a createOnly change routes to the deploy engine's OWN replacement branch — the engine's only provider.update() call site is in the else of that if — 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 confusing already exists with different attributes raised 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 downstream Subscribe rejection — plus the case where the duplicate IS reachable: cloudformation:DescribeType being unavailable degrades an endpoint change into an in-place update (create-only-properties.ts warns 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 that Subscribe was 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 of SNSSubscriptionProvider.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. CallerReference IS CloudFront's idempotency key and was minted afresh inside create() from Date.now() + Math.random() — the worst shape of all, because the call LOOKS idempotent while behaving exactly as if it had none. The deploy engine wraps create() in its outer transient-error retry and issue #2026 made HTTP 500 / 502 / 504 retryable, so a 500 whose CreateDistribution had actually SUCCEEDED server-side re-invoked create(), minted a fresh reference, and CloudFront created a SECOND distribution: no state record, invisible to cdkd destroy, billing indefinitely. The reference now comes from acquireIdempotencyToken (issue #2039), stable across every attempt of one logical create and released — generation-bumped — only once the distribution is settled, so a --replace re-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: DistributionSummary carries Comment but NOT CallerReference, and GetDistribution needs the Id the lost response was carrying. So the replay is refused with DistributionAlreadyExists and 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 into Comment to make it searchable was considered and REJECTED (Comment is capped at 128 characters, so a marker can break a create that works today, and mergeUpdateConfig documents Comment as 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 withRetry under src/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.md states as MANDATORY that a new withRetry thread isInterrupted / onInterrupted, and it is structural rather than stylistic: withRetry is 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.ts and rollback-executor.ts all poll only BETWEEN operations. Measured when #2053 was filed: 11 call sites under providers/** and isInterrupted in 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 in applyAutoScalingDiff, 2 ELBv2 ModifyListenerAttributes, 4 ServiceDiscovery — DeleteNamespace plus three attribute calls), plus the twelfth site in dynamodb-index-busy-delete.ts, which is #1952: the index-busy DeleteTable loop kept re-issuing deletes for the better part of twenty minutes after a Ctrl-C, on the DESTROY path where destroy-runner.ts's withResourceTimeout had 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 no isInterrupted on the ResourceProvider delete 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 on this. 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 single delete() traverses more than one module. (1) One error type. deploy-engine.ts decides whether to ROLL BACK by asking what the failure was, and its InterruptedError is module-private — no provider can produce one. The first cut's onInterrupted returned a bare Error, providers wrapped it in a ProvisioningError, 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 issue DeleteListener / DeleteService, so Ctrl-C meant "delete what you just made, then roll back". onInterrupted now returns an exported InterruptedWaitError; the engine matches it through isInterruptedWaitError, a cause walk bounded at depth 5 (every provider catch re-wraps, so a plain instanceof would have been a placebo); and both cleanup arms re-throw ahead of their delete. The same bare-Error shape 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 the cause chain with a visited set and NO depth ceiling: a ceiling has to be sized against the deepest real chain, and that chain GROWS — flat is 2, DagExecutor adds none (it collects rather than wraps), but deploy-engine.ts adds one ProvisioningError PER 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 their DeleteListener / 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, so newResources[logicalId] is never set, the journal records physicalId: undefined, and the rollback executor classifies it skip-failed-unknown. Nothing holds the physical id, so the real choice is delete vs orphan forever — a preserved listener fails every later deploy with DuplicateListener while cdkd rollback skips it and cdkd destroy has no record of it, and an orphaned Cloud Map service additionally blocks its namespace's deletion with ResourceInUse, all while the engine prints "run deploy again to resume, cdkd rollback to 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.ts and deploy-engine.ts both 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 like HandleNotFoundException made an interrupted delete report success and DROP a live resource's state row. Both now check isInterruptedWaitError ahead of the match, mirroring the isFinalSnapshotError guard 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: a GlobalTable delete 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, and cdkd drift --revert reaches provider.update while 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 on process.listenerCount('SIGINT') > 0 was the first cut and is defeated by the very case it targets: drift runs provider.update at 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 by forwardSigtermToSigint(), which deploy / destroy / rollback / state call and drift does 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.ts registers no SIGINT handler and destroy-runner.ts removes its own in a finally, so between two stacks of a multi-stack destroy the shared watch is the ONLY listener — and merely latching there SWALLOWED the Ctrl-C, leaving draining unset, result.interrupted false, 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 a cdkd force-unlock hint it cannot verify but cannot afford to omit; a second graceful path was rejected because it would duplicate destroy-runner.ts's drain and have to be kept in sync with it. (4b) ...and runDestroyForStack now releases the lock BEFORE unregistering its handler. Property 4 made a SECOND window reachable, in the runner's own finally, which removed its SIGINT handler first and released the lock last: destroy.ts / state.ts register 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 --all run 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 own finally so a throwing release cannot leak the handler, and tests/unit/cli/destroy-runner-lock-release-ordering.test.ts pins it by capturing which listeners are registered at the moment releaseLock is entered — mutation-probed to expected 0 to be 1 under the old ordering. (4b-ii) ...and the reorder MOVED the window result.interrupted is read in. destroy-runner.ts assigns it once, inside the try, 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, so draining flipped too late and the flag stayed false. destroy.ts registers no SIGINT handler of its own (deploy.ts does — which is why the deploy path has no equivalent hole) and reads exactly that flag, so cdkd destroy --all went 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 with result.interrupted ||= draining at the end of the finally (the return is after it, so the caller sees the corrected value), and marked TACTICAL in the code: that flag being the ONLY channel to the --all loop 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 nested finally also now opens at renderer.stop() rather than at the release, and stop() 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 — measured threw=EPIPE releaseLock=0 leakedListeners=1. The enclosing finally protects 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 10 run under Node's ten-listener warning ceiling, which only destroy-runner.ts raises. #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), waitForReplicaGone and waitForTableGone. The two gone-waits answer the signal DIFFERENTLY, mirroring the throw/warn split they already carried for budget exhaustion: waitForReplicaGone THROWS (it runs before DeleteTable, which AWS refuses while a replica lives, so nothing has been accepted), waitForTableGone warns and RETURNS (AWS accepted the delete, so throwing would turn an accepted delete into a reported failure with state preserved), and waitForIndexesSettled stops waiting without throwing, which is the contract every other give-up arm in it already keeps. The regression guard is vp run audit:withretry-interrupt:check, wired into CI beside audit:provider-error-cause:check and cache: false for the same reason — a green that replays from cache is a checker reporting "all threaded" without having looked. It parses src/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 from startInterruptWatch imported from interrupt-watch.js in that file. Presence alone was the first cut and a reviewer broke it in one line — isInterrupted: () => false declares 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 (without onInterrupted, withRetry throws a bare Error('Interrupted') naming no resource; without isInterrupted the other half is never called), a conditional spread does not count, and an options bag it cannot READ is opaque rather than skipped. A SECOND rule runs per file: every watch must be dispose()d from inside a finally — 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 correct finally credit 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 a finally { if (flag) watch.dispose(); } or a dispose() inside an unrelated nested callback's finally both passed while releasing nothing reliably. The dispose is now credited to a DECLARATION resolved by lexical scope, must be an unconditional statement of the finally, 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 a withRetry fails 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 REAL process.listenerCount predicate, 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 and name match but whose type does not), tests/unit/scripts/withretry-interrupt.test.ts (31 cases, every failure probe taken against a scratch COPY so none touches src/) 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 turns expected 9 to be 2 on 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: () => false fails all 11 interrupt cases with all 5 controls green, () => true fails 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 ZERO DescribeTable calls 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.resolveDynamicReferences scans 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's isSingleDynamicReferenceToken and drift.ts's isWholeDynamicReference both spelled the inner class [^{}]*, while drift.ts's survivingDynamicReferences spelled 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, so redactByPath'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 into state.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-exported isSingleDynamicReferenceToken are the single source of truth; SKELETON_WILDCARD and this module's own fourth spelling (dynamicReferenceTokens, previously [^{}]* global) derive from the same character class, and drift.ts imports 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: --revert no longer pushes a literal braced token over a live ssm-secure value (preserveLiveValuesAtUnresolvedTokens now 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-end cdkd scrub / --revert cases 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), so dynamicReferenceTokens is now EXPORTED and drift.ts calls it; the per-call new RegExp at both sites was justified by a lastIndex claim that is FALSE for this use (measured: String.prototype.match with a /g pattern sets lastIndex to 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) and secret-redaction.ts (twice) may write one, ZERO anywhere else under src/**. 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's g flag 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 dropping g silently makes .match first-match-only and would let an ssm-secure token that is not FIRST go unreported by cdkd drift; and the leak SCOPING was incomplete -- cdkd scrub leaks on BOTH its walks, the properties one under TEMPLATE_SOURCED_RULES as 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 claiming mixedLeafMayCarryPublicReference was its ONLY reader, one paragraph below the text adding drift.ts as 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 (AuthorizationHeaderMalformed from S3, InvalidSignatureException from Lambda / ECR, SignatureDoesNotMatch from STS), and deploy.ts does NOT canonicalize where gc.ts:943 and bootstrap-destroy.ts:338 do — so cdkd deploy --region US-EAST-1 dies at the state-bucket preflight and never reaches the marker read at all. The body === null arm 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 by cdk gc" could not publish anything either, since the legacy destination cdk-hnb659fds-assets-<acct>-US-EAST-1 is 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 for cdkd gc and cdkd bootstrap --destroy in #1995, and the one whose failure surfaces LATEST. Both deploy-time callers derive the asset region as options.region || AWS_REGION || 'us-east-1' and then stack.region || baseRegion, so an env-agnostic stack — the CDK default — under cdkd deploy --region US-EAST-1 (or AWS_REGION=US-EAST-1) handed AssetModeResolver.doResolve the upper-cased spelling, which looked for cdkd-bootstrap/US-EAST-1.json, missed the marker cdkd bootstrap wrote at cdkd-bootstrap/us-east-1.json, and took the body === null arm. A stack whose env.region is pinned in CDK was unaffected (stack.region comes from the Cloud Assembly and is canonical). Nothing failed at deploy time and the legacy notice even fired — but the whole point of cdkd bootstrap (#1002) is that cdk gc cannot reach cdkd's asset storage, and the legacy destination is precisely what cdk gc DOES collect, so the failure surfaced later as assets vanishing from under deployed stacks. AssetModeResolver.resolve now 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, so us-east-1 and US-EAST-1 occupied 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 bootstrap derives 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.ts keeps handing the RAW assetRegion to buildAssetRedirectMap and addAssetsToGraph, 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 / #1820 case-sensitive endpoint hazard is now reachable on a cdkd-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) in src/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 --destroy still translate NoSuchBucket into their never-bootstrapped message and hard-error on anything else, while loadBootstrapContainerRepo stays best-effort and falls back to the conventional asset-repo names. bootstrap --destroy additionally still DELETES resolvedKey rather 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. ensureAssetStorage is 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 raw region, so folding the read alone would break that pairing — a bootstrap under US-EAST-1 would 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 in tests/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 resolving cdkd-assets instead 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, parseBootstrapMarker naming 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 maskSecrets contract, and pollOperation — 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: the convertTargets drop-warn at elbv2-provider.ts:1771-1773 was already threaded by the merged #2067 (#2050) lane, which masks the value walk BEFORE JSON.stringify and passes the masker from all three call sites — confirmed against origin/main rather than assumed. What this PR fixes is #2063 plus the sites its own audit found. Issue #2050 threaded CreateContext.maskSecrets / UpdateContext.maskSecrets into 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 a ProvisioningError the deploy engine prints at ERROR, i.e. at DEFAULT verbosity. ELBv2: createLoadBalancer now takes the masker (its outer catch is the ONLY disclosure surface for a non-retryable CreateLoadBalancer rejection, since nothing on that path runs through withRetry and there is no give-up summary behind it), and its partial-create cleanup warn is masked for uniformity with the sibling Listener / TargetGroup paths. updateLoadBalancer needs no parameter for its FAILURE surface — it carries no try/catch, so its ModifyLoadBalancerAttributes / SetSubnets / SetSecurityGroups / AddTags rejections propagate RAW to update()'s outer catch, which is where the mask belongs (the sibling arms wrap their own errors first, so the CdkdError passthrough 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-path logger.debug lines 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 by Number() and by AWS, so the plaintext prints under --verbose. The Number() 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 the Create*Namespace / Update*Namespace payload and the TagResource call syncNamespaceTags makes with resolved tag VALUES. pollOperation is 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 in Operation.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 AND updateService opens its catch with if (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 raw ErrorMessage (before interpolation, so it reaches maskSecretsInText'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: DeleteContext carries no masker (issue #2007) and its payload is a physical id, not a resolved property bag. The docs added here say every create/update caller for 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-create GetNamespace debug and waitForCapacityReservationProvisioned each 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 (DeleteContext carries no maskSecrets). 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's message or 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.stringify escapes the document's quotes, so JSON.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 masked String(newCapacityUnits) — the Number()-COERCED value — where its create twin masks the raw property. A masker matches by literal occurrence, so any value Number() 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 of 4071, 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 through Number() (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: relocating pollOperation'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 when secrets.has(text), i.e. when the text IS the secret entire, so masking raw buys coverage BELOW the 4-character MIN_NEEDLE_LENGTH floor for a whole-value ErrorMessage and nothing at all for an embedded one. The comment now says exactly that, and a case whose ErrorMessage IS 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 the reverse-replacement replay (the create-first attempt and the post-delete-new-first retry) called createProvider.create(...) DIRECTLY inside a withRetry carrying RECREATE_RETRY_SCHEDULE plus a custom isRetryable (isNameCooldownError / isRecreateRetryableError). Two independent mechanisms in retry.ts then made the IAM-propagation class unreachable: a caller-supplied isRetryable REPLACES isRetryableTransientError outright, and ANY explicit schedule knob sets defaultSchedule = 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 took InvalidParameterValueException: 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 existing updateWithRollbackRetry), now owns BOTH loops for both arms: an inner call passing no schedule knobs and no classifier — so defaultSchedule is true and the dense path is live — nested inside the outer name-release loop, with the masking retry logger (#2038) and the isInterrupted / onInterrupted pair threaded into each. Nesting rather than hoisting the propagation check into retry.ts is the load-bearing decision: withRetry never receives the provider, so it structurally cannot honour ResourceProvider.disableOuterRetry — re-running CustomResourceProvider.create() mints a fresh pre-signed S3 URL and RequestId and strands the previous attempt at a key nobody polls, and re-running NestedStackProvider.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-isRetryable call sites, including four throttling-only ones in intrinsic-function-resolver.ts / export.ts / describe-type.ts / dynamodb-index-busy-delete.ts. The disableOuterRetry guard 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 9 create() 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 at deploy-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 --replace fallback 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 from RETRYABLE_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 carries wait 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 by tests/unit/deployment/rollback-executor-replay-propagation-retry.test.ts (12 cases running the REAL withRetry with its sleep recorded 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 in tests/unit/deployment/rollback-executor-log-masking.test.ts pinning 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 emit retry.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-up warn to the raw logger while leaving debug masked 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 OUTER withRetry call by "carries a custom classifier" instead of .at(-1) (which is now the inner call), and rollback-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 catch sites 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 .cause chain, so a provider that wraps an AWS failure in a ProvisioningError WITHOUT 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 of src/provisioning/providers/**, over the 21 CdkdError subclasses actually constructed there: 766 constructions, of which 392 have a caught value in scope and all 392 thread it (387 inside a lexical catch, 5 inside a wrapError helper), 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 720 new ProvisioningError( sites carry no cause argument — and every one of those 338 is a validation throw; the 6 figure is reproduced by widening the scan past providers/ (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 as error 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 five wrapError / wrapUpdateError helpers (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 the cause argument from rds-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 omitted HostedZoneNameNotFoundError, declared inside route53-provider.ts and extending ProvisioningError; a catch-sited construction of it that dropped its cause passed with counts unchanged. The table is now DERIVED from src/utils/error-handler.ts (every class transitively extending CdkdError, 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 before cause makes every site of that class read dropped, 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?", so const cause = new Error(error.message) passed while being exactly as inert as dropping it (the derived Error carries no $metadata and no marker), and result.error was credited from a property name. The check is now STRUCTURAL: a bare identifier resolving to the caught binding, optionally through parentheses / an as cast / a ! / an undefined-guarded conditional / ?? / ||, and through local bindings resolved to the declaration NEAREST the use (a sibling block's const cause no longer credits an unrelated site). A property access, a call, a new, 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 expecting dropped, run before the tree is read — making isThreaded return 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 used import.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 while argv[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 reason typecheckcarries 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 the cache: 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 new Errorfrom 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, and IAMRoleProvider) and asserts all four classifier verdicts agree, for a 500, a throttle, and a cdkd markNonRetryablemarker. 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 outside src/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 CreateAccessKey no 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 every provider.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-invoked create() and provisioned a SECOND resource — with no state entry, invisible to cdkd destroy, billing indefinitely. RunInstances, CreateNatGateway, CreateRouteTable and CreateNetworkAcl now carry a ClientToken from 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 later cdkd run can collide, and is RELEASED on success — and on the wiring-failure path, but only once the compensating TerminateInstances has 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 --replace re-create is not handed back the resource the deploy just destroyed — EC2 keeps a RunInstances token for ~24h and would otherwise answer with the terminated instance. AWS::Route53::HostedZone was the worst shape found by the audit: it already passed a CallerReference, 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, GetHostedZone included so the adopted zone returns the same NameServers a first-attempt create would. CreateAccessKey has no token member at all and disableOuterRetry was rejected (it would make the provider single-shot for the IAM-propagation window between a sibling AWS::IAM::User being 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 real withRetry; the ec2-instance integ fixture gained a real-AWS arm that reads the live instance's ClientToken back and replays RunInstances with 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) and cloudfront-distribution-provider.ts (issue #2079, unfixable the same way because ListDistributions does not return CallerReference) 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, a Date.now()-derived token coincidentally matched, and the pre-fix implementation went GREEN through the probe written to catch it. The fixtures now fake Date and 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-instances was 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 accepts min:max). The CLI rejected the command outright with Unknown options and 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. Every aws flag 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 resolving Fn::ImportValue got that string back verbatim from the exports index (or from the state.json scan) 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 (resolveReplayProps for the rollback replay, #1914 for cdkd 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 the Fn::GetStackOutput sibling read path the issue asked to be audited in the same pass, which reads the same state.outputs bag 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 through nested-stack-provider.ts (issue #2055, the same AWS-facing defect via a third route), and local-state-loader.ts's own --from-state cross-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 own Region), 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 and Fn::GetStackOutput carry 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 no region field — 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, because cachedDynamicReferences is 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-account Fn::GetStackOutput REFUSES such a value instead — an IntrinsicResolutionRefusalError, 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 assumed RoleArn is scoped to a state read and carries no secretsmanager:GetSecretValue promise, so widening it is a permission-model change rather than a silent fallback. Both CloudFormation fallbacks (ListExports, DescribeStacks outputs) 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 into recordedSecretValues, so the deploy engine's save choke point rewrites it back), cdkd diff still compares expression-vs-expression (skipDynamicReferences is honoured), and a value carrying no {{resolve: is returned BY IDENTITY, so every ordinary import is untouched and pays no walk. The Fn::ImportValue log 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: the AWS::EC2::VPC Ipv6CidrBlocks and AWS::ServiceDiscovery::*Namespace HostedZoneId lookups each built a fresh SDK client per CALL — never destroyed, so one client and socket pool per lookup for the life of the process — from resolverRegion, whose AWS_REGION → us-east-1 substitution is the FAIL-OPEN shape #1957 removed from the dynamic-reference lookups, and without the ambient --profile / credentials. The EC2 site now reads clientsForRegion(explicitRegion).ec2 like its DescribeInstances / DescribeLaunchTemplates siblings. ServiceDiscovery has no AwsClients member (adding one would put a static @aws-sdk/client-servicediscovery import into a module every command loads), so the REGION DECISION is still clientsForRegion'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 by tests/unit/deployment/cross-stack-secret-reresolve.test.ts (20 cases against the real AwsClients with 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 in tests/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 an Fn::Sub, and the diff path NOT refusing), 1 in tests/unit/deployment/intrinsic-refusal-non-retryable.test.ts (a source-level enumeration, so a refusal site added later cannot arrive unclassified), and 5 in tests/unit/deployment/dynamic-reference-region-scoped-clients.test.ts for #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 on getAccountInfo'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.ts and src/deployment/retryable-errors.ts sat in NO integ gate while withRetry wrapped every provider's create / update / delete and destroy-runner.ts consulted isRetryableTransientError directly — so a change deciding which AWS errors are retryable reached every mutating call cdkd makes and triggered neither integ-destroy nor integ-broad. Both are now in integ-destroy.include and in CROSS_CUTTING_REGEX. src/deployment/rollback-executor.ts was found unscoped in the same pass and was NOT named by that issue: its reverse-replacement path calls provider.delete() on the new physical resource and re-creates the old one, which is delete logic by any reading. The .markgate.yml half alone would have been inert, which is the non-obvious part: integ-destroy-gate.sh carries 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's strict_delete group (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 the delete / rollback / ENI vocabulary 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.ts and src/provisioning/region-check.ts both ACTIVATED the hook while sitting outside integ-destroy.include, so the gate blocked, ran markgate verify, and got a 0 back — the marker's hash: diff digest 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. The integ-destroy entry in CLAUDE.md gained 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-pr step 6's bullet list AND its detection-snippet copy of the same regex, the CLAUDE.md integ-broad entry, 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 omitted export (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 in integ-broad-gate.test.sh, and 3 activation cases plus 2 controls in integ-destroy-gate.test.sh (which required the suite's first fixture repo carrying a real refs/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 real export drift.

Recently Implemented (2026-08-19):

  • The maskSecrets provider 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 by CreateContext and UpdateContext — and wired exactly ONE consumer, CognitoUserPoolProvider. Every other provider interpolating a resolved property value into its own logger.warn stayed outside cdkd's masking boundary: properties reaches 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 the EnabledMfas case that motivated the contract, and log-only for the same reason (these values do not reach deployments/*.jsonl), which is why they were deliberately deferred rather than overlooked. Shape, copied from buildMfaConfigRequest rather than reinvented: the masker is taken as a trailing SecretMasker parameter 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 from src/types/resource.ts, which re-exports it for exactly this reason; src/provisioning/** still imports nothing from src/deployment/**. BOTH layers, per the contract's own correction: the sink is the OUTER one and can only reach maskSecretsInText's SUBSTRING arm, which ignores needles below MIN_NEEDLE_LENGTH (4) and — more importantly — never matches once JSON.stringify has escaped a ", a \ or a newline inside the value. Each site therefore also masks the raw value BEFORE stringifying: a maskLeaf walk for API Gateway v2's Destination / Source, a per-ARN .map for ASG's expected / observed target-group sets, and a shared module-level maskLeafValue walk in each DynamoDB provider (a walk, not a top-level typeof test, 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). BOTH create() and update() per provider, which is what makes it a fix rather than a hole in the shape of whichever path a deploy takes: apigatewayv2 gained context on both (threaded to createIntegration / updateIntegration → toSdkResponseParameters), sns-topic gained it on update() (create already had one) and threads it to buildDeliveryStatusAttributeMap's desired-side walk, and both DynamoDB providers gained it on update() and install sinks in create() and update() plus the warm-throughput / GSI / auto-scaling helpers those two reach. asg covers both paths too — an earlier revision claimed its create path had nothing to mask, which review disproved: AutoScalingGroupName falls 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), so create() 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-index WarmThroughput refusal, its auto-scaling MinCapacity / MaxCapacity refusal, 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 interpolate physicalId, which for those types IS the resolved TableName — because DeleteContext (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 of applyAutoScalingDiff correctly 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 resolved AutoScalingGroupName in the message's physicalId position). 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: warmThroughputOpFor masked indexName where it built two helper scope strings and then interpolated the SAME value RAW into its own decrease warning and its already-matches debug line, so a resolved secret as IndexName printed in plaintext at DEFAULT verbosity on cdkd 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 a safeIndexName the 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 masks error.message only 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, waitForTableActiveAfterUpdate including 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.md and .claude/rules/providers.md still describe buildMfaConfigRequest as the reference implementation, which this change does not falsify.
  • cdkd scrub can 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. outputSecrets is 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 in state.outputs survived a scrub that REPORTED SUCCESS, and that is exactly the record cdkd diff withholds 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 an Export.Name this 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, since deploy-engine.ts writes an alias key on typeof 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. redactUnaccountedOutputs then repairs every OTHER key by value match alone, against allRecordedSecrets (the union of the outputs map and EVERY resource's, filtered to plaintexts at or above secret-redaction.ts's MIN_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 corruption secret-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 because state.outputs is re-applied VERBATIM to CONSUMER stacks — cdkd's exports index (src/state/export-index-store.ts) and every Fn::ImportValue / Fn::GetStackOutput read 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's totalSecrets === 0 early 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, since totalSecrets counts the raw maps while the union is filtered to MIN_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 parameterized Export.Name leaves the deploy's real alias key unaccounted on EVERY run, so those keys take cross-resource value matching as a standing condition. The --fail KEY scan deliberately keeps reading outputSecrets rather 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 an Export.Name change plus a redeploy, per #1919). The cdkd diff refusal is unchanged — it still cannot decide from a stored string alone whether a value is a plaintext. Covered by tests/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. CustomResourceProvider sets disableOuterRetry, 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 RETURNED cfnResponse.Status === 'FAILED', and the loop body contained no try / catch at any point, so a THROWN exception left create() directly. Every AWS call the provider itself made was therefore single-shot with no retry anywhere in the stack — an AccessDeniedException on lambda:InvokeFunction while 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 new isTransientAuthzThrow (IAM_PROPAGATION_ERROR_MESSAGE_PATTERNS plus CR_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's transientAuthzMaxRetries (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 carries IAM_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 the delivered fence: this arm fires only when the handler has been invoked ZERO times, so a replay re-runs nothing. CDKD_CR_AUTHZ_MAX_RETRIES accordingly bounds handler RE-INVOCATIONS only (and is now clamped to 10 — 1e9 passed the old finite / >= 0 gate and re-invoked until the 1h per-resource deadline). sendRequest gained an onDelivered callback fired the instant Invoke / Publish RETURNS, 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 hazard disableOuterRetry exists for. isRetryableTransientError is 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 S3 PutObject instead gets its own full-schedule withRetry, 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 / UpdatedV2 are not single-shot — the SDK's generated checkState catches every exception and returns RETRY, polling to maxWaitTime (600s) — and the GetFunction delete-path probe already fails OPEN, so retrying it would only delay fall-through by up to 47.75s on a genuine denial. The CR_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's Create (with the documented risk of a Provider-framework onEvent creating 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 bare is unable to assume is 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: the disableOuterRetry / transientAuthzMaxRetries / invokeCustomResourceWithRetry / sendRequest JSDoc, .claude/rules/providers.md, and docs/troubleshooting.md's "invoked exactly once" bullet, which told users a Custom::* 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-waiter serializes observedResponses into its TIMEOUT message and those keys read 403: … not authorized to perform: lambda:GetFunction …, so the classifier matched and a PERMANENT denial cost 27 x 600s instead of 600s. The fence is markNonRetryable on the wrap plus an isMarkedNonRetryable refusal 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 placeholder PutObject is 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 ENTIRE GetFunction response — Configuration.Environment.Variables included — into a message that extractDeploymentEventError persists to deployments/{runId}.jsonl, a durable store contractually free of resource properties; that arm now reports the error NAME plus the function's own State / StateReason / StateReasonCode and withholds the payload. (c) delete()'s lenient catch swallowed the throw and returned undefined, which deleteSkipReason reads as DELETED — so a permanent lambda:InvokeFunction denial printed ✓ … deleted, dropped the state record and exited 0 over a handler that never received a Delete, 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 the Error the 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 per docs/provider-development.md — one SIGINT watch per invocation, threaded into the withRetry as isInterrupted / onInterrupted and polled by the hand-rolled backoff — while each abandoned response placeholder is swept before its retry, since cdkd gc scans the ASSET bucket and not this prefix. Covered by tests/unit/provisioning/custom-resource-provider-thrown-retry.test.ts (25 cases, no *Once primers); 14 mutation probes were run and every one failed a distinct assertion — including one that caught a new test failing to discriminate: deleting the PutObject withRetry left 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. resolveReplayProps re-resolves the journaled operation's properties, so the payload handed to provider.update / provider.create carries Secrets Manager / SSM SecureString PLAINTEXT; AWS validation errors routinely quote the offending value back (Value '<secret>' at 'password' failed to satisfy constraint ...), and this file imported no maskSecretsInText at all. FIVE sinks were open — three on the rollback path plus two the audit of the deploy path turned up. (1) The withRetry options threaded the RAW Logger, so retry.ts's give-up summary printed the verbatim message at warn — DEFAULT verbosity. (2) The Rollback failed for ... warns printed it directly. (3) The one that matters most, found by the trace #2031 required rather than by the report: extractDeploymentEventError copies err.message verbatim into the durable deployments/{runId}.jsonl S3 sink, and the two rollback entry points did not agree — the standalone cdkd rollback command wired recordEvent with no masking at all, while a deploy's in-process auto-rollback masked with the DEPLOY's perResourceSecrets, 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 established drift.ts shape — a RetryLogger masking the CONCATENATED string, which is why RetryLogger.warn is optional in the first place — as a local maskingRetryLogger, plus maskedRollbackEventError for the event reason / error fields; name / awsErrorCode / requestId are left alone as AWS enum-shaped identifiers. The per-op secrets bag is HOISTED above the try in replaySingle and in the replayFailedOperations loop body: it was declared inside each case block, so the shared catch that 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's Failed to <op> <logicalId>: <AWS message> line logged the raw message at error while the recordEvent one 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 the ProvisioningError's CAUSE, formatError renders a CdkdError's cause as Caused by: <cause.message>, and handleError logs that at error level 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 new maskSecretsInError (in secret-redaction.ts, which stays a no-import leaf) masks the message of EVERY link in the cause CHAIN, cloning each with Object.getOwnPropertyDescriptors so markNonRetryable's non-enumerable symbol marker, $metadata / Code and name all survive for isMarkedNonRetryable / isThrottlingError / isTransientServerError and extractDeploymentEventError; 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. formatError walking 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, and cause is rewired to the CLONE in a second pass so a cycle is rebuilt among the masked copies rather than re-entering the originals. extractDeploymentEventError needed 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 --replace wrap 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 thrown Error's cause chain. #2038's acceptance item 3 (the withRetry call-site audit) is discharged in the PR body and extended the fix to DeployEngine.withRetry plus the two direct --replace re-create sites, where perResourceSecrets is populated before the provider call and so is in scope at every retried call; a resource with no recorded secret forwards byte-identically. The masking RetryLogger object itself is now ONE definition in the new src/deployment/masking-retry-logger.ts — rollback-executor.ts and drift.ts carried byte-identical eager copies, and it cannot live in secret-redaction.ts because that module is a documented no-import leaf while the helper needs RetryLogger. The deploy engine keeps a second, LAZY variant for its generic withRetry wrapper, 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 the maskSecrets CAPABILITY rather than a secrets map — a different mechanism needing CreateContext / UpdateContext threading, filed as #2050 rather than invented here. Covered by tests/unit/deployment/rollback-executor-log-masking.test.ts and tests/unit/deployment/deploy-engine-retry-log-masking.test.ts, both mocking only retry.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 captures error as well as warn (it discarded error with 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 — replacing perResourceSecrets.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. withRetry computes 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-stress reproduced on round 11 of 11 real-AWS runs (us-east-1, 2026-08-19 08:57:30Z) with UnknownError [name=InternalFailure http=500 requestId=ebf581cc-6072-5ffc-943a-e33312488615]. SQS answered a mid-propagation SetQueueAttributes with HTTP 500 and an empty body, so the SDK substituted its UnknownError placeholder, no message pattern could match, and 500 was in no status set. isRetryableTransientError now also consults isTransientServerError — HTTP 500 / 502 / 503 / 504, mirroring @smithy/service-error-classification's own TRANSIENT_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 to RETRYABLE_HTTP_STATUS_CODES: that one is read by isThrottlingError, which SEVEN isRetryable call sites across four files (describe-type.ts:67, dynamodb-globaltable-provider.ts x4, export.ts:1744, intrinsic-function-resolver.ts:5216) pass as a deliberately NARROW classifier — describe-type.ts states 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, since drift.ts would 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 drop cause (#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 SQS SetQueueAttributes, while the report is a Lambda CreateFunction whose message (role ... cannot be assumed) classifies fine, so an unreadable-message fix does not explain it. That residue is external input. Covered by tests/unit/deployment/retry-transient-server-error.test.ts (5 cases, built from the captured error shape, 2 verified to fail without the fix) and tests/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 with The 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 ONE warn line — MyFn: gave up after 26 IAM-propagation retries over 47.75s of propagation backoff (the full propagation budget) - <AWS message> — and each per-attempt debug line carries the running total (attempt 15/26, 25.75s backoff through this attempt), so --verbose yields a measurement rather than an inference. warn is 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.warn is OPTIONAL because drift.ts's revert threads a MASKING logger (issue 1914), so a required warn would have been silently satisfied by an unmasked logger.warn; that caller now threads a masked warn too, 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 the integ-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_PATTERNS spreads IAM_PROPAGATION_ERROR_MESSAGE_PATTERNS), ProvisioningError embeds the AWS message so the classifier matches, Cloud Control's failure path embeds StatusMessage so the CC route classifies too, ProvisioningError is not self-marked terminal (only ResourceUpdateNotSupportedError is), 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 by tests/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.sh now deploys with --verbose and 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 deploy now exits 2 when it leaves a resource unaddressed (issue #1960) — src/cli/commands/deploy.ts, src/cli/options.ts. cdkd destroy has 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 deploy printed 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 raise PartialFailureError (exit 2) after the work graph drains — placed there so a genuine deploy failure still wins the exit code, mirroring how destroy.ts checks totalErrors before totalSkipped. This can turn a currently-green pipeline red. The new --allow-unaddressed flag restores exit 0, taking the run-level PartialFailureError message 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 the RunCounts.skipped figure in cdkd events are 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 once DescribeCertificate.InUseBy empties — and because a shell || [ $? -eq 2 ] wrapper is NOT an equivalent workaround: cdkd deploy also exits 2 for MacroExpansionError and ResourceUpdateNotSupportedError, 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 unaddressed warning 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 a logger.warn, so a scraper reading only stdout loses it entirely rather than merely failing to match. (b) The events store: RUN_FINISHED now records result: 'FAILED' for such a run, matching what cdkd destroy has recorded for the identical outcome since #1752 — recording SUCCEEDED while 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 filtering cdkd events on result sees these runs flip from SUCCEEDED to FAILED. --allow-unaddressed does not touch either: the banner still warns and the run is still recorded FAILED, because the events store records what happened, not what the operator chose to tolerate. recordRunSucceeded was accordingly replaced by recordRunOutcome (same counts payload, caller-chosen result); recordRunFailed could not serve it, since it carries error metadata and no counts and the skipped figure is the only thing in the summary saying a resource survived. Covered by tests/unit/cli/deploy-unaddressed-exit.test.ts, which drives the real commander command and asserts on process.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 the RUN_FINISHED result and of the cancellation note, and --dry-run counting nothing). A 21st case in tests/unit/cli/deployment-events-run.test.ts pins the result at the layer that PERSISTS it — the deploy-side cases vi.mock that module, so they assert what deploy passes and never what the store records, and hard-coding 'SUCCEEDED' inside recordRunOutcome passed 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 bare return, 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 / UpdateContext now carry a maskSecrets capability (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 into logger.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 reach deployments/*.jsonl (only thrown errors do, and those already pass through maskSecretsInText), so this was log exposure, not persisted exposure. Why it needed more than a one-line mask: maskSecretsInText requires a RecordedSecretValues bag as its second argument, and no provider under src/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 shared SecretMaskingContext base with one optional field, maskSecrets?: (text: string) => string, which BOTH CreateContext and UpdateContext extend. Declared once and inherited rather than written twice, because create() and update() 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: RecordedSecretValues is 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 keeps src/provisioning/** free of a src/deployment/secret-redaction.ts import, and can be WIDENED later (to cover NoEcho parameters) by changing the callers alone. The codebase already had this shape: drift.ts hands withRetry a masking logger.debug rather 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 on this. 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.ts and cdkd drift --revert; the five providers that re-create inside their own update() pass no context, since they forward the outer call's properties and have none of their own. The drift --revert half 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's resolveReplayProps") re-resolves the state record back to plaintext, so a state record holding EnabledMfas: "{{resolve:secretsmanager:...}}" re-resolves to a plaintext string, trips not 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: resolveReplayProps deliberately 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 shared REPLAYING_STATE_CREATE_CONTEXT constant rather than mutating it, since maskSecrets is per-op and that object is shared by every op in a run. DeleteContext does NOT get the capability, and the first rationale for that was FALSE. It claimed every delete() 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-MEMORY stateResources, redaction happens at the save choke point on a COPY (redactStateForPersist → scrubResourceRecord), and the in-process rollback hands that same in-memory map to replayRollback, whose delete arms read current.properties straight off it. A delete bag CAN carry a secret. What is true is narrower and is now what the code says: no provider delete() 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 in DeployEngine.perResourceSecrets, while RollbackExecutorContext has 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 on RollbackExecutorContext plumbed 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. A NoEcho: true template PARAMETER is OUTSIDE that model by construction — the resolver redacts it in its own debug line (stringifyParameterForLog) but never RECORDS the value — so EnabledMfas: {Ref: SomeNoEchoParam} is NOT masked by this, and no masker built from a RecordedSecretValues bag could be: that map exists to rewrite a plaintext back onto the {{resolve:...}} expression it came from, and a Ref has none. Recording NoEcho values into it would also change what redactSecretsForState PERSISTS, so it is filed separately (issue #1998) rather than smuggled in here. That residual is persisted, not log-only — a NoEcho value quoted back inside an AWS error reaches deployments/*.jsonl, since the event masker reads this same bag — which is the opposite of the dynamic-reference case fixed here, whose errors already pass through maskSecretsInText; 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 by JSON.stringify: masking matches by literal occurrence, and stringification escapes ", \ and newlines, so super"secret-plaintext-value came through completely unchanged — i.e. every Secrets Manager JSON document, the commonest real secret shape. A second, independent reason points the same way: maskSecretsInText masks an exact whole-value match at any length but only SCANS for substrings of at least MIN_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: buildMfaConfigRequest takes the masker (typed as the contract's own SecretMasker, re-exported from src/types/resource.ts the way DeleteContext is, so providers still import only from types/ and never name the secrets bag — an earlier structural re-declaration bought nothing once types/resource.ts already imported the alias, and only created room to drift from the contract) and applies BOTH layers: a bounded maskDeep walk masks every string leaf and key before JSON.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-level typeof v === 'string' test because a secret nested in an object leaf is escaped identically. CognitoUserPoolProvider.update() gains a context?: UpdateContext parameter it did not have; it reads maskSecrets alone and deliberately does NOT consult desiredFromAwsReadback (no motivating shape, and the MfaConfiguration refusal 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 the drift --revert call fails 1; sharing one stack-wide bag across resources on the UPDATE path fails the per-resource isolation twin; and injecting replayingState: true at 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: createSecretMasker captures its bag BY REFERENCE with no size === 0 short-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 #1463 inverse fences asserted call.length === 3, which this change makes impossible; they now assert the context's key set is EXACTLY ['maskSecrets'], keeping the original power (still failing on replayingState and 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 that toHaveBeenCalledWith(a, b, c) is itself ARITY-STRICT, so five further assertions were IMPLICIT #1463 fences; the first pass relaxed them to expect.objectContaining({ maskSecrets }), which would have admitted replayingState: 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 injecting replayingState: true at the main CREATE site and watching exactly those fences red. One weak negative found on the way — not.toHaveBeenCalledWith(...) in rollback.test.ts is 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 / ServiceDiscoveryProvider no 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, new src/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) Five withRetry call sites threaded the raw this.logger: two ModifyListenerAttributes calls whose Attributes come from ListenerAttributes (create + update), and UpdateServiceAttributes / DeleteServiceAttributes whose payload comes from ServiceAttributes (create + both update arms). withRetry interpolates the AWS message VERBATIM into its per-attempt debug line and into the give-up warn summary added for #2018, and that summary prints WITHOUT --verbose. (b) withRetry then rethrows the RAW error, and all four of these providers' create / update methods interpolated error.message into the wrapping ProvisioningError, which deploy-engine.ts prints 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 on propagationRetries > 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 same try blocks (the partial-create cleanup warns) and two warns that name RESOLVED map data: convertTargets' malformed-entry line, which stringifies a whole rejected Targets element, 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 for DeleteServiceAttributes, and the two files must not disagree about it. The Targets line masks BEFORE JSON.stringify, not after, and that ordering is the fix rather than a detail: maskSecretsInText matches by literal occurrence and stringify escapes ", \ 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 three secret-redaction.ts documents ("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) => string shape RetryLogger needs) could not reach these sites because ELBv2Provider.create declared no context parameter at all. Both providers' public create() / 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. warn is 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 to error.message rather than to the assembled sentence, because only the raw value can reach maskSecretsInText's WHOLE-VALUE arm (any length); a longer sentence reaches only the substring arm, which ignores needles under 4 characters. The cause chain is deliberately left untouched so isRetryableTransientError's $metadata walk still classifies identically — asserted by a test, not just by reading. On the issue's acceptance item 3 (should RetryLogger take the capability directly instead of each caller hand-rolling the object): NO. RetryLogger is a structural type any caller already satisfies with a masking object — drift.ts's revert has done exactly that since #1914 — so a maskSecrets option 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 module src/provisioning/masked-retry-logger.ts and both providers call it. Scope is these two providers only — the generic provider-call retries in deploy-engine.ts and rollback-executor.ts are the same mechanism one layer up and are NOT addressed here. Covered by tests/unit/provisioning/elbv2-listener-attributes-retry-masking.test.ts (20 cases) and tests/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's message — 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, that withRetry logged 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 the Targets warn 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 the Targets line 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: createLoadBalancer and the four ServiceDiscovery namespace arms receive no masker at all, and the generic provider-call retries in deploy-engine.ts / rollback-executor.ts are 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: UpdateUserPool lands FIRST, so the pool keeps the NEW Policies / mutable fields while SetUserPoolMfaConfig is rejected and update() 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. describeUnsupportedMfaCombination is one pre-flight seam running ahead of every mutating call on BOTH paths: in create() it sits outside the try and before CreateUserPool; in update() before readLiveMfaConfiguration, i.e. before any AWS call at all. Rule 1 (#1977): the value cdkd would SEND for MfaConfiguration resolves to OFF while the SetUserPoolMfaConfig request carries a factor sub-block (hasAnyMfaFactorBlock — exactly {Sms, SoftwareToken, Email}MfaConfiguration, deliberately NOT WebAuthnConfiguration, which rides beside OFF on every passkey-only pool). MEASURED us-east-1 2026-08-19 (#1968) as a 100% rejection including SoftwareTokenMfaConfiguration {Enabled: false}, which is what settles the key on the block's PRESENCE. Rule 2 (#1975): the same request resolves MfaConfiguration to ON while Policies.SignInPolicy.AllowedFirstAuthFactors allows EMAIL_OTP or SMS_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. The SMS_OTP probe needed a pool with a valid SmsConfiguration (SNS-caller IAM role + ExternalId): without one, CreateUserPool refuses 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-EnabledMfas case 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 whole AuthFactorType enum outside the accepted pair, and the constant is as 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 a SetUserPoolMfaConfig request is actually BUILT. With no MFA-routed property, MfaConfiguration rides on the SINGLE CreateUserPool / UpdateUserPool call, 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 with AllowedFirstAuthFactors: [PASSWORD, EMAIL_OTP] and no MFA call comes up fine; only the later SetUserPoolMfaConfig(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 reaches ROLLBACK_COMPLETE on 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 ONE buildMfaConfigRequest call (with no logger, so the pre-flight is silent and applyMfaConfig'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 the SignInPolicy block while setting MfaConfiguration: ON, and omitting MfaConfiguration while adding EMAIL_OTP to 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: the MfaConfiguration is pinned to OFF warn 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 surviving deploys with MFA DISABLED arm true again rather than merely usually true. The suite grows from 118 to 143 runtime cases in tests/unit/provisioning/cognito-provider.test.ts (21 new it / it.each blocks, 7 removed or rewritten), including EIGHT must-still-deploy negatives that fence the rules against over-refusal (PASSWORD + WEB_AUTHN under ON, EMAIL_OTP under OFF, EMAIL_OTP under the ACCEPTED OPTIONAL, EMAIL_OTP + ON with no SetUserPoolMfaConfig on both the create and the update path, a factor cdkd does not know, a passkey-only pool under OFF, and seven mis-shaped Policies containers). 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, counting WebAuthnConfiguration as a factor block, rewriting rule 2 from the allow-list, dropping SMS_OTP from the deny-list, naming only the first offending factor, dropping the SmsMfaConfiguration arm of the named-block list, moving either seam after its mutating call, and each surviving shape guard in readAllowedFirstAuthFactors all turn the intended cases RED. Two comments were corrected to say only what a test holds, since a false fence claim suppresses the retest: readAllowedFirstAuthFactors traverses with ?. rather than the typeof === 'object' guard config-shape.ts uses 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 — includes is 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. Its Array.isArray sibling 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: waitForReplicaGone at 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), and waitForTableGone at 600 polls (~12 min) on a late loop SUCCESS. destroy-runner.ts runs the whole thing under ONE DEFAULT_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). withResourceDeadline does NOT cancel what it wraps, so an overshoot both replaced AWS's own actionable Cannot delete table while indexes are being ... sentence with a generic ResourceTimeoutError that never mentions indexes AND left the polling loop running behind a run that had already reported failure (2). And deleteTableWithIndexBusyRetry's inner isRetryable is index-busy-ONLY by design (the classifier is a single message regex, which is the whole safety argument for retrying a ResourceInUseException at all), so a THROTTLED DeleteTable escaped to destroy-runner.ts's outer loop — which DOES class throttles as retryable and re-enters delete() 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 the AWS::DynamoDB::Table path 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 of delete() and drawn on by every wait below it: the #1521 gate, waitForReplicaGone, the per-retry re-arm, waitForTableGone, and (on the Table side) the --remove-protection ACTIVE wait each ask pollsWithinDeleteBudget(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). deleteTableWithIndexBusyRetry gained an optional shouldKeepRetrying predicate, 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 an ElapsedBudgetRegistry keyed 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_MS is 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 the Table single 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 generic ResourceTimeoutError with the loop still running, so a bounded stop is a strict improvement there rather than a trade. Both providers now declare getMinResourceTimeoutMs() returning 26 min + a 4 min margin = 30 min, which EQUALS today's DEFAULT_RESOURCE_TIMEOUT_MS, so at default settings nothing moves (max(30m, 30m)); what it buys is that a lowered global --resource-timeout can 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: the DeleteTable / UpdateTable calls themselves, the outer loop's own 5 + 10 + 20s backoff (which elapses between delete() 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-protection ACTIVE wait was MEASURED by the allowance but not CLAMPED by it — it runs FIRST and its predicate is ACTIVE and no transitional index, so on cdkd destroy --remove-protection against 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. Its Table twin 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 on withRetry'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 1s after cdkd had been deleting for twenty-six minutes); every such message now names which clock ended the wait, via one shared deleteBudgetExhaustedNote and a clampedFromCap field on waitForIndexesSettled. (4) Worse, a budget-clamped waitForTableGone turned a SUCCESSFUL delete into a reported failure — DeleteTable had 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 enclosing catch's Failed to describe ... before delete wrapper — a describe failure that never happened — so an already-actionable ProvisioningError now passes through verbatim. Plus four smaller ones: attemptsWithin failed 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 is performance.now() rather than Date.now(), because a forward NTP correction on a wall clock drains the allowance instantly mid-delete; and both getMinResourceTimeoutMs JSDocs plus docs/cli-reference.md now state that the self-report is per TYPE, so --resource-timeout 5m for 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 the release() calls deleted, because the stub left the table gone and the second delete() 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; the Table retain-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 read getAwsClients() — 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 deploy defaults to --stack-concurrency 4 and re-points the singleton per stack, so two stacks in different regions race for one mutable global and stack B's GetSecretValue / GetParameter can execute against stack A's client (the value is redacted on its way into state, so nothing downstream shows which region answered); cdkd scrub --all installs its clients ONCE while resolving per-stack regions, so a region-B SecureString whose region-A namesake is a plain String was classified PUBLIC and its plaintext left in state.json — the GHSA-p5qg-v9gv-hc7w disclosure class rather than merely a wrong value; and cdkd drift --revert WRITES 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 ten setAwsClients call 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. AwsClients gained configuredRegion (the EXPLICITLY configured region and nothing else), credentialConfig (the profile + explicit credentials half, deliberately WITHOUT the region, and CLONED so a caller cannot mutate the source instance's credentials through it) and withRegion(region); the resolver's clientsForRegion derives 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. Reading process.env was 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's loadConfig wraps the chain in memoize) while switchRegion keeps mutating AWS_REGION per 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: clientOptions OMITS region from 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, and ssm pinning us-west-2 says nothing about a secretsManager constructed a moment later, after a sibling stack's finally restored AWS_REGION, which pins us-east-1. That is Site 1 of this very issue surviving inside the arm meant to fix it. Only a CONFIGURED bag passes region to every member and is therefore internally consistent, and withRegion always 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 stub getAwsClients() with a plain object green is now a separate, explicitly named arm — an object that cannot DERIVE a sibling (withRegion absent) 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: --profile DOES reach a freshly constructed client on its own, because src/cli/program.ts sets process.env.AWS_PROFILE in a preAction hook for every command, so carrying profile is belt-and-braces rather than what stands between a user and the wrong account. What genuinely has no environment path is an explicit credentials object, plus any library caller that constructs AwsClients directly and never runs that hook. --role-arn needs nothing carried at all, since applyRoleArnIfSet exports the three AWS_* credential variables. Rebound sites: both dynamic-reference lookups ({{resolve:secretsmanager:...}} / {{resolve:ssm:...}}), the AWS::SSM::Parameter::Value<> template-default GetParameter, the EC2 DescribeInstances / DescribeLaunchTemplates attribute lookups (a foreign-region client answers NotFound, degrading to the physical-id fallback those branches exist to avoid), and Fn::GetAZs — which additionally fixes an explicit foreign region (Fn::GetAZs: us-west-2 from a us-east-1 deploy), since DescribeAvailabilityZones lists the AZs of the region the CLIENT points at and its region-name filter 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::GetAZs is the first. Its argument is TEMPLATE-derived and can arrive through an Fn::ImportValue or a parameter, and the SDK substitutes a region into the service hostname — evil.example.com# yields https://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 reach withRegion (charset-based: lowercase alphanumerics and hyphens, which cannot express a host delimiter, and deliberately not an AWS region registry, since eusc-de-east-1 and friends keep arriving; clientsForRegion keeps 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, and cachedAvailabilityZones is module-global so one degenerate answer would be replayed for the rest of the process. The second gated path is Fn::GetStackOutput's Region, which is PRE-EXISTING rather than introduced here but has two trusted sinks and is one call away from the same gate: a CloudFormationClient region (a SigV4-signed DescribeStacks to 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 reaching clientsForRegion itself now THROWS rather than falling back to the ambient: for scrub / drift / import, whose region is state-derived, falling back means reading ANOTHER region — which is the disclosure, so a stopped command is strictly better. The GetCallerIdentity in resolveAccountIdentity is 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 (no env.region) run with neither --region nor AWS_REGION, the region cdkd computes is its hard-coded us-east-1 fallback, so the lookup now goes there — where before it followed the ambient clients to whatever ~/.aws/config said. The new behaviour is the consistent one, since us-east-1 is 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 explicit env.region is unaffected — every caller prefers it. Tests: 32 units across two new files. The resolver file fakes the SDK client CLASSES rather than getAwsClients(), 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-B SecureString recorded as secret while region A holds a String namesake) and four negative cases pinning that the common path constructs no extra client. The AwsClients file 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 the Fn::GetAZs region gate at BOTH layers reds the refusal case with the fake client recording a construction for evil.example.com#, reverting each of the two EC2 rebinds, the Fn::GetAZs fallback arm, the empty-AZ refusal and the client cache's region key reds exactly one case each, deleting canonicalizeRegion from the seam reds the case-folding test, removing the Fn::GetStackOutput gate lets US-WEST-2 reach the S3 state key verbatim and stops evil.example.com# being refused, and restoring the one-member short-circuit reds the divergent-bag case with the resolver for us-west-2 receiving us-east-1's password — the disclosure itself, reproduced. Live coverage extends tests/integration/dynamic-ref-cross-region with a fourth arm and a new phase: a MIXED-TYPE source parameter (one shared name that is a plain String in region A and a SecureString in 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 a cdkd scrub --all phase 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's String namesake, 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, and switchRegion mutates process.env.AWS_REGION for 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, and cdkd scrub --all could 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 whose Type is not a definitive SecureString is 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, and rollback-executor.ts now builds ONE resolver per replay rather than one per op. The other half — the lookups themselves running through the process-ambient getAwsClients() singleton — was issue #1957 and is now closed too (see the entry above it). Live-covered by a new tests/integration/dynamic-ref-cross-region fixture (two regions, one shared parameter name, String and SecureString arms, asserting each stack resolves its own region's value and that state holds the unresolved expression and neither plaintext).
  • AWS::Cognito::UserPool MFA: an enable-on-update no longer hits AWS's refusal, a malformed MfaConfiguration is refused instead of silently disabling MFA, and three silent cases now warn (issues #1925 / #1932) — src/provisioning/providers/cognito-provider.ts. update() now withholds MfaConfiguration from UpdateUserPool when an MFA factor is declared, the same gate create() already had: measured against real AWS, UpdateUserPool with ON and 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 later SetUserPoolMfaConfig. Withholding is safe because UpdateUserPool does NOT reset an omitted MfaConfiguration — also measured, with controls proving the same call DOES reset an omitted AutoVerifiedAttributes, i.e. that API's "unspecified attributes take their default" rule holds field by field rather than API-wide. A declared null / non-string now goes through requireConfigString (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 to MfaConfiguration: OFF warns; and every substituted value is reported as effectiveProperties so the substitution cannot read back as permanent drift. Live-covered by a new CDKD_TEST_UPDATE phase in tests/integration/cognito exercising both transitions (OFF -> ON, and an undeclared downgrade announced against the live OPTIONAL).
  • The index-busy DeleteTable retry budget is PER TYPE, and AWS::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's Cannot delete table while indexes are being created, updated, or deleted shipped at withRetry's default of 8 retries, shared by both DynamoDB types. The live dynamodb-gsi-update run 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) and GLOBAL_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_MS of sleep plus a DescribeTable round trip — the live per-attempt gaps were 73/74/77/80/80/80s), so a 60-poll re-arm is ~72s and the loop is N x 72 + (8N - 17) seconds: ~10.4 min at 8 (the ~8.8 min previously 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-min DEFAULT_RESOURCE_TIMEOUT_MS by the time it reaches the loop differs. Table has 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. GlobalTable may 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 where waitForTableGone provably 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-region TableV2 shape, whose user-visible result is the generic ResourceTimeoutError the 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 about GlobalTable'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 the Table side, 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" because withResourceDeadline does NOT cancel what it wraps, and an overshoot leaves the loop issuing DeleteTable in 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 from INDEX_SETTLE_POLL_INTERVAL_MS instead of a literal ~1s sitting next to a derived total; waitForTableActiveAfterUpdate reads 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 millisecond withRetry asks 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: Table must 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); GlobalTable must 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 the Table budget asserted as a premise not to fit there and the late-success total asserted as the tracked overshoot it is. The Table provider'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 IMPORTS TABLE_ACTIVE_WAIT_ATTEMPTS / TABLE_GONE_WAIT_ATTEMPTS rather than copying them, after a 60 -> 900 mutation of the first left all 51 tests green. waitForReplicaGone and waitForTableGone now read INDEX_SETTLE_POLL_INTERVAL_MS too, 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.Name is 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.outputs is keyed by output NAME and an output carrying Export: 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 an Export.Name was 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, so redactByPath positioned that leaf by a source belonging to a different output and persisted one output's {{resolve:...}} reference as the other's value into state.outputs and 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 scrub had 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 shared outputs-export-alias.ts so 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 because resolveOutputs runs at most once per deploy: its two call sites are mutually exclusive and deploy() resets the bag, a property now pinned by a test since the decision rests on it. (3) An Export.Name that 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 --fail stops 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 helper src/utils/regexp.ts now shares with cdkd diff's renderer, since a resolved Export.Name passes 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 — a continue, a discarded local, a throw from an Fn::Join sibling 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 intrinsic Export.Name best-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's Fn::ImportValue on 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 in docs/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 kept cdkd diff --fail red 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's else arm; 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 a Ref, so the value is an Fn::Join object 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::Table destroy absorbs the transient index-busy DeleteTable refusal (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's delete() issued a single DeleteTable, so a table whose GSI was mid-transition when destroy reached it surfaced AWS's Attempt to change a resource which is still in use: Cannot delete table while indexes are being created, updated, or deleted. as a hard PartialFailureError with 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 sibling AWS::DynamoDB::GlobalTable and left Table untouched. The change: rather than spell the classifier, the budget, the bounded re-arm and the warning a second time, they were LIFTED into a new src/provisioning/dynamodb-index-busy-delete.ts that BOTH providers read — the way dynamodb-warm-throughput.ts already holds the two WarmThroughput rules. No AWS-FACING behaviour changed for GlobalTable — call order, budgets, the #1521 pre-delete gate and replica teardown are identical, and the dynamodb-globaltable integ 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 plain ResourceInUseException, 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 against destroy-runner.ts's 30-minute per-resource deadline, turning an actionable AWS message into a generic ResourceTimeoutError. That deadline wraps the runner's whole OUTER loop (up to 4 delete() calls), and the budget survives it only because this refusal matches no RETRYABLE_ERROR_MESSAGE_PATTERNS entry (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 for AWS::DynamoDB::Table only — 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; on AccessDenied that 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 rides tests/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 and set -o pipefail aborts 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's cleanup also 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): redactByPath opened 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, reported recordsChanged for 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). Testing secrets.has(bag) is not the fix: it is precisely the rewrite the guard prevents, one input over. Fix: PathSourceRules gains 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 holding prod, inside {{resolve:secretsmanager:prod/db:SecretString:pw}}), corrupting a persisted expression on every cdkd scrub over already-clean state. The same bound is spelled out in the generic walk, which is where a leaf reached with NO source (the journal's previousState, every attributes bag) 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.redactStateForPersist walks EVERY record in the state map while perResourceTemplateProps is 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 intermediate saveStateAfterResource, the pre/post-rollback saves, and Ctrl-C. Keyed on the caller, a rotation that FAILED and was rolled back to :AWSPREVIOUS was persisted as :AWSCURRENT, so the next deploy saw NO_CHANGE and the rotation was silently never applied — invisible to cdkd drift because 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 exactly STATE_DERIVED_RULES (the replay resolved its bag from the journaled record) and STATE_SOURCED_READBACK_RULES (the #1900 observed walk projects from the properties beside it). The draft's separate constant for the cdkd scrub bag collapsed into TEMPLATE_SOURCED_RULES once the axis moved (every template source is a different generation, whichever command supplied the bag), and a new STATE_SOURCED_CROSS_GENERATION_RULES covers the one caller a derivation cannot serve — cdkd scrub's observedProperties walk, which repositions properties onto 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 reason properties did — its bag is persisted state and its source is today's template — but the two constants differ on descendArrays alone, and that flag cannot fire there: a template Output Value is 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 — outputsTemplateSource gaining 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 in observedProperties on 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 its perResourceSecrets entry is empty — the #1900 shape). Fix: a KEYED array descent pairing elements by an identity field (Name / Key, the shape canonicalizeTagListsDeep already 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, because descendArrays rests 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 new tests/integration/secrets-array-nested fixture 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 diff reports 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 deploy learned to persist an Outputs-only change in #875, but cdkd diff never did — DiffCalculator.calculateDiff compares only currentState.resources vs template.Resources, and the deploy engine's compensating logic lived entirely in its own no-change branch. A stack whose Outputs gained, changed, or lost an entry with a byte-identical Resources section still printed ✓ No changes detected and cdkd diff --fail exited 0, while the apply DID write new outputs and republish the exports index. In the motivating chain (a producer that gains an Export.Name because a downstream stack started referencing it) the preview actively steered the user away from the deploy that repairs the consumer's Fn::ImportValue; the reverse case — an export REMOVED, which can break a consumer — was hidden identically. cdk diff and 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 edits src/deployment/deploy-engine.ts, which is in BOTH the integ-broad CROSS_CUTTING_REGEX and integ-destroy's scope, so a diff-only fix would have acquired two real-AWS merge gates — and that file was concurrently held by the live fix/1919 lane, 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: computeOutputsDiff compares bag KEY by bag KEY, which IS the outputMapsEqual predicate the deploy engine gates its persist on (no interpretive layer that could drift), and a unit anti-drift fence reads deploy-engine.ts and asserts its three mirrored semantics — the Export.Name alias 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: resolveTemplateOutputs rebuilds the exact bag DeployEngine.resolveOutputs persists (condition-false outputs skipped; an Export.Name stored as a SECOND key holding the same value, which is what Fn::ImportValue resolves against). Because the diff resolver is BEST-EFFORT — it returns the ORIGINAL value on failure rather than throwing, unlike the deploy side whose catch stores undefined — the unresolved detector is a DEEP containsIntrinsic walk; a shallow check would pass a half-substituted Fn::Join through 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. computeStackDiff now 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. nodeHasChanges is true for an Outputs-only delta (the arm that stops "No changes detected" and makes --fail exit 1), renderOutputChangeLines prints an Outputs: block, and diffTreeToJson carries outputChanges (always present, like children, for key-set stability). Rows are keyed by the PERSISTED bag key, so an output with an Export.Name shows 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. --recursive is 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.ts needed no change (the gate and the renderer both live in diff-recursive.ts). Four defects found by the review round, each now pinned by a mutation probe: (1) the unresolved detector missed undefined — resolve returns it WITHOUT throwing for a constructible-but-unknown attribute (StreamArn / PolicyId / VpcId / RoleId), and since JSON.stringify drops an undefined-valued key, state can never hold one, so such an output was a PERMANENT phantom ADD printing new: undefined with --fail exiting 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 missed Fn::Sub's laundered placeholder: resolveSub catches a genuine Ref / GetAtt miss, 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 cited Fn::Join, which was WRONG — resolveJoin propagates the throw — and is corrected. (3) A top-level Fn::If selecting Ref: AWS::NoValue returns a bare sentinel symbol (resolveValue strips it only INSIDE containers), likewise unpersistable and likewise a permanent phantom. (4) Security: the new renderer prints the STORED previous value, and StackState.outputs is not guaranteed redacted for every schema version — a record written before the GHSA redaction holds resolved plaintext (the condition cdkd scrub exists to repair), so cdkd diff, a command routinely run in CI, would have put a secret in build logs. oldValue is now WITHHELD (replaced by oldValueRedacted: true) whenever the desired side is still a {{resolve:...}} expression and the stored side is not, in the text renderer and --json alike. 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 an Export.Name is 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 real IntrinsicFunctionResolver (computeStackDiff passes it directly, not through DiffCalculator.resolveBestEffort's returns-the-original wrapper), so the tests stop agreeing with a wrong contract; and INTRINSIC_KEYS is now exported from diff-calculator.ts and 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 used Fn::Sub (templateUsesSub, walking nested Fn::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, and buildDeletedSubtree dumps 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 as undefined) 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; failedKeys is now returned and excluded before the warn is computed. (d) Control-char stripping reached names but not rendered VALUES, and JSON.stringify escapes 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 --json is 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 new INTRINSIC_KEYS const had been inserted between CanonicalizePropertiesFn's JSDoc and its declaration, orphaning the doc, and diff-recursive.ts kept 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, intrinsic Export.Name both 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, explicit null vs absent key, the no-phantom case, treeHasChanges true 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 the nodeHasChanges Outputs 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 --fail to exit 1, no "No changes detected", an Outputs: section naming [+] CdkdOutputsOnlyBucketArn [export], and the entry in --json outputChanges with export: true while 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 an Fn::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; outputChanges is an additive --json key.
  • cdkd drift reconciles 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.ts imported nothing from secret-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 while readCurrentState returns the resolved plaintext, and nothing reconciled the two: --revert handed that baseline to provider.update, setting the live property to the literal token; --accept persisted the AWS-current plaintext into state.json; and the comparison could not compare at all — PR #1899 had bought quiet by making calculateResourceDrift SKIP every state leaf holding a {{resolve: string, which also made a console edit of a secret-bearing property invisible to cdkd drift and unreachable by --revert. Fix: resolveStateSecretExpressions re-resolves the secret references a STATE bag holds — the synth-free counterpart of rollback-executor.ts's resolveReplayProps. The comparison resolves the baseline FOR COMPARISON ONLY (observedProperties / properties untouched) and runRevert re-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-resource RecordedSecretValues AND the set of paths that produced one; redactDriftChanges rewrites both sides of every PropertyDrift at the moment the outcome is built — the single choke point behind the human report, the --json payload, both plans, and the value --accept persists. 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 --accept writes to state. At a known-secret path the only value ever shown is the expression itself; anything else becomes *** (undefined / null excepted, since an absent key discloses nothing), and --accept refuses 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.path is masked too — redactSecretsForState walks 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 --accept refuses it through the SAME acceptRefusalReason predicate printAcceptPlan asks (so a --dry-run cannot promise a write the run refuses), because setAtPath would otherwise invent a key literally named *** and, with an undefined value, 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-unredacted awsProperties. carriesRecordedSecret's substring branch carries the same 4-character needle floor secret-redaction.ts uses 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: collectDynamicReferencePaths seeds 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 whose observedProperties lack the secret's parent drifts at the ANCESTOR with the whole AWS subtree, plaintext included, as awsValue. Every failure mode of the new lookups is per-resource, never fatal. cdkd drift previously made no secret calls at all, so a deleted secret, a rotated-away version, or a least-privilege role lacking secretsmanager:GetSecretValue / ssm:GetParameter would 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 --revert reports it as a per-resource failure with its own message rather than a misleading AWS 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-secure is the one spelling for which "the token is already what AWS holds" is false: CloudFormation resolves it SERVER-side, so a record adopted by cdkd import --migrate-from-cloudformation carries the literal in state while AWS holds the plaintext. Both halves follow — the path must be masked (diffAt never descends arrays, so an ECS Secrets[] drift otherwise reports the whole array with the plaintext in it), and preserveLiveValuesAtUnresolvedTokens runs before provider.update so 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: resolveDynamicReferences substitutes token by token, so a mixed secretsmanager + ssm-secure leaf 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 in docs/cli-reference.md. Every AWS error string a revert prints now goes through maskSecretsInText: the payload carried resolved secrets and AWS quotes the offending value, the fence deploy-engine.ts already applies to its own provider calls. --accept keeps 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 --accept on 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 against properties, with STATE_SOURCED_READBACK_RULES rather than the rollback executor's STATE_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 ABSENT awsValue at a position known to hold a secret is dropped rather than reported — a write-only credential (MasterUserPassword and friends; RDS / DocDB / Neptune / ElastiCache / Cognito declare no getDriftUnknownPaths) 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, --accept writing undefined and so DELETING the {{resolve:...}} reference out of properties, and --revert re-pushing the credential every run. And preserveLiveValuesAtUnresolvedTokens registers 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 into state.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 the ssm-secure plaintext 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 in effectiveProperties, 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 in drift.ts and docs/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 a TypeError. The secrets-dynamic-ref integ 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; --accept refusing that path while leaving every secret leaf on its OWN expression and no mask in state.json; a PRE-FIX-shaped state record seeded straight into the state bucket (plaintext in observedProperties, expression in properties — the shape a user has after running --accept on an older binary, and one the CLI cannot be driven into) so the positioned --accept redaction has real-AWS coverage rather than only a mutation probe; --revert leaving the live Lambda holding the RESOLVED value with every sibling reference still resolved; and a second --accept recording 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 --accept redaction 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::Sub leaf 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 a Ref, hence an Fn::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 the secrets-dynamic-ref fixture once #1910 restored the collision it had been dodging: the live Lambda received both values correctly while state persisted the :AWSCURRENT spelling 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 into recordedSecretExpressions, not only the ssm SecureString ones. Nothing had to ask the set about a secretsmanager reference (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 on isSecret: an ssm reference whose Type came 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) positionByIntrinsicSkeleton builds an anchored pattern from the intrinsic — literal parts escaped, non-literal parts wildcarded as [^}]* so a wildcard cannot cross a token terminator, with Fn::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 (an Fn::Join with an EMPTY delimiter and consecutive non-string parts; an Fn::Sub with 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 realistic Fn::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 bound MAX_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's source rather 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 NO isKnownSecretExpression test 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 answer false, 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 the Fn::Sub escape, removing the resolver's recording, and widening the recording gate to isSecret each 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 differing Ref in 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. The secrets-dynamic-ref integ 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 called redactSecretsForState / scrubResourceRecord with 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's redactRollbackRecord, 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 in deploy-engine.ts (the outputs' unresolved values are captured into a new outputsTemplateSource field, the sibling of perResourceTemplateProps, and all three outputs-redaction sites route through one redactOutputs helper so they cannot drift), cdkd scrub's synthesized template, cdkd import's pre-resolution bag (captured BEFORE resource.properties is overwritten), and the rollback executor's JOURNALED previous properties. The journal is the consequential one: resolveReplayProps RE-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 / attemptedProperties are this deploy's desired bags and take the TEMPLATE bag, while previousState was read back from STATE and positions itself through scrubResourceRecord with no source (the #1900 fallback). The ssm/ssm residue the issue also names is closed by giving secret-redaction.ts a module-level recordedSecretExpressions SET, uncollapsed by value: a secretsmanager reference is secret by SPELLING, but an ssm one is secret only when its parameter is a SecureString, 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 so isKnownSecretExpression reads 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). The secrets-dynamic-ref integ 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 reads password again, so the state-expression and diff --fail assertions 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, resolveImportedProperties caught it and persisted the RAW template shape — which equals the redacted form, so every assertion passed while redaction never ran. Its expect(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, but resolveSSMReference fetches with WithDecryption: true, so a plain ssm reference 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 in state.json, the rollback journal, cdkd state show / diff / drift output), just out of the advisory's scope. It was left as a documented "Known limitation" because the resolver's isSecret gate keyed off the reference's SPELLING, and plain ssm is the same spelling for both a public String parameter and a secret SecureString one. Fix: decide by the parameter's TYPE, which is free — Parameter.Type rides on the same GetParameter response that carries the value, so classifying a reference costs no extra API call. resolveSSMReference now returns { value, secure }, and a secure reference is recorded into recordedSecretValues exactly like a secretsmanager one, so every existing choke point inherits it unchanged: redactStateForPersist / scrubResourceRecord store the expression, the rollback-journal writer redacts the same way, maskSecretsInText keeps it out of Fn::Join / Fn::Sub debug lines, cdkd scrub cleans it out of state an older cdkd wrote, and the rollback replay's resolveReplayProps re-resolves it before handing the bag to the provider (its comment claimed plain ssm "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). A String / StringList parameter 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": skipDynamicReferences must 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 passes WithDecryption: false, which returns a SecureString's encrypted blob (never substituted, never cached, never persisted) while String / StringList are unaffected by the flag and resolve exactly as before. The verdict is then remembered per expression in a module-level secureStringSsmReferences set (cleared alongside cachedDynamicReferences by resetAccountInfoCache), 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 the SecureString / String / StringList split, the WithDecryption polarity 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, an Fn::Sub-embedded reference, and the end-to-end redactSecretsForState round trip. The secrets-dynamic-ref integ gained a real-AWS arm: verify.sh creates a SecureString parameter out of band (CloudFormation cannot create one) and asserts it is really a SecureString before proceeding, the consumer Lambda references it through the plain ssm: form, and the run asserts the live Lambda carries the DECRYPTED value while state holds the expression, the String parameter 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 / scrub output, and cdkd diff --fail exits 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. That cdkd diff --fail assertion 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:password and :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 an Fn::Join result); 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::Split over 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. resolveSplit refused any non-string value with Fn::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 where AWS::Route53::HostedZone.NameServers resolved 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-returning Fn::GetAtt — AWS::EC2::VPC.Ipv6CidrBlocks, ... — reaches the same refusal. The BEHAVIOR is deliberately unchanged: an array is still refused, because real CloudFormation rejects Fn::Split over a list too, so such a template was never valid CFn — accepting it would let cdkd deploy templates that cdkd export / cdkd import --migrate-from-cloudformation then 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 the Fn::Split and use the value directly. A non-array non-string keeps its own distinct refusal (and reports null rather than typeof's misleading object), so the two cases can never be confused. ResolverContext carries 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 !GetAtt spellings (splitting on the FIRST dot only, so a nested stack's Child.Outputs.Key renders [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 a Ref to a CommaDelimitedList / List<Number> parameter — the SECOND genuinely reachable array source, via coerceParameterValue. The remedy is per-source: the Route 53 example and the #1868 workaround note are emitted ONLY when the value actually is an Fn::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 throw IntrinsicResolutionRefusalError rather than a bare Error, matching the deliberate refusals already in the file; the #1740 laundering path is NOT reachable here today (an Fn::Sub ${...} placeholder cannot contain an Fn::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's evaluateConditions absorbs any failure per condition and downgrades it to false, so an Fn::Split-over-a-list inside a Conditions entry is silently laundered today (by both classes alike — no regression). Five of the class's six throw sites are now markNonRetryable (#1838) — both Fn::Split refusals plus the three Fn::Sub-reachable ones that were already shipping unmarked (guardedPhysicalIdFallback's ARN / URL hard-fail, the --strict-getatt rejection, rejectPlaceholderArnAttribute). The criterion is the one retryable-errors.ts documents — "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 like MyDependencyViolationHandler puts DependencyViolation (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 outside withRetry on the flat path, by two routes: NestedStackProvider.create runs a child DeployEngine.deploy() and re-throws, and the parent wraps create() in withRetry, so inside a nested stack each retry re-runs a full child deploy plus rollback across the ~47s schedule; and under --strict-getatt an OUTPUT-resolution failure was re-wrapped in a fresh Error with no cause, which DROPPED the non-enumerable marker while inlining the refusal's full text (including the logical id) into the new message — so deploy-engine.ts now threads { cause: error } there, which isMarkedNonRetryable's .cause walk follows. Marked at each THROW rather than in the constructor because exactly ONE site can genuinely heal: the #1730 fabricated-account guard, where getAccountInfo caches a fabricated answer for only 10s so a later attempt succeeds. src/utils/error-handler.ts's JSDoc and .claude/rules/architecture.md now enumerate all six sites accurately, split by whether they are Fn::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 bare Errors (Resource ... not found for Fn::GetAtt, Ref ... not found) and three further cause-dropping re-wraps in deploy-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's evaluateConditions catches everything per condition and downgrades it to false, so an Fn::Split-over-a-list inside a Conditions entry IS silently absorbed today — by both classes alike, so nothing regresses, and a unit test now pins that behavior alongside the Fn::Sub variable-map PROPAGATION the class rationale rests on. Tests: the array refusal (wording, item count, singular/plural), the real-world NameServers shape against a list-valued state record, both Fn::GetAtt spellings 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 supported Fn::Join-then-Fn::Split route), and the error class + code.
  • An IMPORTED hosted zone records the same NameServers attribute 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. importHostedZone returned attributes: {} from BOTH branches, so a zone adopted through cdkd import / cdkd import --migrate-from-cloudformation had no NameServers in 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), constructAttribute has no AWS::Route53::HostedZone case, and resolution fell through to guardedPhysicalIdFallback, which warns and hands back the zone id STRING. A downstream Fn::Join — what CDK's zone.hostedZoneNameServers emits — then threw Fn::Join's second argument must be a list, resolveOutputs caught 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.getAttribute does 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 leaving constructAttribute untouched. The --resource override branch pays NOTHING: its GetHostedZone verification response already carries the delegation set, so the same call now supplies the attributes. The NAME-lookup branch pays one extra GetHostedZone, because ListHostedZonesByName returns no delegation set. Both normalize ?? [] — a LIST, never a comma string — exactly as createHostedZone / updateHostedZone / getHostedZoneAttribute do, so an imported zone and a deployed one resolve IDENTICALLY and no phantom drift is introduced; both also record Id, which is what the create path records. Both also record Id, which is what the create path records — STRIPPED of the /hostedzone/ prefix with the same replace createHostedZone uses, because --resource MyZone=/hostedzone/Z123 is accepted (the SDK's idNormalizerMiddleware removes the prefix on the wire) and recording it verbatim would persist an Id a deploy never produces. The extra read is BEST-EFFORT and never fails the import, matching AppSyncProvider.childImportAttributes (issue #1728) and importRecordSet's adopt-verbatim fallback: import.ts only aborts when ZERO resources import, so a throw here would cost the row under --migrate-from-cloudformation at 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 spells attributes: {} 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 while route53:GetHostedZone is denied would LOSE its stored NameServers, i.e. state strictly worse than before this change. {} falls through and preserves it. The one failure that does NOT degrade is NoSuchHostedZone between the ListHostedZonesByName and 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 denied ListHostedZonesByName still propagate as a failed row. 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 plain cdkd deploy does NOT heal the record: deploy-engine.ts continues on NO_CHANGE, so an unchanged zone never calls update(), and kickOffAutoRefreshObservedProperties refreshes observedProperties, a different field. Tests: a new file covering both branches x (populated / empty / absent delegation set), the NoSuchHostedZone -> null regression on BOTH branches, the degraded arm asserting adoption + an EMPTY map + the warning, an import.ts-level arm proving a prior NameServers SURVIVES a re-import whose read fails, the prefixed-override Id normalization, a parity assertion that the imported and created attribute sets are toEqual, and an END-TO-END block that feeds the import's OWN returned attributes to the real IntrinsicFunctionResolver and joins them — with the pre-fix {} shape kept beside it as a negative control. Six mutation probes (each branch reverted to attributes: {}; 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 }; the NoSuchHostedZone decline 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-up GetHostedZone so those tests exercise the SUCCESS path rather than the catch. Live-tested by a new tests/integration/route53/verify.sh Phase 2.5 against real AWS: the --resource branch re-adopts the deployed zone and a redeploy re-resolves BOTH Outputs (the Fn::Join and the bare list-valued Fn::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 disables import.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 immutable HostedZoneId from a Ref to 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 two NameServers outputs from state first, which is what makes it able to fail at all -- cdkd import carries existingState.outputs forward 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 flips outputsChanged and re-persists them (green); proven by a mutation probe that blanks the imported zone's attributes to {} -- 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 synthesized Name is an unresolved Fn::Join and auto mode correctly declines the row -- asserted as a decline, which doubles as a tripwire for issue #1897.

Recently Implemented (2026-08-13):

  • The handledProperties wiring 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's unwrap peeled as T / satisfies T / parentheses but NOT the ! non-null assertion, while its upward twin climb DID — so properties!['WarmThroughput'] contributed NO evidence while the runtime-identical properties['WarmThroughput'] contributed the full set. Both directions now share one isTransparentWrapper predicate 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: wired is a floor of ONE surviving read, so --check printed its clean-tree OK — ... 0 gaps line byte-identically through a real degradation. MEASURED on dynamodb-table-provider.ts: respelling the ONE delegated read inside declaresWarmThroughput() drops WarmThroughput's delegated evidence plus its getDriftUnknownPaths / readCurrentState seeds, with byte-identical output. #1808 rewrote that very read into the properties !== undefined && ... spelling for exactly this reason, and its in-code comment says so — an independent confirmation. (Stated per-site because breadth matters: respelling EVERY WarmThroughput read instead removes the last evidence and is caught as an ordinary gap, 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. --check now 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/_generated through, and comparing resolve() 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's git diff --quiet step covers only the UNSTAGED spelling of a deletion (the path stays in the index, so the re-created file reads as modified; a staged git rm makes it untracked and the diff exits 0). Rather than a sixth guard, usability is defined POSITIVELY (assessBaseline) as three conditions: SELF-CONSISTENT (its WHOLE summary equal to the summary recomputed from its own classes — not two hand-picked count fields, so a field added later is constrained automatically), EVIDENCE-BEARING (every wired property actually carrying evidence and seededBy — 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 / seededBy blanked while every count stays intact, so the mitigation announced graded 1138/1138 having 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 DISTINCT Class#Property so 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, prints graded N/M pairs plus the total evidence DEPTH (evidence + seededBy entries), 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 seeds ApiGatewayProvider#CloudWatchRoleArn loses, 5383 for trimming ACMCertificateProvider#CertificateAuthorityArn to 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. --check previously printed OK — 0 evidence losses at 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-loss waives an ENUMERATED per-property reduction, --accept-missing-baseline waives 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 to unhandledByDesign is 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 under cmp, while the new verdict names DynamoDBTableProvider#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 !== undefined workaround instead — so the parser fix is a no-op on the current tree and a fence for the next one.
  • cdkd local invoke --assume-role and --stack-region stop 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-role STS chain. cdkd local invoke's credential block resolved its STS region from --region then AWS_REGION then AWS_DEFAULT_REGION; the handler-entry fold covers only the first link, so with --assume-role and AWS_REGION=CN-NORTH-1 and no --region the RAW value reached both the AssumeRole STSClient (commercial sts.CN-NORTH-1.amazonaws.com) and the container's own AWS_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 copies AWS_REGION / AWS_DEFAULT_REGION into the container through forwardAwsEnv, so folding inside the assume-role arm alone made ONE command answer two different ways for the same shell (cn-north-1 with --assume-role, CN-NORTH-1 without) — forwardAwsEnv now folds exactly those two entries and copies the credential triple verbatim (an AKID is case-sensitive) — and so does local-invoke-agentcore.ts's own copy, the unfixed twin the second review round found: #1814 folded that command's stsRegion but left its default arm copying AWS_REGION verbatim, so cdkd local invoke-agentcore with AWS_REGION=CN-NORTH-1 gave the container cn-north-1 WITH --assume-role and CN-NORTH-1 without — the exact asymmetry local-invoke.ts's comment describes, and outside #1843's scope (which names only local-start-api.ts), so it was neither fixed nor filed. Its test asserts the two arms AGREE for one shell, because the defect was never the value is wrong but the value depends on a flag that has nothing to do with the region. The whole chain now folds through canonicalizeRegion, and the block was extracted into an exported applyLambdaCredentialEnv mirroring applyAgentCoreCredentialEnv (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 named args.region, which is the agentcore spelling; cdkd's local invoke copy read options.region inline 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 existing options.region fold — 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-agentcore inherit the flag from cdk-local and cdk-local owns their handler), ALSO at the --from-state factory in local-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 folds cdkd deploy --region — DNS is case-insensitive, so an upper-cased COMMERCIAL deploy succeeds and keys its state cdkd/{stack}/US-EAST-1/state.json. So local-state-loader.ts folds BOTH sides for the comparison while targetRegion — 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-only find INTRODUCED a silent-wrong-state read. S3StateBackend.listStacks dedupes on the exact {stack}\0{region} pair, so cdkd/MyStack/US-EAST-1/state.json and cdkd/MyStack/us-east-1/state.json are two DISTINCT refs returned in ListObjectsV2's ASCII order (upper-cased first); with both present, a fold-only lookup answered --stack-region us-east-1 with 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-region first (each handler's entry fold, and the --from-state factory), so the candidate was always canonical: exact could only ever match an already-canonical record, --stack-region US-EAST-1 read the us-east-1 one, and the warning still called that the exact spelling. The user's RAW spelling is therefore captured at each handler entry BEFORE the fold (options.rawStackRegion), carried through the factory and S3LocalStateProvider, and consulted by exactly two things: this record-match compare and loadBootstrapContainerRepo'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-cased cdkd bootstrap wrote). It never reaches an SDK client or an endpoint. It DOES reach one S3 key no state record spells — that marker probe's cdkd-bootstrap/{RAW}.json second 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' exactly vs no 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 the Fn::ImportValue index-miss scan, whose consumerRegion is 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 keys getState, exactly as targetRegion is. local-run-task.ts was handing that helper a FOLDED chain value while local-invoke.ts / local-invoke-agentcore.ts passed loaded.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. consumerRegion becomes the exports-index KEY cdkd/_index/{region}/exports.json AND the raw ref.region === this.region filter ExportIndexStore's index-miss REBUILD uses, so a spelling no state record carries yields ZERO refs and PUTs an EMPTY index — after which every Fn::ImportValue degrades 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 of buildEcsImageResolutionContext and prefers it, falling back to the folded env chain only when no record was loaded at all; that resolution moved into an exported resolveEcsConsumerRegion so it has a BEHAVIORAL test rather than only the source-level co-occurrence pin (which a cond ? canonicalizeRegion(a) : process.env['AWS_REGION'] would also have satisfied). src/state/export-index-store.ts is deliberately NOT touched: deploy derives its own write key from --region / AWS_REGION verbatim (deploy.ts), which already differs from the stack's region whenever a stack declares env.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 claimed read-only against state, now says what is true: no lock and no state.json write, but an Fn::ImportValue lookup 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 to resolveStateBucketWithDefault, which names the legacy default bucket cdkd-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 to us-east-1; the CLIENT's region is opts.region, which now folds SEPARATELY at all three construction sites in the file (both loaders plus buildCrossStackResolver), 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 !== undefined gate and arrived as region: '' while the comment beside it claimed otherwise (AwsClients dropped it one layer down on a truthiness test, so this makes the boundary say what the client already did; the --from-state factory's two spreads take the same treatment). The unit assertions now observe the AwsClients CONSTRUCTOR, not the S3StateBackend one — the second review round found the fold unfenced because S3StateBackend reads only profile / credentials out of the bag those tests asserted, so reverting the one new AwsClients({ region }) line left the whole suite green; all three sites now assert it, and loadBootstrapContainerRepo's client had no coverage at all. And loadBootstrapContainerRepo's chain becomes the cdkd-bootstrap/{region}.json marker KEY, which cdkd bootstrap does NOT write from a canonical region: it derives options.region || AWS_REGION || 'us-east-1' verbatim, so AWS_REGION=US-EAST-1 cdkd bootstrap really 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 owns bootstrap.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' }, a getState keyed 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-state factory down to the getState key 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 became expect(warnSpy).not.toHaveBeenCalled() (they would have gone vacuous on this round's own message rewrite), and the --stack-region handler-entry pin is DERIVED from the flag DECLARATION rather than from a read of options.stackRegion — local-invoke.ts / local-start-api.ts never name the field (they hand the whole options bag to createLocalStateProvider), 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 in local-run-task.ts and is fixed here too: consumerRegion, which becomes the exports-index key cdkd/_index/{region}/exports.json and 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 (every process.env['AWS_REGION'] chain must sit inside a canonicalizeRegion(...) call, with a floor so a broken parse cannot pass vacuously) rather than tree-wide, because local-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, never git checkout) and watching exactly the corresponding rows fail — seven probes for the first review round (exact-match-first, the --region factory fold, the marker raw-spelling fallback, forwardAwsEnv's fold, consumerRegion's fold, the cross-stack same-region filter, and the three clientRegion sites) 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 three AwsClients region sites individually, the agentcore forwardAwsEnv fold, and the three blank-region guards — plus SEVEN for the fourth: the cross-stack bucket-name fold, the resolveGetStackOutput both-sides recovery, that arm's exact-key-first guard, the two false doc claims (run-task's and the loader's), the factory's blank stackRegion gate, and resolveEcsConsumerRegion's || flag link. The two Docker / real-AWS fixtures gained the arms that actually discriminate the change, since neither did before (local-invoke never passed a non-canonical region, and local-invoke-from-state pinned AWS_REGION canonical): local-invoke invokes the echo handler with an upper-cased AWS_REGION and asserts the ECHOED container AWS_REGION arrives canonical, paired with the canonical counter-case asserting byte-identical output — the handler echoes process.env.AWS_REGION for exactly this — and local-invoke-from-state re-reads the already-deployed stack with an upper-cased --stack-region, asserting BUCKET_NAME still resolves to the deployed bucket (pre-fix it silently came through unset) at no extra deploy. The local-invoke arm deliberately stays Docker-only with NO --assume-role arm — 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 the STSClient's own constructor region. Both fixtures carry the new local-region-case-fold scenario tag. Left open, filed separately: local-start-api.ts carries three chains of the same item-(1) shape whose env-var fall-throughs are still raw (the per-Lambda stsRegion, the server's defaultRegion, and the WebSocket management-API container AWS_REGION) — issue #1843, whose scope was widened with a FOURTH site the review-fix round surfaced: that file's own forwardAwsEnv copy, the exact twin of the one fixed here, which #1843 did not enumerate. A FOURTH review round then found that consumerRegion has FOUR consumers, not the two the third round's comment named, and that two of them were still wrong. (a) buildCrossStackResolver handed the RAW record spelling to resolveStateBucketWithDefault, 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 at cdkd/MyStack/US-EAST-1/state.json named the legacy default cdkd-state-<acct>-US-EAST-1, an upper-cased bucket name is not virtual-hostable so the request went path-style, S3 answered 400 InvalidBucketName, probeBucket rethrew, and the resolver warn-and-dropped EVERY Fn::ImportValue / Fn::GetStackOutput env entry while loadStateForStack read 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) resolveGetStackOutput had no case recovery at all, although it is reached with a producerRegion cdkd does not control: cdk-local defaults it to SubstitutionContext.consumerRegion for an intrinsic carrying no explicit Region, so an US-EAST-1 consumer record referencing a us-east-1 producer built cdkd/Producer/US-EAST-1/state.json, 404'd, and the var was dropped. It now mirrors resolveImport: EXACT key first (a record that exists at that key IS the producer, so a missing output there stays a genuine miss), then a listStacks walk 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 beat AWS_REGION and 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-state factory's stackRegion spread 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: the rawStackRegion doc is now DERIVED against the code — the command that feeds loadBootstrapContainerRepo may 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.DBSubnetGroupArn and AWS::SSM::Parameter.Arn are cached, so an output Fn::GetAtt on 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 every tests/fixtures/cfn-schemas/*.json from 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-resource Fn::GetAtt reads the CACHED resource.attributes[<CFnName>] in IntrinsicFunctionResolver.constructAttribute, which never calls a provider's getAttribute, and for an *Arn name the resolver's shape guard HARD-FAILS rather than degrading — both physicalIds here are NAMES (a subnet-group name, a parameter name), so new cdk.CfnOutput(this, 'ParamArn', { value: param.attrArn }) failed the deploy. The two differ in source and were fixed accordingly. DBSubnetGroupArn is read straight off the CreateDBSubnetGroup response (no extra call), and re-reported from update off the DescribeDBSubnetGroups the tag diff already issues — necessary because an update result's attributes REPLACE the state record's rather than merging, so returning only DBSubnetGroupName would have WIPED the create-time ARN. AWS::SSM::Parameter.Arn has no create-response source (PutParameter reports only Version / Tier), and the recorded decision is a CONSTRUCTED ARN rather than a follow-up GetParameter: 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's attributes REPLACE the state record's, so a degradation during update drops 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 in buildParameterArn's JSDoc and pinned by a test: the degraded arm returns the PARTIAL map (dropping Arn, so the resolver's *Arn guard 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 SUPERSEDED Type / Value — a silently wrong answer to Fn::GetAtt [Param, Value] and a cdkd drift baseline AWS does not hold. A loud missing value beats a quiet wrong one, the same call the fabricated-account arm makes. Construction reuses the machinery this repo already has for "the create response carries no ARN" — the same getAccountInfo + derivePartitionAndUrlSuffix + fabricated-refusal trio as AppSyncProvider.buildAppSyncArn: the partition is DERIVED, never hardcoded arn:aws: (the active #1794 / #1815 bug class), and a fabricated account (#1730 / #1746) REFUSES to record anything, since an ARN built from the placeholder 123456789012 carries no wildcard and is therefore invisible to isPlaceholderArn — degrading to the loud pre-fix guard failure rather than to a plausible-looking wrong value. The import() path records the ARN too, read off the existence-verification GetParameter it 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 through canonicalizeRegion (the #1795 / #1814 class): derivePartitionAndUrlSuffix folds case INTERNALLY, so the partition was already right for an upper-cased region while the segment was not, and cdkd deploy --region US-EAST-1 is REACHABLE — DNS is case-insensitive, so the deploy SUCCEEDS and then records arn:aws:ssm:US-EAST-1:..., a value that matches no IAM policy, is rejected by every SDK call taking the ARN, and is persisted into state.json where it outlives the deploy. cdkd import now 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/foo verifies through GetParameter, which ACCEPTS an ARN, so resolveExplicitPhysicalId records the ARN AS the physicalId — and the damage lands later and elsewhere: the next cdkd deploy fails at PutParameter({Name: physicalId}) with a ValidationException after a Value change, and cdkd destroy fails identically at DeleteParameter. Round 2 addressed the symptom instead, returning an ARN-shaped id verbatim from the ARN BUILDER — which can only ever run against a mock, because update() sends PutParameter at ssm-parameter-provider.ts well BEFORE it builds the ARN and create() likewise, and PutParameterRequest.Name forbids an ARN outright ("You can't enter the Amazon Resource Name (ARN) for a parameter, only the parameter name itself"), as does DeleteParameterRequest.Name. Worse, the test pinning it primed PutParameter to 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 shapes GetParameter additionally 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, since arn:...:parameter/foo is the ARN of BOTH the simple name foo and the one-level hierarchical name /foo (aws-cdk-lib's arnForParameterName renders the two to the identical string, which is exactly why CDK needs an explicit simpleName flag 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 --resource form, or Properties.Name) plus the aws ssm get-parameter --query Parameter.Name command 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.buildAppSyncArn carries the same latent region-verbatim shape and is filed as issue #1850 (its file is owned by another lane). Both SDK_ATTR_ALLOW_LIST KNOWN GAP entries are DELETED (the list is now empty), which is what verifies the fix: classifyType tests cachedKeys before 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 attributes allow-listed -> cached and knownGap 2 -> 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, every resolves Fn::GetAtt case fails with the resolver's own IntrinsicResolutionRefusalError (... is not an ARN (arn:...)) — the actual pre-fix deploy failure — and the critic's new positive cached fence 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: collectStoredAttributeKeys pools object-literal keys per file, so any ONE of the create / update / import literals keeps the type classified cached — measured, neutralizing BOTH the create and update spreads leaves the critic green off the import() occurrences alone, and only removing all three flips it to gap. 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 write undefined" 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 — a cn-north-1 and a us-gov-west-1 case, 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 used us-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-guard gains a POSITIVE GUARD_PHASE=arn-resolves phase whose Consumer value is Fn::GetAtt [Probe, Arn]: the deploy must SUCCEED and the consumer parameter's live value must equal GetParameter's Parameter.ARN BYTE FOR BYTE, for a FLAT name AND a HIERARCHICAL (leading-/) one — the leading-slash fold into the parameter/ separator is exactly where a constructed ARN goes wrong, and every pre-existing phase used a flat name only. A follow-on arn-resolves-update phase rotates the probes' VALUES so each takes the UPDATE path, then asserts attributes.Arn in the REAL persisted state.json is 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 with HierProbe never 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 a FAIL: line naming the compared values instead of return 1, which under the fixture's set -euo pipefail aborted 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-stack routes Fn::GetAtt(<DbSubnetGroup>, DBSubnetGroupArn) into a second SSM parameter — mirroring the pattern it already uses for the computed DB endpoint — and asserts it equals the live describe-db-subnet-groups ARN, which is what validates that CreateDBSubnetGroup really returns DBSubnetGroup.DBSubnetGroupArn rather 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 add new cdk.CfnOutput(this, 'ParamArn', { value: param.attrArn }) and change nothing else) STILL hard-fails — and the resolver's attributes are not enriched for this resource type ... file an issue message is now FALSE for these two types. The heal plus that wording are issue #1852, deferred because both files are src/deployment/**, owned by another lane and in the integ-broad gate scope; until then the remedy is a real property change on the resource (or a re-import).
  • AWS::DynamoDB::GlobalTable destroy + WarmThroughput hardening (issues #1830 / #1857): delete() retries the transient Cannot delete table while indexes are being created, updated, or deleted refusal (message classifier + a waitForIndexesActive re-arm between attempts, both provider-local at the time and since LIFTED into the shared src/provisioning/dynamodb-index-busy-delete.ts by issue #1931, which gave the sibling AWS::DynamoDB::Table type the same retry) instead of surfacing a hard PartialFailureError. 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 call destroy-runner.ts caps at 30 min — a genuinely stuck index therefore produced a 30-minute wait ending in a generic ResourceTimeoutError that never mentions indexes, instead of AWS's own actionable sentence in seconds. The first index-busy retry also emits ONE logger.warn naming the cause, since withRetry announces retries only at debug level and a delete spending minutes re-polling otherwise printed nothing. And a THROTTLED DescribeTable inside 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 the WarmThroughput half, the block is now numerically coerced per member in toSdkGlobalSecondaryIndexes (the one funnel all four send sites read), refused-and-warned when no member resolves, and dropped with a warning at each UpdateTable site when it would LOWER the live value (AWS rejects a decrease). The two WarmThroughput RULES — the per-member numeric coercion and the decrease guard — live in a new shared src/provisioning/dynamodb-warm-throughput.ts that BOTH DynamoDB providers read, rather than in a second GlobalTable-local copy: PR #1808 had just landed the same two rules for the sibling AWS::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 explicit null, 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 bare Number(' ') is 0 rather than NaN, and the shared rule takes the REFUSING answer. The GlobalTable provider's own toFiniteNumber (capacity units, on-demand ceilings) moved into the shared module in the same change, alongside WARM_THROUGHPUT_MEMBERS: it was one rule written by hand three times (here, the Table provider's byte-identical capacityNumber, and the shared module's own member parser), and a file REFUSING a whitespace WarmThroughput while reading a whitespace MaxReadRequestUnits as ZERO is the very divergence the extraction exists to prevent, one property over. dynamodb-table-provider.ts keeps its own coerceWarmThroughput signature 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's isSendableWarmThroughput was issue #1760's Boolean(value) truthiness gate at the time, and #1808 redefined it as coerceWarmThroughput(value) !== undefined — i.e. the same rule as the coercion's spec presence, 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's ECR_REGISTRY_HOST carried the -fips and on.aws forms while ecr-uri.ts's ECR_URI_HOST_REGEX matched only the plain dkr.ecr one, so a genuine FIPS or dual-stack registry was not recognized there AT ALL and cdkd local invoke / start-api / run-task classified it as a public image — anonymous docker pull, no docker login, opaque auth error. ecr-uri.ts now owns the FORM TABLE (ECR_REGISTRY_HOST_FORMS + the ecrRegistryHostPattern(...) builder) and gc.ts builds 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), while parseEcrRegistryHost still pairs the captured suffix WITH the region. The four rows are read off the AWS-published ecr endpoint list — dkr.ecr (every partition), dkr.ecr-fips (the six US / GovCloud FIPS regions), and the dual-stack dkr-ecr / dkr-ecr-fips, whose suffix is the FIXED on.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.aws is AWS-owned, so unlike a captured suffix it cannot be substituted by a look-alike). Unifying the copies surfaced dkr-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, the dkr.ecr LABELS 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 (parseEcrRegistryHost undefined AND looksLikeEcrHostWithForeignSuffix false, 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/reference spells domain-component as ([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]) while the repository PATH is alpha-numeric := [a-z0-9]+, and ParseNormalizedNamed raises repository name must be lowercase for the REMOTE NAME only (splitDockerDomain performs no case folding on the domain) — so docker accepts an upper-cased host, cdkd is not made to docker login for 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 by ecr-puller.ts's canonicalizeImageUriHost; #1817 tracks moving that fold into parseEcrRegistryHost. 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 unescaped dkr.ecr matching dkr-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.aws as 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 of ecrRegistryHostPattern, while ECR_URI_HOST_REGEX re-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 a fixedUrlSuffix uncaptured. So the shared unit is the TABLE — which is what had drifted — not the whole host shape; a THIRD, looser spelling (isCdkAssetImageUri's host.includes('.dkr.ecr.')) still lives in src/local/ecs-task-resolver.ts under issue #1846. (2) gc's DIGEST match looked protective and was INERT. The i flag widens sha256:[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-case imageDigest, 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 (a runGc arm asserting BatchDeleteImage's payload, not merely what collect() gathered) plus a real-AWS arm in gc-custom-asset-names. (3) RECOGNITION IS NOT A WORKING PULL for the three newly matched forms: ecrLogin authenticates 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 with no basic auth credentials where local invoke used to REFUSE it with a clear message. docs/local-emulation.md was 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 plain toLowerCase(): 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 future u flag 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: escapeRegExp was a THIRD copy (ecr-uri.ts / gc.ts / assets/asset-redirect.ts) and is now the shared src/utils/regexp.ts, with gc-side negative arms proving the escaping (unescaped, gc collects dkrxecr / onXaws look-alikes); the complete classification table of every verdict these two issues changed — including the five DIAGNOSTIC-ONLY flips, where the parse result is unchanged but looksLikeEcrHostWithForeignSuffix goes false -> true — is recorded in ECR_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.ts arms proving a FIPS / dual-stack / UPPER-cased host now classifies kind: 'ecr', and that a mispairing stays public while reaching the #1764 diagnostic).
  • The AWS::ECS::Service DeploymentConfiguration nested 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 reach LinearConfiguration / CanaryConfiguration / LifecycleHooks[], and those carry no (or a partial) required list, 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's TimeoutConfiguration.Action -> ROLLBACK; the defaults are fixed, not derived — an empty LinearConfiguration: {} 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 NOT BLUE_GREEN-reachable (each is gated on its OWN strategy — AWS answers Linear configuration can only be present with LINEAR deployment strategy — so the fixture the issue prescribed could not have reached them at all; LifecycleHooks is not strategy-gated that way, measured under CANARY), and the default-fill does not become phantom drift, because the deploy engine captures observedProperties from readCurrentState and 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 (DeploymentAlarms keeps its permissive-divergence row). AWS itself REFUSES — the ASG InstanceMaintenancePolicy disposition (#1227), cdkd fails loudly, parity with no nested required-ness check involved — for a hook element missing LifecycleStages; a hook element missing TargetType (absent DEFAULTS to AWS_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); and DeploymentCircuitBreaker.ThresholdConfiguration missing Value. That last one carries required: [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 (DeploymentCircuitBreaker missing Rollback is 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-ecs rather 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 whole ThresholdConfiguration block) dropped from an otherwise complete parent are RETAINED by cdkd and RESET to their AWS defaults by CloudFormation (measured from the identical baseline with Rollback flipped in the same call so the update demonstrably applied: cdkd left false / {COUNT, 7} intact, CFn produced true / {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 only MinimumHealthyPercent, while the same member declared-then-dropped was reset — the previous-present / current-absent semantic clearOnUpdateRemoval implements 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; the LinearConfiguration family 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. The LifecycleHooks ARRAY 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; declaring DeploymentConfiguration in getDriftUnknownPaths fails the drift fence; and a readback that still WRITES the key but stops re-casing it (raw SDK camelCase) fails the readCurrentState round-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.
  • ResourceUpdateNotSupportedError is 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 the markNonRetryable / isMarkedNonRetryable marker mechanism (issue #1778) and recorded this as a KNOWN LIVE INSTANCE it could not cover, because src/utils/error-handler.ts was owned by the concurrent #1710 lane. The retry classifiers in retryable-errors.ts match by SUBSTRING against the error MESSAGE, and ResourceUpdateNotSupportedError interpolates the resource's LOGICAL ID into its message. DependencyViolation is the only whitespace-free entry in RETRYABLE_ERROR_MESSAGE_PATTERNS, so an ordinary composite CDK logical id was enough: MyDependencyViolationSub made isRetryableTransientError return true for a deterministic, cdkd-authored "this type does not support UPDATE" refusal, which ~20 providers raise from inside the update() call deploy-engine.ts wraps in withRetry (and drift.ts wraps for --revert). The refusal was therefore retried through the full generic schedule — 8 retries, ~47s of pure sleep — before the deploy reached the --replace DELETE+CREATE fallback the error exists to trigger. It now calls markNonRetryable(this) in its CONSTRUCTOR rather than at each throw: 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.ts has zero imports of its own, and src/utils -> other-layer imports already have precedent (src/utils/live-renderer.ts -> src/provisioning/resource-name.js). Class identity, exitCode = 2, and the resourceType / logicalId / suggestion payload are untouched, so the deploy engine's instanceof fallback selection and handleError's exit code are unaffected. The issue's sibling audit of the other refusal classes in the same file marked nothing else: ProvisioningError (and the CdkdError base) must stay unmarked because they are the generic wrapper for RELAYED AWS failures — marking either would make every transient AWS error terminal; ResourceTimeoutError is raised BY withResourceDeadline, which wraps the retry loop from OUTSIDE at both sites (deploy-engine.ts:2375, destroy-runner.ts:983), so the classifier never sees it; IntrinsicResolutionRefusalError interpolates user text but intrinsic resolution runs OUTSIDE the withRetry closure (which wraps only provider.create / provider.update), and one of its four arms — the fabricated-account guard — is genuinely time-dependent, since getAccountInfo caches 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, the Local* / 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: isRetryableTransientError is false for a ResourceUpdateNotSupportedError whose logical id contains DependencyViolation, 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 a ProvisioningError .cause wrap, precedence over a co-occurring throttle signal, payload / class-identity preservation, and the four audited-and-left-unmarked siblings. A second block in tests/unit/deployment/retry.test.ts pins the user-visible SYMPTOM rather than the classifier verdict — the real class driven through the real withRetry rejects 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's markNonRetryable(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 in nestedStackChildFailureMessage (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, because src/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 WarmThroughput stops 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. DescribeTable reports a WarmThroughput for 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 the rollback-replay-effective-props fixture — so on a properties-only baseline (what a reverse-replacement rollback leaves behind, since rollback-executor.ts strips observedProperties) the member was a PERMANENT one-sided difference: cdkd drift reported an untouched table forever and --revert re-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 in getDriftUnknownPaths — the #1760 answer for the sibling AWS::DynamoDB::Table's TOP-LEVEL member — cannot express a member of an ARRAY ELEMENT: calculateResourceDrift compares arrays wholesale, so isIgnoredPath is never asked about a path crossing one and the only expressible suppression is the WHOLE GlobalSecondaryIndexes subtree, 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 larger observedProperties population — every bag already in S3 was written by a binary that emitted the computed member, so the first cdkd drift after 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 the canonicalizeDriftProperties seam (issue #1784): the provider strips GlobalSecondaryIndexes[].WarmThroughput from 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 named WarmThroughput (Replicas[].GlobalSecondaryIndexes[] entries carry only the read-half throughput blocks, and LocalSecondaryIndexes is a wider raw-SDK divergence than one member), the resourceType is CHECKED as a cheap shape guard — the arm is UNREACHABLE today (drift.ts resolves 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 no readCurrentState and 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: readCurrentState now reverse-maps the per-index WarmThroughput to the CFn shape, dropping the AWS-managed Status member the CFn WarmThroughputType has no concept of — a table that DECLARES warm throughput drifted permanently against its own declaration on that member alone, in cdkd drift and in --revert's update() previous side (NOT in cdkd diff, which compares state.properties and never consults the readback). Dropping a key from a readback would ordinarily strand every already-written observedProperties record (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-index WarmThroughput no longer has changes to it REPORTED by cdkd drift. The value is still SENT (toSdkGlobalSecondaryIndexes forwards 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 --revert would 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 no side argument 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 reported WarmThroughput loses its Status; 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 REAL calculateResourceDrift (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; and getDriftUnknownPaths is 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 STALE observedProperties baseline, which a fresh-deploy fixture structurally cannot provide because both comparison sides come from the same readback — plus restoring the rollback-replay-effective-props capacity table's GSI, which that fixture drops today to work around this defect.
  • The onUnusable warning 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 an AWS::DynamoDB::Table whose template carries BillingMode: '' against a live PAY_PER_REQUEST table 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: every onUnusable message ended bare while every caller composes as ${message} <their sentence>, so the two ran together at exactly ...template-path create The mode.... All four onUnusable messages (readConfigString's container arm, requireConfigString, requireConfigArray, requireConfigObject) now end with a period; the throw arms 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. requireConfigString now 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 / requireConfigObject take (1) but NOT (2): they return undefined and 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.md prescribes 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 uses stringContaining, 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, no create [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 NameServers keeps 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 expose AWS::Route53::HostedZone.NameServers — create, update, and on-demand getAttribute — 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 pattern Fn::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, so Fn::GetAtt supplies the list shape that Fn::Join requires. 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 real GetHostedZone delegation 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::Database resource 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.buildDatabaseInput is a FRESH-OBJECT builder (const result: DatabaseInput = { Name }) that named only Description / LocationUri / Parameters, while both the CFn schema and @aws-sdk/client-glue's DatabaseInput also declare TargetDatabase, FederatedDatabase and CreateTableDefaultPermissions — and grep -rn for those three spellings under src/ 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 no cdkd drift finding either, because readCurrentState did 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 path same-spelling and 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 in GLUE_DATABASE_DROPPED_PATHS plus 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 and AWS::Glue::Database now carries freshObjectMapper: true (15 audited paths, all same-spelling, 0 blocking findings). The fix names every member INDIVIDUALLY rather than casting the three blocks through verbatim, which the sibling TableInput.TargetTable / ViewDefinition forwards do. A cast would typecheck and deliver correctly, but it leaves the 8 CHILD paths reporting no-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. readDatabase reverse-maps the same three blocks EMIT-WHEN-PRESENT (unlike the always-emitted Description / Parameters placeholders): AWS reports a Lake Formation DEFAULT CreateTableDefaultPermissions of IAM_ALLOWED_PRINCIPALS/ALL for 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 REFUSES Description and TargetDatabase together (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 back CreateTableDefaultPermissions: [{IAM_ALLOWED_PRINCIPALS, [ALL]}], which is what makes a declared [SELECT] distinguishable from a silent drop; and UpdateDatabase re-sending TargetDatabase round-trips unchanged. The three blocks are SHAPE-GUARDED, which the first cut of this change was not (found by all three PR reviewers): a malformed TargetDatabase passed every member read as undefined and cdkd SENT an empty {} block, a non-array CreateTableDefaultPermissions threw a raw TypeError out 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 through replayWarn on a reverse-replacement replay, which needed CreateContext threading into createDatabase), WARN plus RETAIN THE PREVIOUS block on update, because UpdateDatabase replaces DatabaseInput wholesale 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. CreateTableDefaultPermissions is 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 the observedProperties baseline 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 or drift --accept — getDriftUnknownPaths now scopes DatabaseInput.CreateTableDefaultPermissions out PER RESOURCE for a database whose template declares none (the ELBv2 Targets shape; 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 a TargetDatabase, but the refusal is on a NON-EMPTY description — the always-emitted Description: '' placeholder is accepted alongside a link on both create and update (measured us-east-1, 2026-08-13), so the drift --revert round-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 ?? previous fallback never fired — the block built empty and UpdateDatabase dropped 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-valued DatabaseName rode an unchecked cast onto the wire) and makes an unquoted-YAML numeric CatalogId reach AWS as the string the SDK wants. A Principal naming 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), and getDriftUnknownPaths treats 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-level DatabaseName would 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 used some, so ONE literal member licensed the whole block and an intrinsic-valued sibling was dropped with no message — TargetDatabase: {DatabaseName, CatalogId: {Ref}} shipped without CatalogId, silently retargeting a cross-account resource link at the caller's own catalog, and the guard's strictness depended on siblings (the same CatalogId alone WAS refused). A block is now usable only when EVERY declared member present is sendable. The same round aligned isSendableLeaf with catalogIdForApi on non-finite numbers, coerced a numeric DataLakePrincipalIdentifier like 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-PAYLOAD toEqual / toStrictEqual on the DatabaseInput the SDK command carries, because the regression is a member silently MISSING — a toMatchObject or 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-hardening gains a resource-link database (TargetDatabase pointing at the fixture's existing table database) and a second database carrying CreateTableDefaultPermissions, asserted on BOTH the create and the UPDATE phase — the update phase flips the granted permission set, since UpdateDatabase REPLACES DatabaseInput wholesale 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 deploy consumes the { outcome: 'skipped' } delete outcome its five call sites used to discard (issues #1762 / #1803) — new src/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 gave ResourceProvider.delete a 'skipped' outcome meaning "this row was NOT destroyed and may still be alive" and taught cdkd destroy to 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 new DeployResult.deleteSkipped (rendered as Skipped (not deleted) only when non-zero) and emits RESOURCE_SKIPPED instead of RESOURCE_SUCCEEDED — but still exits 0, because the kept record means the resource is still a pending DELETE and the next deploy re-attempts it, where cdkd destroy has no next run and therefore exits 2; the three replacement deletes (--replace delete-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 a failures count with ROLLBACK_RESOURCE_FAILED and 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 a warnings count, 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 to completedOperations, because journaling a delete that never happened would make cdkd rollback re-CREATE a live resource; and the skip is checked OUTSIDE each site's catch, so a provider reason can 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.deleteSkipped is deliberately separate from the existing skipped, which counts UPDATEs that resolved to no change and feeds unchanged. Thirteen unit cases across the two new files, mutation-probed by neutering deleteSkipReason (9 of 13 went red; the 4 void / { 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's skippedCount JSDoc said "Two producers today" and had gone stale three times (once per lane that added a producer), so it now points at ResourceDeleteResult's own enumeration as the single source of truth instead of restating it. The same staleness was fixed in the five providers' DEPLOY_SKIP_CAVEAT strings and in compositeIdFormatMessage, 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 inside derivePartitionAndUrlSuffix (src/utils/aws-partition.ts) so every cdkd-OWNED caller inherits ONE normalization point, but derivePseudoParametersFromRegion is not cdkd's — it arrives through the src/local/intrinsic-image.ts shim as a bare re-export from cdk-local/internal, and cdk-local carries its own partition table whose prefix tests are case-sensitive in the same direction. So the Fn::Join Code.ImageUri path (issue #637) still synthesized <acct>.dkr.ecr.CN-NORTH-1.amazonaws.com/... — a cn- 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) and src/local/lambda-resolver.ts. The shim's re-export of that ONE symbol becomes a thin BOUNDARY WRAPPER (the shape docker-image-builder.ts already uses for its error-class boundary) that runs the region through canonicalizeRegion before delegating; substituteImagePlaceholders / tryResolveImageFnJoin stay 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 returned region is substituted as ${AWS::Region} into every ARN the resolver builds, so a raw value would misspell those too and not just the derived suffix; undefined still 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 --region flag 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 from cdk-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, so us-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 own it.each case 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-agentcore was the one region-taking cdkd local command the #1795 sweep skipped, so while this change fixed its pseudo-parameter path, its RAW --region still reached three consumers that are case-SENSITIVE in the same direction the partition table is — the SigV4 signing SCOPE (where --region has the highest precedence over the credential chain's region), the STSClient endpoint resolution, and applyRoleArnIfSet. It now folds at the handler entry like its three siblings, and tests/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.ts plus 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's Fn::GetAtt answer. Each builder now routes through derivePartitionAndUrlSuffix(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 to TagResource / UntagResource as resourceArn, and the S3 Express directory-bucket ARN is BOTH an attribute and the S3 Control tag ResourceArn — 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.ts has no region SEGMENT but still carries a partition, so budgetArn became async and reads getClient().config.region() — the RESOLVED region of the very client that receives the ARN, the same source its delete() path already consults for assertRegionMatch. Deriving from providerRegion (process.env['AWS_REGION']) instead was wrong on the PRIMARY path, not a fallback: when that env var is unset getClient() builds new BudgetsClient({}) and the SDK resolves the region from its OWN chain (AWS_DEFAULT_REGION, the ~/.aws/config profile), so a profile-configured cn-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/sqs unknown:unknown, the cloudwatch wildcard-region *:*) read AWS_REGION as 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 commercial aws when 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 FROM AWS_REGION, so config.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 a cn-north-1 AND a us-gov-west-1 case asserting the arn: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.ts derives the ARN's region (and now partition) from s3Client while the tag calls consuming it go through s3ControlClient, built from providerRegion — 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-sensitive startsWith predicates, which change BEHAVIOR rather than a recorded string and are the half the issue flags as the user-visible one: custom-resource-provider.ts:732 misroutes an SNS-backed custom resource as Lambda-backed, and iam-managed-policy-provider.ts:501 reads an AWS-managed policy as customer-managed.
  • The DenyExternalAccess bucket 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), the cdkd state migrate destination bucket, and the asset bucket (ensureAssetStorage) — are hardened with a DenyExternalAccess statement denying s3:* to any principal whose aws:PrincipalAccount is not the owner. Its Resource ARNs hardcoded the aws partition, so on aws-cn / aws-us-gov the 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, PutBucketPolicy succeeds, 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 via derivePartitionAndUrlSuffix(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 local region variable — one rule, no per-site judgement about which value is authoritative. The first cut of this fix passed bootstrap's region variable 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: region is options.region || AWS_REGION || 'us-east-1', a HARDCODED commercial fallback, while AwsClients omits region entirely when --region is absent and lets the SDK chain read the profile's region and AWS_DEFAULT_REGION. So a GovCloud / China user with a non-commercial profile region, no AWS_REGION and no --region reproduced #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: bootstrap skips the policy PUT for a bucket that already exists unless --force is passed, so an aws-cn / aws-us-gov state bucket bootstrapped by an earlier cdkd keeps its inert deny until cdkd bootstrap --force is re-run (it rewrites versioning / encryption / policy and does not touch existing state). Partial by design: the issue's fourth site, CloudControlProvider's AWS::S3::Bucket Arn enrichment, is NOT in this change — src/provisioning/cloud-control-provider.ts is 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-sensitive startsWith predicates) 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 shared derivePartitionAndUrlSuffix (src/utils/aws-partition.ts) folds its region through the new exported canonicalizeRegion before the PARTITION_TABLE prefix walk, which is a case-SENSITIVE startsWith — so --region CN-NORTH-1 fell through to the COMMERCIAL partition and the three cdkd local commands synthesized <acct>.dkr.ecr.CN-NORTH-1.amazonaws.com/<repo>:<tag>, a cn- 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-endpoints partition data, cn-north-1 resolves aws-cn / amazonaws.com.cn while CN-NORTH-1 resolves aws / amazonaws.com (both the exact-match table and the regionRegex fallback are case-sensitive) — so every SDK client built from the raw region talked to the WRONG partition's endpoint: sts.CN-NORTH-1.amazonaws.com for the ${AWS::AccountId} lookup (which then failed, dropping the account id with a warn and leaving the image Fn::Sub unresolved), plus the ECR / SecretsManager / SSM clients further down the cdkd local run-task path; 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, so options.region is 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 why parseEcrUri canonicalizes 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-puller logs in to and pulls from the SAME host. It authenticated against the lower-cased endpoint (authData.proxyEndpoint, or the derived fallback) while running docker pull against the RAW imageUri, and docker's credential store is keyed on the hostname VERBATIM — measured with a temporary DOCKER_CONFIG holding auth for <acct>.dkr.ecr.us-east-1.amazonaws.com, the lower-cased reference sent credentials (denied: Your Authorization Token is invalid) while the US-EAST-1 spelling sent NONE (no basic auth credentials), and docker pull was independently confirmed to preserve host casing verbatim in the request URL. canonicalizeImageUriHost now folds ONLY the registry HOST and parseEcrUri returns it as canonicalUri, which docker 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 equals localhost, so MyOrg/MyRepo:tag is a Docker Hub repository PATH and folding it would name a DIFFERENT image — the component is tested rather than assumed. An AWS-reported proxyEndpoint still 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, so US-EAST-1 and us-east-1 were separate entries paying a duplicate GetCallerIdentity / 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-1 against a lower-case host stops logging a spurious Cross-region ECR pull line. 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 image Fn::Sub those 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-dominant proxyEndpoint arm, 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 desired BillingMode suppresses 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. retainUnsendableCapacityMembers now 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 mode PROVISIONED the 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); with PAY_PER_REQUEST the mirror. That is the .claude/rules/providers.md UPDATE 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 through asRecord (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 by IndexName / Region, never positionally. No canonicalizeDesiredProperties twin — this is a SKIP, so folding the desired side would derive a REMOVAL of a live capacity block from a template whose only fault is BillingMode. #1733: an ABSENT recorded BillingMode resolved to the create-path default PAY_PER_REQUEST WITHOUT consulting AWS (#1552 scoped its live-read fallback to the present-but-unusable shape), so a record with no mode — left by cdkd import of a PROVISIONED table, or by this arm's own DROP — made a corrected PAY_PER_REQUEST template compare EQUAL, issue no UpdateTable, and silently lose the flip, with cdkd drift unable 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 DECLARES BillingMode, 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::Table has resolved an absent previous exactly that way all along, so GlobalTable CONVERGED onto its sibling rather than diverging from it. A BillingModeSummary AWS does not report resolves to PROVISIONED, the same reading liveBillingMode and both providers' readCurrentState already 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-mode UpdateTable from the other side (a record with no mode against a PROVISIONED template 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 on oldBilling !== newBilling and a baseline resolved from DescribeTable IS the mode AWS holds. The seeded branch also REMOVES a spurious call — a PROVISIONED template on an already-PROVISIONED table whose record carried no mode used to read as a real flip. Both halves are live-covered by new dynamodb-globaltable fixture 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 new BillingSeedTable drives 13j (state record patched to remove BillingMode, corrected template must APPLY the flip) and 13k (kept-PAY_PER_REQUEST must retain the previous replica read capacity). The run also MEASURED what BillingModeSummary reports 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 required list, so a required-ness-dependent parity verdict can be fenced (issue #1800) — scripts/refresh-cfn-schemas.mjs (+ its .d.mts), all 134 tests/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 captured properties / readOnlyProperties / createOnlyProperties / primaryIdentifier / nestedProperties / nestedPropertyPaths / definitionShapes — but not required, which the live registry schema does carry. Why that mattered: the #1225 classification of AWS::ECS::Service.DeploymentConfiguration concluded 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 kept DeploymentCircuitBreaker missing Rollback has its rollback REPLACED true -> false by UpdateService (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 no required in 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 a guard the guard assertion designed to RED the day the fixture started carrying required. What shipped: extractDefinitionRequired captures the per-definition list (top-level under the same reserved #top key definitionShapes uses; 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 exact toEqual, since a member DROPPED from a required list is precisely the relaxation being fenced and toContain cannot see a removal. It also asserts the ABSENCE of a list on LinearConfiguration / 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: the definitionShapes loud-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 sibling nestedPropertyPaths fence), 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-array required). One side effect of the re-capture was measured rather than assumed, because it is the property backfill flips CC route class 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 a silentDrop entry — 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), and Listener.Tags looked like a live route flip for the alb / alb-advanced fixtures, both of which apply stack-level cdk.Tags.of(this). It is not: every one of the nine is absent from its aws-cdk-lib 2.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-written EvaluationInterval entry records. A hand-authored L1 using addPropertyOverride can 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 in tests/fixtures/cfn-schemas/_todo-backfill.json, the ledger built for exactly this case, regenerated via the documented CDKD_GENERATE_BACKFILL=true (umbrella campaign: #609). (b) That regeneration ALSO cleaned a pile of STALE entries — AWS::ApiGateway::Stage entirely, plus LaunchConfigurationName / NotificationConfiguration / AvailabilityZone / ElasticGpuSpecifications / ElasticInferenceAccelerators / EvaluationCriteria / EvaluationInterval and 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 via handledProperties / 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 to AWS::RDS::DBSubnetGroup (DBSubnetGroupArn) and AWS::SSM::Parameter (Arn), neither provider caches it, and since the resolver reads the cached attributes[<CFnName>] rather than calling getAttribute, an output Fn::GetAtt on either HARD-FAILS the *Arn shape guard — the #1179 class. Both are recorded as KNOWN GAP entries in SDK_ATTR_ALLOW_LIST pointing at issue #1824 (the same disposition AWS::Lambda::EventSourceMapping.EventSourceMappingArn had 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 editing src/provisioning/providers/**, which activates the integ-destroy gate 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) The AWS::SNS::Subscription entry in SDK_ATTR_ALLOW_LIST is now provably INERT and is RETIRED: its fixture predated the #1694 primaryIdentifier capture, so Arn used to reach the allow-list, and once the re-capture gave the fixture primaryIdentifier: ['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" behavior extractPrimaryIdentifier exists for. It was found by a fence added in this PR, not by reading: classifyType tests cachedKeys before the allow-list, so a fixed or superseded entry silently goes inert and nothing flags it (there is no findStaleAllowListEntries here the way there is in gen-nested-key-coverage.ts). The new test asserts every allow-listed attribute still classifies allow-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 said gap: 0 for two types whose Fn::GetAtt hard-fails, because a NOT-A-BUG entry and a tracked real gap shared one bucket; AllowListEntry gained a knownGap flag 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 — generatedAt is the discriminator), and the capture sees only a definition's OWN top-level required array, so required-ness expressed through a oneOf combinator (AWS::S3::Bucket's TargetObjectKeyFormat) or on an inline nested object (AWS::WAFv2::WebACL's FieldToMatch.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 new definitionRequired section, and no CLI flag, dependency, or state-schema change.
  • cdkd export stops 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::VPCCidrBlock has a COMPOSITE primaryIdentifier and had no COMPOSITE_ID_SPLITTERS entry, and cdkd export is all-or-nothing, so its mere presence aborted the WHOLE command with add an entry to COMPOSITE_ID_SPLITTERS — while any VPC carrying a secondary IPv4 CIDR or an Amazon-provided IPv6 CIDR declares one, including every CDK Vpc configured with ipProtocol: IpProtocol.DUAL_STACK. Every schema fact behind the entry is a live DescribeType measurement (us-east-1, 2026-08-13), not the issue's table: identifier [Id, VpcId], with Id read-only, so the overlay narrows to VpcId — the same narrowing, for the same reason, as the AWS::EC2::VPCGatewayAttachment / AWS::EC2::Route siblings. 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 with vpc-, but the association id carries the longer, distinct vpc-cidr-assoc- prefix, so the two ARE discriminable and the splitter shape-binds BOTH (the way the AWS::EC2::EIP entry binds eipalloc-; validating one side only is not a discriminator). It also accepts the BARE <Id> form, recovering VpcId from recorded properties, because CloudFormation reports this type's PhysicalResourceId as the association id alone — the shape a --migrate-from-cloudformation stack carries. #1787: overlayResourceIdentifierOnProperties only 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 a Ref / Fn::GetAtt intrinsic 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 is AWS::S3Tables::Namespace, whose registry type is {"type": "string"} and whose CDK L1 emits a string, but addPropertyOverride('Namespace', ['analytics']) synthesizes "Namespace": ["analytics"] — which cdkd deploys, because s3-tables-provider.ts accepts both wire shapes. The array was not a literal string, so the overlay left it alone and it reached CreateChangeSet, 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 (filterTemplateForImport and applyImportOverlayForPhase2 — 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. resolveBucketRegion built its GetBucketLocation probe client with a HARDCODED region: '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, and upload-cfn-template's TemplateURL (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 new opts.region (the caller's own region), opts.fallbackRegion, the AWS SDK's own region chain, and only then us-east-1. The cross-region question was MEASURED, not assumed (2026-08-13, real eu-west-1 bucket): SDK v3 GetBucketLocation resolves 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 passes fallbackRegion" was wrong: rebuildClientForBucketRegion already passes it, sourced from the client's own config.region(), which covers the whole state-bucket family; and deploy / destroy / export / import / orphan each assign --region into process.env.AWS_REGION before any work, so the SDK chain covers uploadCfnTemplate's five call sites (two of which live in export.ts, held by a parallel lane). A caller with no region configured anywhere still probes us-east-1, so commercial behavior is preserved rather than merely equivalent. Second half of the fix: only a SUCCESSFUL lookup is now cached, mirroring write-only-properties.ts and #1746's getAccountInfo — 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) and AWS::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-index KeySchema, 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 the properties-baseline population a reverse-replacement rollback leaves behind. Appending LEAF_ONLY_PATH_SUFFIX ([]) now claims the path ALONE: 'GlobalSecondaryIndexes[]' sorts the list and stops. The marker is honored by BOTH getDriftUnknownPaths and getDriftUnorderedPaths rather 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 by drift.ts to 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: calculateResourceDrift compares arrays wholesale via deepEqual and never descends, so isIgnoredPath is 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-written observedProperties record with a post-fix readback instead, with no ignore-path and no lost detection. It takes NO side argument on purpose (one-sided normalization is the mistake drift-normalize.ts's header already records — it manufactures drift on the properties-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 the aws bag --accept writes to state and --revert diffs against is untouched. Both seams are pinned by command-path tests that drive runDriftForStack (not the comparator directly) plus negative controls proving the clean result comes from the declaration being threaded through: the SUBTREE spelling is shown HIDING the KeySchema reorder 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_SUFFIXES was a hand-written literal maintained as a SUPERSET of derivePartitionAndUrlSuffix's arms by convention only, and the test meant to fence it iterated a hand-written region list — so a new arm in PARTITION_TABLE redded nothing and gc would go on treating that partition's assets as UNREFERENCED, the irreversible direction. The list is now built from PARTITION_TABLE's suffixes plus a declared GC_EXTRA_URL_SUFFIXES seam (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 naming eusc-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 with src/utils/ecr-uri.ts (which matches only the plain dkr.ecr form, 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::Bucket host attributes derive their URL suffix, and the resolver and provider halves can no longer disagree (issue #1745) — new src/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 / WebsiteURL were templated in TWO places — the provider records them into state, the resolver answers a cross-resource Fn::GetAtt state has no cached value for — and both hardcoded the commercial amazonaws.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 the Fn::GetAtt answer disagree with what readCurrentState reports, the phantom-drift shape .claude/rules/providers.md warns about. The provider's own Arn moved to arn:${partition}:s3::: in the same object (the #1730 class, one line over). WebsiteURL was NOT a mechanical suffix swap, which the issue asked to verify rather than splice: AWS serves the legacy s3-website-<region> spelling for exactly NINE regions and s3-website.<region> for every other, so the separator is per REGION and cannot be derived from the partition — us-gov-west-1 takes the hyphen while its us-gov-east-1 sibling takes the dot. The set was read out of aws-cdk-lib's region-info S3_STATIC_WEBSITE_ENDPOINT facts (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 — an eu-central-1 bucket's WebsiteURL used to name a host AWS does not serve. The resolver additionally gained a DualStackDomainName case: 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 S3 Arn enrichment (so the two ROUTES disagree for the same bucket) and in three self-authored IAM policies — issue #1794. And DomainName is 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.cn is NXDOMAIN; GovCloud's …s3.amazonaws.com resolves to COMMERCIAL S3). cdkd emits the spelling aws-cdk-lib's own Bucket.fromBucketAttributes uses 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 in state.attributes and refresh only on the bucket's next create/update, so an existing non-legacy-region stack keeps its old WebsiteURL until then.
  • ECS DeploymentConfiguration sub-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 — UpdateService merges at the sub-field level, so a template keeping DeploymentConfiguration while dropping MinimumHealthyPercent retains 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 only MaximumPercent: 150 left MinimumHealthyPercent at 50 and the circuit breaker enabled — but CloudFormation renders the same partial block and reached the IDENTICAL end state on a real UPDATE_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's undefined is 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 kept DeploymentCircuitBreaker missing Rollback has the nested struct REPLACED rather than merged, and the SDK ACCEPTS it (a live rollback went true -> false), while CloudFormation refuses the same template up front (Model validation failed (#/DeploymentConfiguration/DeploymentCircuitBreaker: required key [Rollback] not found), UPDATE_ROLLBACK_COMPLETE with the live config intact) because the registry schema marks the DeploymentCircuitBreaker definition required [Enable, Rollback] and DeploymentAlarms (the Alarms property) required [AlarmNames, Rollback, Enable]. cdkd enforces nested required-ness NOWHERE — property-coverage.ts is top-level-only and mutually-exclusive-properties.ts handles combinations, neither reads a definition's required list — 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-filling Rollback from the previous side, which would invent a value the template never declared and would fix one property rather than the class. The ASG InstanceMaintenancePolicy analogy 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-filled rollback, a fill-only-missing on the CREATE path at both levels, and the Alarms twin). The accompanying schema check deliberately does NOT claim to fence a relaxed required list — the fixtures capture no required at 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 carrying required (#1800). Both pins are mutation-probed against the real provider: adding a clearOnUpdateRemoval reset reds the removal test, merging the previous block into the desired one reds two more. Remaining #1225 scope is now only item 2, ASG CapacityReservationSpecification, 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.delete hands a protected AWS::AutoScaling::AutoScalingGroup to ASGProvider.delete under --remove-protection and used to await + bare return, 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 (matching NestedStackProvider.delete's skippedCount / interrupted forwarding) 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 as ResourceProvider so the forwarding survives ASGProvider widening its own return type. The provider's delete return type widened to Promise<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: the Removed failed-create remnant debug 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 four update() implementations that pair create() + delete() discarded the delete result, and because a skip does not throw it slipped past the very catch whose 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; ResourceUpdateResult has no skip channel), so each WARNS with the skip's reason in the same orphan wording its failure arm already used; delete-then-create (sns-subscription) ABORTS with a ProvisioningError before 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 (NestedStackProvider reports skipped after 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 every update() 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 existing try) lives OUTSIDE the try, so delete()'s own ProvisioningError failure path keeps its warn-and-continue semantics; and the thrown message interpolates NOTHING but the template logical id, because the rollback arms wrap update() in withRetry and retryable-errors.ts classifies by SUBSTRING — a provider-supplied reason or a state-borne physicalId carrying does not exist / Rate exceeded / DependencyViolation would 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 the ProvisioningError'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 like MyDependencyViolationSub carries a retryable pattern (measured), and a message naming nothing is not diagnosable. So src/deployment/retryable-errors.ts gained a markNonRetryable / isMarkedNonRetryable pair (a non-enumerable Symbol.for marker, walked down the .cause chain like isThrottlingError) that isRetryableTransientError consults 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 — withRetry rethrows a marked error ABOVE its opts.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 --replace delete-first fallback and the rollback executor's reverse-replacement — are MESSAGE-only, so they could not see it even in principle), and destroy-runner.ts's delete-retry loop gates its own Too Many Requests message 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. markNonRetryable is narrowed to E 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 as throw markNonRetryable(...), where a TypeError would 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 a PendingConfirmation-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 in lambda-layer / lambda-permission / custom-resource / iam-policy / iam-user-group, none of which CloudControlProvider delegates to or any of the four REPLACE update()s calls; AWS::IAM::Policy is a different type and file from AWS::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 real replayRollback revert arm and proving the abort surfaces after ONE attempt (benign and hostile reasons) and hostile-reason / hostile-physicalId regression 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 gave ResourceProvider.delete its ResourceDeleteResult return 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 bare void, so cdkd destroy printed ✓ <id> (<type>) deleted, counted them toward N 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 separate skipped, 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-LayerVersionArn arms — a too-short ARN and an unparsable trailing version, kept as two reasons because they point at different halves of the id); a RemovePermission statement stays on the function's resource policy, i.e. an invoke grant outliving the stack; a Custom Resource's handler never sees a Delete request, so whatever it manages in a third-party API / another account is untouched (both the no-properties and the no-ServiceToken arms); an inline IAM policy stays ATTACHED to its roles / groups / users, i.e. live permissions outliving the stack; and an AWS::IAM::UserToGroupAddition's memberships survive, so the users keep every permission the group grants. Each reason is an exported named constant beside its provider (deliberately NOT one shared literal like COMPOSITE_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, name state / physicalId, and say what did NOT happen. The two AWS::IAM::UserToGroupAddition arms 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", but GroupName and Users are BOTH required by the CloudFormation schema, so a record missing either is CORRUPT rather than empty, and the create path issued real AddUserToGroup calls — 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 EMPTY Users: [] is a VALID membership list, falls through the !users guard (an array is truthy), does nothing and correctly reports deleted — collapsing the two would turn a legitimate no-op destroy into a non-zero exit. The *NotFound idempotent arms in the same files are untouched, as is CustomResourceProvider'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 reports deleted. 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::Permission read only properties['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, and RemovePermission accepts a full ARN as FunctionName; AWS::IAM::Policy derived the name only from the physicalId, although PolicyName is in handledProperties and create() 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 a DeleteRolePolicy for a name AWS never had — and the Lambda fallback is gated on arn: + :function: so a non-Lambda ARN, or a statementId that merely contains |, is never sent as a function name. POLICY_NAME_SKIP_REASON changed to no policy name in state accordingly, 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 caveat compositeIdFormatMessage does; "LEFT IN PLACE" was asserted unconditionally and is FALSE for four arms when the parent is in the same stack (deleteGroup -> removeAllUsersFromGroup and deleteUser -> removeUserFromAllGroups remove 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 name cdkd state orphan <stack>, while the layer / Custom Resource arms deliberately do NOT, a false reassurance being worse than the warning it softens; and the UserToGroupAddition comment cited the wrong producer for Users: [] — createUserToGroupAddition THROWS on an empty list, so the reachable producer is updateUserToGroupAddition, which removes every user and records []. The outcome: 'skipped' member JSDoc in src/types/resource.ts also still read "no AWS call was issued", contradicting the block comment 20 lines above it that warns against exactly that reading (NestedStackProvider reports skipped after 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-string PolicyName shapes (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 #1762 caveat 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. warnContains was also made arm-SPECIFIC, since seven arms shared the literal LEFT IN PLACE and 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-bearing typeof half 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 silent deleted over a LIVE resource - worse than the skip they replaced - in three ways, all fixed. (1) The typeof hardening had been applied to the IAM guard but NOT to its Lambda sibling, so a truthy NON-string FunctionName ({ Ref: 'MyFn' }, ['my-fn'], a number) beat a perfectly good ARN: the SDK URI-encodes it, [object Object] comes back ResourceNotFoundException, 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 sent RemovePermission to the wrong region, came back ResourceNotFoundException, 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) The arn: requirement REFUSED a genuine second source: the CFn primary identifier is [FunctionName, Id] and FunctionName is often a BARE name (src/cli/commands/export.ts documents 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's StatementId pattern 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 reach RemovePermission with 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 at FunctionName would be the wrong half of the record). The IAM PolicyName fallback separately converted an honest skipped into a silent deleted: with the name now resolvable the guard passed, but an inline policy exists only as an ATTACHMENT, and a record naming no Roles / Groups / Users and 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 new POLICY_NO_TARGET_SKIP_REASON arm reports it. The guard deliberately uses the SAME truthiness spelling the legacy branch and the removal loops use rather than === undefined - a null-valued Roles, which a hand-edited or pre-v7 state file carries, would otherwise fall through into the very hole being closed - while a PRESENT-but-empty Roles: [] stays an honest deleted (nothing is attached, so there is nothing to remove; the same judgment as Users: []). 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 from warnContains one 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-bearing typeof half was probed instead once non-string cases existed) and the === undefined vs !roles distinction (which the Roles: null test 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. parseEcrRegistryHost compares a registry host's captured suffix against derivePartitionAndUrlSuffix(region).urlSuffix so a look-alike host cannot attract a docker login by 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's main the 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:t was REJECTED (correct — a commercial suffix on an ISO region is a look-alike) while 123456789012.dkr.ecr.US-ISO-EAST-1.amazonaws.com/r:t was ACCEPTED, because upper-casing the region fails every startsWith prefix test in the partition helper, falls through to the commercial partition, and that partition's amazonaws.com is 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, so US-ISO-EAST-1.c2s.ic.gov, us-east-1.AMAZONAWS.COM and CN-NORTH-1.amazonaws.com.cn were all REJECTED — a real ECR registry silently classified as a user-managed image with no docker login, i.e. the #1758 regression one layer up. Both halves now normalize at ONE boundary, a new module-private matchEcrRegistryHost that 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: looksLikeEcrHostWithForeignSuffix is 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 returned false, so ecs-task-resolver.ts did not even log the #1764 foreign-suffix diagnostic. The normalization lives here rather than in derivePartitionAndUrlSuffix (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.ts derive ${AWS::URLSuffix} from the RAW user --region, so --region CN-NORTH-1 synthesizes a cn- 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 a docker login at 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 added canonicalizeRegion inside derivePartitionAndUrlSuffix so 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 turns us-eKst-1 into the plausible us-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 returned region is now the canonical lower-case spelling, which matters beyond the comparison: ecr-puller.ts uses it as an SDK client region and ecs-task-resolver.ts as the region of a kind: '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 another describe, 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 the startsWith classification -- any leading junk defeats it identically, and the captured segment does not stay inert (it becomes an ECRClient({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 with amazonaws.com would reject cn-north-1 for 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's aws-isoe suffix cloud.adc-e.uk CONTAINS a k, 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, never git checkout. One finding audited and deliberately NOT ridden along: the dkr.ecr LABELS in ECR_URI_HOST_REGEX are still matched case-sensitively, so <acct>.DKR.ECR.<region>.<suffix>/ does not match at all (measured; falls through as a public image, and returns false from 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-task and local invoke-agentcore fall through to an anonymous docker pull that fails, while local invoke and local start-api REFUSE 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 AFTER toLowerCase(), and String.prototype.toLowerCase performs full Unicode case folding, so U+212A KELVIN SIGN folds to ASCII k and ...dkr.ecr.us-e\u212Ast-1.amazonaws.com/... was ACCEPTED as the region us-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 that aws-partition.ts was 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 temporary DOCKER_CONFIG, that docker's credential store is keyed on the hostname VERBATIM, so ecr-puller.ts logging 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 in src/local/** and carries its own integ-local obligation. 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 gc stops 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 commercial amazonaws.com URL suffix, so a state file recording amazonaws.com.cn / c2s.ic.gov / sc2s.sgov.gov hosts 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_RE collects <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-run against a seeded cn-north-1 state 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 are sha256:<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 closed AWS_URL_SUFFIXES SET — the union over EVERY partition, not derivePartitionAndUrlSuffix(region).urlSuffix for 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 a cn-north-1 stack's images during a us-east-1 gc 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.eu are 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 than src/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 to ecr(?:-fips)? plus the short-form <acct>.dkr-ecr.<region>.on.aws alias, kept in a separate ECR_REGISTRY_HOST constant precisely so on.aws cannot 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-end cdkd gc --region cn-north-1 run over a state file recording amazonaws.com.cn hosts 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 needs src/utils/aws-partition.ts to 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.delete runs the child stack's destroy through a nested DestroyRunner, and DestroyRunnerResult carries THREE fields that say "this child stack is NOT gone". (#1752) / PR (#1774) propagated two of them — skippedCount and interrupted — 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 own errorCount still 0, so preserveState evaluated to FALSE — deleted the parent's state.json AND the exports index outright, exiting 0. The end state was worse than a dangling pointer: the child's own state.json sat there preserved describing live, billing resources, and nothing left named it, so recovery required knowing the <parent>~<child> key layout by hand. Now delete throws, naming the child stack, the failure count, and cdkd 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.json and the exports index survive, and the run exits 2 (PartialFailureError) — the same "state preserved, stack not destroyed" contract errorCount > 0 already carries everywhere else. This is a deliberate BEHAVIOR CHANGE and it reaches BOTH callers of delete: cdkd destroy / cdkd state destroy fail the row (exit 2), AND cdkd 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 — so deploy-engine.ts gains this behavior with no edit to it. The review found a self-defeating gap and it is fixed here: the destroy runner's errorCount > 0 summary named cdkd state orphan <parentStack> as the last resort, and following that advice DROPS the Child row — 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.ts now collects failedStateTargets alongside #1752's skippedStateTargets, both through one shared stateTargetFor(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; a stackName fallback keeps the hint non-empty if a future path ever increments errorCount without 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 once errorCount > 0), so naming only the failed target printed , N skipped in 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 when skippedStateTargets is non-empty, and both arms build their hints through one hintFor helper. Two more review items shipped with it: the message builder moved OUT of the provider into a new dependency-free LEAF module src/provisioning/nested-stack-messages.ts, because importing it from the provider dragged destroy-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's verify.sh gained an assert_exists mirroring gone_probe's THREE-way outcome, since a wrapped if ! 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. The errorCount check 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's was 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 ABSENT errorCount falls 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-errorCount row, 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 into errorCount exactly 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 the Child row, state.json and 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 ordinary Failed 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 exported nestedStackChildFailureMessage builder rather than a hand-copied literal, so wording drift into an already-deleted shape cannot leave a stale literal passing, and each fake provider sets disableOuterRetry: true like the real one so the harness exercises production's single-attempt path. Mutation-probed in SIX cuts against real source, each restored by hand: the errorCount throw neutered → 7 provider rows red; || childResult.interrupted removed → the interrupt row red; failedStateTargets recording 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 a verify.sh (it had none — the ledger records the test as mode standard, 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 to verify.sh). It provokes a genuinely failing child delete without touching the CDK app: the child's S3 bucket has no autoDeleteObjects, 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 … deleted line, 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's Child row 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-run mktemp -d rather than fixed /tmp paths, 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 of autoDeleteObjects is 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 its rc -ne 2 check (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. Because destroy-runner.ts is in the integ-broad gate scope, this change needs a broad integ in addition to the feature one. No CLI flag, dependency, or state-schema change.
  • cdkd export no longer aborts on a Route / EIP / EventInvokeConfig, and the VPCGatewayAttachment identifier 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 export is all-or-nothing, so ONE composite-primaryIdentifier type with no COMPOSITE_ID_SPLITTERS entry 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 live DescribeType measurement (us-east-1, 2026-08-13), not the issue's table. AWS::EC2::Route: identifier [RouteTableId, CidrBlock] with CidrBlock read-only, so the overlay narrows to RouteTableId. The subtlety is what CidrBlock HOLDS: the schema gives it an empty description, so "it is the IPv4 CIDR" is the natural and wrong reading. Cloud Control GetResource against a scratch route table carrying both shapes says otherwise — rtb-…|::/0 reads back {"CidrBlock":"::/0","DestinationIpv6CidrBlock":"::/0"} — i.e. it carries whichever of DestinationCidrBlock / DestinationIpv6CidrBlock / DestinationPrefixListId the 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: createRoute packs the destination it SENT into the physicalId and EC2 clears host bits on the way in, so an SDK-written state entry can hold rtb-…|100.68.0.18/18 while AWS holds 100.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 on CreateRoute (CFn documents rewriting 100.68.0.18/18 to 100.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, so pl-AAA vs pl-BBB is 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 whose propertiesOverlay is an explicit empty map; a bare eipalloc-… / 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 passes eipalloc-a|eipalloc-b and then ships PublicIp: 'eipalloc-b' — rather than by position: every writer goes through eipPhysicalId, 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 own parsePhysicalId, and the id splits on the FIRST | so a function ARN survives. Plus a correction to #1691's AWS::EC2::VPCGatewayAttachment entry: it produced AttachmentType values of InternetGateway / 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 are IGW / VGW (Cloud Control ListResources against 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 uses Object.hasOwn so a first segment of constructor cannot 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 real ChangeSetType=IMPORT changesets — both reached IMPORT_COMPLETE, and the same run is what produced the Invalid Attachment Type rejection. Tests: the export integ fixture now declares all of it (VPC / IGW / attachment / route table / default route / EIP / EventInvokeConfig), verify.sh asserts the RESOLVED identifier value per type in the printed import plan — from a SHARED helper called by BOTH the default and dry-run arms, because /run-integ export runs 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-…|IGW vs IGW|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 types block, 2 on the corrected VPCGatewayAttachment entry, 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, since propertiesOverlay: {} only protects the EIP if the ?? resourceIdentifier fallbacks treat it as write-nothing), the $LATEST bare form, the shape-bound EIP segments, the Object.hasOwn alias lookup, the previously-unreached vgw- 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 the every-vs-some decidability boundary, the /0 mask special case, and both registry lookups. The IPv6 route arm is deliberately NOT in the fixture: it needs an AWS::EC2::VPCCidrBlock, itself an unregistered composite type that would abort the same export (#1788). Also corrects a stale comment in s3-tables-provider.ts claiming the CFn schema types Namespace as List<String> (it is {"type": "string"}; the provider still accepts both wire shapes because addPropertyOverride can 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::SecurityGroupIngress records the sgr-… rule id, so cdkd export has 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 tuple RevokeSecurityGroupIngress needs, and correct for deploy / destroy / drift — but CloudFormation identifies a rule by the SINGLE field Id, which is the sgr-… security-group rule id AWS mints (live describe-type, us-east-1, 2026-08-13: primaryIdentifier and readOnlyProperties are both exactly ["/properties/Id"], handlers create/delete/list/read/update). createSecurityGroupIngress threw that value away (attributes: {} on both arms), which is why #1659 had to register the type as a pure REFUSAL in COMPOSITE_PHYSICAL_ID_IDENTIFIERS. It is now recorded under Id — the same name the three sibling composite types use, i.e. the CFn primaryIdentifier field, which is also the type's only Fn::GetAtt attribute. Where the value comes from is the load-bearing part: it is read off AuthorizeSecurityGroupIngress's OWN response (SecurityGroupRules[].SecurityGroupRuleId, verified against @aws-sdk/client-ec2's AuthorizeSecurityGroupIngressResult), never from a follow-up describe — an await added inside create()'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 both CidrIp and CidrIpv6 makes 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-effort DescribeSecurityGroupRules lookup matched on protocol + ports + the one declared source — deliberately NOT via sgRuleKey, whose SourceSecurityGroupOwnerId / Description members 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 is AWS::EC2::SecurityGroupEgress, whose delete calls a different API), and records the composite plus the attribute. No handledProperties / readback companion is needed — Id is read-only, never templated, and lives in attributes, which drift does not compare. The export side is converted in the same PR. COMPOSITE_PHYSICAL_ID_IDENTIFIERS' AWS::EC2::SecurityGroupIngress entry was a pure REFUSAL that existed only because nothing recorded the id; it is now a recordedArnIdentifier-shaped resolution reading attributes.Id, with an EMPTY propertiesOverlay (Id is the type's only identifier field AND is readOnlyProperties, so CFn rejects writing it into the template's Properties). 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 bare startsWith('sgr-') accepts sgr-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 reports PhysicalResourceId sgr-02345615af6d2db0d, with Ref and Fn::GetAtt .Id returning that same value — so for THIS type, unlike AWS::EC2::EIP / ::VPCGatewayAttachment, CFn's string agrees with the identifier. tests/unit/cli/export-composite-identifier.test.ts had a case PINNING the refusal ("refuses … even when an Id attribute happens to be present", justified by "nothing in cdkd writes an Id attribute 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 recorded Id and cdkd export blocks it — with a message that does NOT say "re-deploy the stack once" like its three siblings, because the id rides AuthorizeSecurityGroupIngress'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 the sg-circular-dependency integ — state must carry an sgr--shaped attributes.Id, and cdkd export --dry-run must 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) because cdkd export aborts on the first unresolvable resource even under --dry-run, and the circular-ref fixture's ec2.Vpc emits an AWS::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 (attributes is 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. readCurrentState emitted the DescribeTable index descriptions unchanged, so IndexStatus / Backfilling / ItemCount / IndexSizeBytes / IndexArn, the on-demand {0, 0} ProvisionedThroughput placeholder and the AWS-computed per-index WarmThroughput all took part in drift detection — the method's docstring claimed "the comparator filters them" and it does not, because that filter is calculateResourceDrift's state-keys-only TOP-LEVEL walk while these are members of an ARRAY the baseline DOES carry, compared positionally by deepEqual. Two failures fell out: a properties baseline could never equal the readback (permanent phantom), and — worse, on the ORDINARY path — ItemCount / IndexSizeBytes MOVE with write traffic, so any in-use table with an index drifted against its own observedProperties on a schedule set by its own traffic, with drift --revert re-sending the frozen blob through update(). 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 same IndexName declaring them — the #1760 shape one nesting level down, matched by NAME never by position). The allow-list is what caught Backfilling, which the issue's quoted sample does not list. The getDriftUnknownPaths companion 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-fix observedProperties reports a one-sided difference until the next deploy or cdkd 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 in getDriftUnorderedPaths: a plain entry there is a SUBTREE declaration whose walk descends into array elements, so it would also sort the per-index KeySchema and 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 declared WarmThroughput AWS has GROWN was unrevertable. Measured live (us-east-1, 2026-08-13): against a table AWS reports {12000, 4000} for, an UpdateTable lowering either member is REJECTED (decreasing WarmThroughput is not supported) while a re-assert of the equal value is accepted — so cdkd drift --revert issued a call that could only fail and the resource reported could 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). No effectiveProperties is 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 every update() 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: applyGsiUpdates sent only ProvisionedThroughput / OnDemandThroughput, so a template-declared per-index WarmThroughput was silently dropped on every index add / change and --revert exited 0 claiming success — it now rides the Create action and gets its own Update action (measured: a WarmThroughput-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 rides tests/integration/dynamodb-gsi-update: the fixture now writes real items, asserts the deploy-written observedProperties index entry carries ONLY the CFn members, asserts cdkd drift reports 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-index ProvisionedThroughput (AWS's on-demand placeholder) arriving from a pre-fix state record via --revert is 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-index WarmThroughput gained the liveCapacityAlreadyMatches twin it was missing (warmThroughputAlreadyMatches), without which a --revert of a stale blob emitted one redundant UpdateTable plus 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, so WarmThroughput: {} / '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-index WarmThroughput at all; and NonKeyAttributes is copied rather than aliased into the SDK response, like its KeySchema sibling. The suspected WarmThroughput.Status / runGsiOps race was MEASURED rather than reasoned about (us-east-1, 2026-08-13): the wait predicate IS satisfied while a warm update settles — IndexStatus: ACTIVE alongside WarmThroughput.Status: UPDATING for 90+ seconds — but all three next-op shapes (warm Update on another index, on the SAME index, and a GSI Create) 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 declares WarmThroughput: {12001, 4000} on gsi1, which rides the Create action the GSI add already issues — no extra AWS call — and verify.sh asserts 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 for WarmThroughput.Status, because the same measurement shows the units report the requested value from the first poll (while IndexStatus is 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 becomes IndexName,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 refused WarmThroughput (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; warmThroughputOpFor and the capacity path share ONE Update action per index (both members are optional on UpdateGlobalSecondaryIndexAction, so splitting them cost a second UpdateTable plus 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 -e kills 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 claimed update() takes no context when UpdateContext exists and s3-bucket-provider.ts consumes it (the behavior stands: its one field separates drift --revert only, and the rollback replay is still indistinguishable from a template deploy), and indexDeclares's header had claimed its predicate is what the write path calls universally, which is false for create()'s verbatim CreateTable forward. Review round 4 fixed a regression the round-3 narrowing had introduced — one if (!('ProvisionedThroughput' in live)) return out; guard ahead of all three throughput blocks gated OnDemandThroughput and WarmThroughput on a THIRD, SDK-optional member, so the ordinary PAY_PER_REQUEST-with-caps readback (OnDemand + Warm, no ProvisionedThroughput key) 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: two properties!['…'] non-null assertions are not followed by scripts/gen-handled-property-wiring.ts, which had silently dropped the getDriftUnknownPaths / readCurrentState / delegated evidence for WarmThroughput and both index lists from the checked-in matrix — an explicit properties !== 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, and getDriftUnknownPaths then answered "declared" for it. The value is now COERCED per member at every send site ('12000' -> 12000, matching what create()'s table-level ProvisionedThroughput already did), an unusable member is dropped and named rather than reaching AWS as NaN, and isSendableWarmThroughput is 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 declared GlobalSecondaryIndexes array to CreateTable, 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 a readCurrentState shape 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 that Projection's rebuilt key order is what applyGsiUpdates's JSON.stringify compare 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"; the Projection key-order flip comes from the TEMPLATE side, since aws-cdk-lib emits {NonKeyAttributes, ProjectionType} while a readback is always the reverse; and the note claiming create() forwards a per-index WarmThroughput verbatim 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 a WarmThroughput rather than unknown so 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 the AWS::DynamoDB::GlobalTable replay-CREATE arms (the rollback-replay-effective-props fixture 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 #1544 replayWarn downgrade when a state record carries a malformed GlobalSecondaryIndexes: the translator warns and returns the well-defined EMPTY list, so CreateTable goes out with no indexes — but with the record's AttributeDefinitions UNCHANGED. DynamoDB requires the definitions to be exactly the attributes named by KeySchema and 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 prunes AttributeDefinitions to the attributes the call still keys on, and records the pruned list in effectiveProperties for the same reason #1724 drops the index key (an unsent definition is a record readCurrentState can never match). LSI key attributes SURVIVE — LocalSecondaryIndexes is create-only and is not omitted by this arm, so a naive "keep only the table KeySchema" would break a table with an LSI in the opposite direction — which is why collectDesiredKeyAttributeNames gained 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 unreadable KeySchema (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 keeps Replicas[].GlobalSecondaryIndexes overrides, which addReplica then 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 is properties rather than observedProperties, i.e. after any reverse-replacement rollback, which rollback-executor.ts strips observedProperties on — which is why they had not been seen: on the ordinary path both comparison sides come from the same readback and already agree. (a) AttributeDefinitions is an unordered SET compared positionally: DescribeTable returns 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 in getDriftUnorderedPaths — the first such declaration on this type, so the method is new — and sorting cannot hide a real change because the list is keyed by AttributeName. KeySchema is deliberately NOT declared at either level, being order-SIGNIFICANT (HASH before RANGE). (b) GlobalSecondaryIndexes[].WarmThroughput is 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 the readCurrentState emission on the desired side -- was implemented, reviewed, and then REVERTED, because it trades one population's phantom drift for another's: every observedProperties bag already in S3 was written by a binary that emitted the member, so the first cdkd drift after upgrading would report the whole GlobalSecondaryIndexes array on every untouched table (measured live on the sibling AWS::DynamoDB::Table type under #1760). The companion that closes that transition -- declaring the path in getDriftUnknownPaths -- 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 the drift-protocol-normalize.ts mould 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 sibling AWS::DynamoDB::Table provider has the identical un-declared AttributeDefinitions readback and an unconditional table-level WarmThroughput emission — 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 (the AWS::DynamoDB::GlobalTable <logicalId>: prefix and the "compared against" wording are both present and fenced at dynamodb-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 unreadable KeySchema falls 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 SAME canonicalizeUnorderedArraysAtPaths helper drift-calculator.ts applies, 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::Table phantom drifts — AttributeDefinitions order and the AWS-computed table-level WarmThroughput (issue #1760) — src/provisioning/providers/dynamodb-table-provider.ts, tests/unit/provisioning/dynamodb-table-provider-drift-phantoms.test.ts. The AWS::DynamoDB::Table twin of the AWS::DynamoDB::GlobalTable pair 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. AttributeDefinitions is the half the issue describes: reachable only when the drift baseline is the template properties rather than observedProperties (after a reverse-replacement rollback, which strips observedProperties, 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. WarmThroughput is the OPPOSITE: the comparator's top-level walk iterates Object.keys(stateProperties) (src/analyzer/drift-calculator.ts), so a key the template never declared cannot drift against a properties baseline AT ALL. Its harm lands on the observedProperties baseline, 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, which drift --revert then re-sends. AttributeDefinitions. The provider declared no getDriftUnorderedPaths at all while emitting the readback verbatim, and the property is a genuine SET keyed by AttributeName: measured us-east-1 2026-08-13, a CreateTable declaring [{pk,S}, {gsipk,S}] read back as [{gsipk,S}, {pk,S}] on the create response AND on every later DescribeTable, so the comparator's positional array compare reported permanent drift on a table nobody touched. It is now declared unordered; KeySchema is 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, DescribeTable returned {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 an observedProperties baseline turns a later AWS-side increase into drift on a property the user never declared, which drift --revert then re-sends through update() as a decrease AWS rejects. readCurrentState now 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 the properties parameter 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 declared WarmThroughput: null sends nothing while AWS still computes 12000/4000, and an !== undefined drift-side gate would re-create the identical phantom one value over — with --revert unable to clear it, since the write gate skips a falsy value. One declaresWarmThroughput predicate 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 in observedProperties, so dropping it from the AWS side flips EVERY existing table to a one-sided WarmThroughput drift until the next deploy refreshes the capture. getDriftUnknownPaths therefore ignores the path on BOTH sides when the recorded template declares nothing, scoped per resource through the same seam AWS::ApiGatewayV2::Integration's TlsConfig uses (#1602); a table that DOES declare WarmThroughput is compared normally. Every assertion was mutation-probed against the real source line it targets, and the whole fix was live-tested through the real cdkd drift CLI against a hand-seeded state file (no deploy — the properties baseline 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, so ItemCount alone drifts every in-use table with an index (issue #1767 — wider than this fix and needing its own migration story), and a DECLARED WarmThroughput AWS has since grown is unrevertable, since --revert issues 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 malformed EventBridgeEnabled stops 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 / NoncurrentVersionExpirationInDays were recorded against an Expiration / NoncurrentVersionExpiration readback, and the empty-prefix Filter the 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 (readLifecycle now 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, and effectiveLifecycleRule is the ONE fold both the recorder and canonicalizeDesiredProperties run. The scope half is wider than the issue's headline because the same round trip also moves a bare top-level Prefix, rule-level TagFilters and the rule-level ObjectSize* members under Filter; lifecycleRuleScope / lifecycleUsesFilterForm were 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 a Filter the wire never sent. #1754 — the legacy singular actions. Transition / NoncurrentVersionTransition are folded into the plural array by sharing mergeLegacySingular itself, 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 a StorageClass collision 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 malformed EventBridgeEnabled (null, 'yes', an array, an unresolved intrinsic) took the ENABLE arm because coerceCfnBoolean answers undefined and undefined !== false is 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 runs configBooleanRefusal and 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 because PutBucketNotificationConfiguration is a full replace (the caller retains the previous value, the #1612 UPDATE answer). #1430's polarity survives intact — a usable false still emits no block, a usable true still emits {} — and the s3-lifecycle integ asserts both on both phases. The stringly-typed EventBridgeEnabled: '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: readLifecycle always emits {Rules: []} so the lifecycle placeholder is RECORDED (#1718), while readNotification emits a family only when it is non-empty. MEASURED and deliberately NOT folded, stated rather than quietly omitted: readNotification emits EventBridgeConfiguration unconditionally, 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[].TransitionDate was only RENAMED by the alias fold while the applier sends new Date(...) and the readback emits .toISOString(), so '2030-01-01' recorded against '2030-01-01T00:00:00.000Z' — the identical class the rule-level ExpirationDate fold 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 nested Expiration.Date was folded on PRESENCE where the applier reads TRUTHINESS, so a falsy date would have recorded 1970-01-01. The review also asked for #1755's fourth bullet, the per-item notification Id, to be MEASURED rather than dropped: the applier sends Id: t['Id'], AWS generates one when none is declared, and readNotification emits it — an AWS-COMPUTED value that belongs in observedProperties, 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 refused Status or Filter container, an unreadable ExpiredObjectDeleteMarker, a marker colliding with Days, 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, coerceCfnNumber downgraded to typeof, the declared-null guard 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-junk Rules array is already short-circuited by the empty-list arm, so only a MIXED array exercises the element-count guard. The s3-lifecycle integ gains a malformed-value arm on a new bucket rather than a new fixture: phase 1 deploys a usable false, 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: --check reports 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 what readCurrentState emits 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: foldTransitionDate folded on PRESENCE where the wire reads TRUTHINESS, so a declared TransitionDate: 0 recorded 1970-01-01T00:00:00.000Z against 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 nested Expiration.Date; and cfnDateToIso was timezone-dependent (new Date('Jan 1 2030') resolves LOCAL), which is harmless for the wire (the applier shares the dependence) but not for canonicalizeDesiredProperties, 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 (bare YYYY-MM-DD, a Z / ±HH:MM date-time, an epoch number) and leaves every other spelling alone, with a row asserting two process.env.TZ settings 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 to cdkd 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 Enabled stops 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 drift re-reports it forever and --revert re-issues the same call, with no warning anywhere because nothing is malformed and nothing is substituted. effectiveNotificationConfiguration / effectiveLifecycleConfiguration are now the ONE helper the appliers and canonicalizeDesiredProperties share, folding each tolerated spelling onto the emitted one and REMOVING the tolerated key (not setting it undefined, which survives a structuredClone and leaves the unionWalkObjects drift 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 ?? Date on Transitions[], TransitionInDays ?? NoncurrentDays on NoncurrentVersionTransitions[]), so fixing the reported key alone would have left its siblings broken. Then the ROUND-TRIP fence — assert the recorded bag against what readCurrentState ACTUALLY EMITS for the configuration just sent, never against a literal — failed immediately on a divergence neither the issue nor the audit had named: readNotification emitted the SDK LIST Events while every template carries the CFn scalar Event (the registry schema declares no Events member), so the record and the readback disagreed on EVERY notification-configured bucket rather than only on the rare TopicArn-spelled one. That half is fixed on the READBACK side, by ARITY — a single-element list is the CFn Event, a longer one has no CFn spelling and stays Events — mirroring what readLifecycle already does for TransitionInDays; 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 — inventory Enabled. The one member of that item whose wire read coerced SILENTLY ((config['Enabled'] as boolean) ?? true), so a declared Enabled: null went out as true — ENABLING a report the template may have been disabling — while the record kept null. 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 in config-shape.ts, alongside a coerceCfnBoolean moved 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 }, and gen-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 rewritten Enabled block (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 asserts effectiveProperties is ABSENT, a nullish first spelling still falls through to the alias, a multi-element Events is 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 to Events, delete swapped for undefined, the twin unwired, a skipped Put folded anyway); each fails only its own rows. Two residuals filed rather than ridden along: the legacy singular Transition / NoncurrentVersionTransition MERGE (issue #1754) and the RULE-level ExpirationInDays / NoncurrentVersionExpirationInDays / Filter divergences 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 in tests/unit/{utils/resource-line,cli/destroy,cli/state-destroy}.test.ts. ResourceProvider.delete returned Promise<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 toward N 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 reported Stack Cdkd1657Verify destroyed (3 deleted, 0 errors) having deleted nothing. delete now returns Promise<void | ResourceDeleteResult>; void still 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 shared compositeIdSkipResult(). 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 delete failure line — counts it in a new skippedCount, emits a RESOURCE_SKIPPED deployment event (no error: nothing was attempted), and renders (2 deleted, 1 skipped, 0 errors). Two decisions the issue left open: state RETENTION — a skipped resource stays in remainingResources, so state.json is 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 destroy now raise PartialFailureError (exit 2), which is not a new policy but the existing "state preserved, stack not destroyed" contract that errorCount > 0 and a graceful interrupt already carry. The idempotent *NotFound arms are untouched and still count as deleted (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 — the skipped suffix renders only when non-zero. Every new test was mutation-probed (9 probes across the runner branch, the preserveState clause, 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.delete recursed into runDestroyForStack and 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 own state.json sat there preserved describing a live resource; it now returns { outcome: 'skipped' } naming the child and its count. (errorCount is 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: ResourceDeleteResult is a DISCRIMINATED union so reason is REQUIRED on 'skipped' (a skip whose line reads a bare skipped is barely better than the deleted it replaced) — the one guarantee no runtime test can fence, so it carries the repo's first *.test-d.ts; RUN_FINISHED gained counts.skipped, without which a skip-only run recorded result: 'FAILED' with deleted: N and nothing named as failed; cdkd events renders RESOURCE_SKIPPED yellow instead of the neutral cyan it shared with RESOURCE_STARTED (RESOURCE_RETAINED deliberately 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: NestedStackProvider also propagates the child's interrupted (a SIGINT mid-child is the identical data loss reached through another field — with the child's errorCount at 0 the parent's preserveState is 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 events now RENDERS counts.skipped as ⚠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-event reason, because the events store is the durable post-mortem and a bare RESOURCE_SKIPPED there 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 — with NestedStackProvider as 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.ts discard 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 bare void — issue #1770; the child's errorCount is still swallowed by NestedStackProvider.delete, whose answer is a THROW with its own blast radius — issue #1777; and six further delete-result discarders (the cloud-control-provider.ts self-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 export stops 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 named AWS::Glue::Table as 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, no handlers block, so CreateChangeSet answers ResourceTypes [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. resolveResourceIdentifier branched on the CFn primaryIdentifier'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 an AWS::S3Tables::Table was imported as {TableARN: '<bucketArn>|analytics|events'}. COMPOSITE_ID_SPLITTERS could 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 new COMPOSITE_PHYSICAL_ID_IDENTIFIERS table 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 — TableARN for AWS::S3Tables::Table, DataSourceArn / ResolverArn for the two AWS::AppSync::* children (recorded since #1681), with a bare-ARN physicalId accepted as the second shape because S3TablesProvider.importTable records 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 whose primaryIdentifier no longer matches the registered field refuses by name. propertiesOverlay is empty for all of them (every field is readOnlyProperties, measured live). AWS::EC2::SecurityGroupIngress is the fourth member and is REFUSED: CFn identifies a rule by the sgr-... id AWS mints and cdkd records nothing carrying it, so the message points at #1761, which tracks the attribute-recording work in ec2-provider.ts. (b) Pre-flight refusal for types CFn cannot IMPORT. NEVER_IMPORTABLE_TYPES' comment deliberately deferred to the CreateChangeSet error; 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. buildImportPlan now 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 — no read handler AND ProvisioningType: NON_PROVISIONABLE — so a partial or unusual DescribeType response falls back to letting AWS answer rather than refusing an export that works today; the fallback identifier table likewise reports unknown. Measured live: AWS::Glue::Table / AWS::Route53::RecordSet / AWS::Route53::RecordSetGroup / AWS::AppSync::ApiKey / AWS::EC2::NetworkAclEntry / AWS::SQS::QueuePolicy / AWS::SNS::TopicPolicy all satisfy both and are all rejected by AWS; AWS::S3::Bucket satisfies neither and passes. The check runs AFTER the IMPORT_UNSUPPORTED_RECREATABLE_TYPES branch, since AWS::IAM::Policy also lacks a read handler and cdkd has a real answer for it (pre-delete + phase-2 CREATE). One sibling splitter had to ship with it: the live test found cdkd export aborting on EVERY S3 Tables stack with composite primary identifier (2 fields: TableBucketARN, Namespace) — AWS::S3Tables::Namespace had no COMPOSITE_ID_SPLITTERS entry, and since CDK's CfnTable requires a namespace resource, the AWS::S3Tables::Table fix above was unreachable in practice without it. It splits <tableBucketARN>|<namespaceName> in CFn identifier order and keeps the whole-map propertiesOverlay (live DescribeType reports no readOnlyProperties at 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. buildImportPlan and the blocked hard-fail now run BEFORE acquireLock on 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 concurrent cdkd deploy; planning issues no AWS write, and the nested-stack path had always planned before locking. And --skip-import-support-preflight is the escape hatch the refusal was missing: the verdict is a registry HEURISTIC rather than AWS's published supported-for-import list, and blocked aborts the whole run, so a type AWS later makes importable would otherwise be unrecoverably blocked until a cdkd release (its neighbour IMPORT_UNSUPPORTED_RECREATABLE_TYPES has --no-recreate-import-unsupported; this is the counterpart). Two follow-ups were filed rather than folded in: #1771 (AWS::EC2::Route / ::EIP / AWS::Lambda::EventInvokeConfig are 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::Stage now reports FULLY_MUTABLE with a read handler 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 a read handler while reporting NON_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. cachedAccountInfo stored a whole AwsAccountInfo, so the FIRST caller's resolverRegion (or AWS_REGION) was frozen and a later caller passing NO overrideRegion inherited it. That was pre-existing for region alone; PR #1743 made partition DERIVE from the region, so the stale region started dragging a stale partition with it — a first call from a cn-north-1-scoped resolver cached {region: 'cn-north-1', partition: 'aws-cn'} and a subsequent no-override call from a us-east-1 context read aws-cn. The issue named two options; this takes the one it called cleaner, storing only the ACCOUNT. CachedAccountIdentity (accountId + the fabricated flag) is what the two caches now hold, and accountInfoFor(identity, overrideRegion) derives region + partition per call on the way out — for the process cache, the bounded fabricated-TTL window, AND the shared in-flight promise. resolveAccountIdentity takes 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, and withOverrideRegion is gone (its whole job was patching a cached entry after the fact). #1746 item 2 — the guard. constructGuardedAttribute's fabricated-account refusal tested typeof value === 'string'. Every account-bearing constructAttribute branch 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 extracted embedsAccountId also 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 a urlSuffix that is amazonaws.com.cn in aws-cn (and c2s.ic.gov / sc2s.sgov.gov in us-iso*), and #1743 routed AWS::URLSuffix / AWS::StackId / both ECR RepositoryUri sites 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's ProviderName / ProviderURL on BOTH create and update (fixed together, or an update would rewrite a correct suffix into a commercial-only one), all six ECR registry URIs in DockerAssetPublisher (the four push targets the issue lists, plus the ecrLogin cache key and the login ENDPOINT fallback the issue's grep missed — that one is what docker login actually receives when AWS reports no proxyEndpoint), and asset-redirect.ts's evaluatePseudoParam, whose AWS::URLSuffix arm returned a hardcoded string under a comment claiming to mirror IntrinsicFunctionResolver — a claim #1743 had just made false, so a folded Fn::Join produced an unresolvable host. The S3 trio is deliberately NOT in this change and #1745 stays open for it: DomainName / RegionalDomainName / WebsiteURL span the resolver AND s3-bucket-provider.ts, which an in-flight lane (PR #1744) owned, and the issue itself requires both halves to move together or the Fn::GetAtt answer disagrees with readCurrentState (the phantom-drift shape). WebsiteURL additionally needs the s3-website-<region> vs s3-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 adds compositeIdFormatMessage(format, logicalId, physicalId, options?) as the decode-side counterpart of the module's existing packCompositeId / compositeIdSeparatorRefusal pack 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 the applyTableTagsDiff arm 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-type CompositeIdFormat carries a label and ordered segments; every segment is a PLACEHOLDER whose name must match the type's packCompositeId segment names. The first cut had a literal-segment form used exactly once, rendering AWS::EC2::VPCGatewayAttachment as IGW|<vpcId> — and IGW is a token no packer emits (the real value is the internetGatewayId), so the message would have instructed a user repairing state.json to write DetachInternetGateway(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). alsoAccepts covers AWS::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 are logger.warn + return on the DELETE path, so a malformed record leaves the AWS resource ALIVE while cdkd destroy reports 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::Table had gained its expected-format text, and route53-provider.ts's message no longer exists at all — PR #1678 removed that arm in favour of parseRecordSetCompositeId plus 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 real AppSyncProvider through 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 at deleteResolver fails 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::Sub stops laundering resolver REFUSALS into a literal ${X.Y}, and getAccountInfo stops 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 — the Fn::Sub catch. ${LogicalId.Attribute} resolves through resolveGetAtt inside a bare catch, 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 / *Url shape hard-fail (the #1103 class), the --strict-getatt rejection, and #1729's rejectPlaceholderArnAttribute. 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 an Fn::Sub, with a warning (not found) naming the wrong cause and discarding whatever remedy the refusal carried. Now those three sites throw IntrinsicResolutionRefusalError (a new CdkdError subclass 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 asserting not found. The Ref arm re-raises a refusal only when the variable has NO . — with one, Ref is a speculative first attempt whose miss is how the GetAtt interpretation 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 Output deploy catches it per-output and exits 0 with keeping 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) partition was 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 own Fn::GetAtt construction plus four CloudControlProvider enrichment sites (KMS key, ECR repository x2, Kinesis stream) — and structurally valid, so nothing downstream could catch it. It now derives through the shared derivePartitionAndUrlSuffix(region), INCLUDING on the paths that swap in a caller's override region: partition is a function of region, so a cached entry handed back under a cn- 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 hardcoded 123456789012 fallback) 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, mirroring write-only-properties.ts's "only SUCCESSFUL lookups are cached"; an operator-supplied AWS_ACCOUNT_ID is a real answer to "which account" and is still cached as one. The four CloudControlProvider ARN sites additionally OMIT the attribute when fabricated is set (accountInfoForSynthesizedArn), mirroring AppSyncProvider.childImportAttributes from #1728 — an ARN built from the placeholder account carries no wildcard, so #1681's isPlaceholderArn cannot catch it and the value would be RECORDED into state and served as the resource's Fn::GetAtt answer, indistinguishable from a real one; the resolver's own guardedPhysicalIdFallback then hard-fails an *Arn read 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, but AWS::ECR::Repository's RepositoryUri embeds the account with NO colons (<acct>.dkr.ecr.<region>.amazonaws.com/<repo>) — the single such site in constructAttribute — so the resolver served the fabricated URI and silently NULLIFIED the CloudControlProvider omission 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 made getAccountInfo re-issue GetCallerIdentity — with the SDK's own 3-attempt retry, plus one warning — on EVERY Fn::GetAtt and every AWS::AccountId / AWS::Partition / AWS::StackId pseudo-parameter, i.e. hundreds of calls on a large stack. A fabricated answer now gets a bounded 10s window (accountInfoClock is the test seam) instead of nothing, PLUS a shared accountInfoInFlight promise so the parallel case collapses too — the TTL alone only helps SEQUENTIAL callers, and cdkd deploy --concurrency 10 resolves 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 and CloudControlProvider sites — amazonaws.com.cn in aws-cn, the identical defect one field over from the partition, at the very attribute the blocker was about — and AWS::URLSuffix was issuing a getAccountInfo round trip for a value that is a pure function of the region. Three smaller review items shipped in the same round: AWS::StackId and AWS::URLSuffix were the identical hardcoded-partition defect one site over (arn:aws:cloudformation:… and 'amazonaws.com' in aws-cn) and now derive; a dead withOverrideRegion call on the fresh-resolve path was removed (region is already overrideRegion || …, so the branch could never be taken — and one of the claimed mutation cuts was therefore not probing anything); and two comments in src/utils/aws-partition.ts / appsync-provider.ts still 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 no Arn / Url suffix still falls back to the physical ID; a real account still enriches, including a cn-north-1 ARN under aws-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 two instanceof re-throws, the success-path / override-path / failure-path partition derivations, the fabricated-caching skip, the CC refusal, the constructAttribute refusal, the colon-vs-bare account match, the TTL window, the StackId / URLSuffix derivations, 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 named client-eventbridge / client-scheduler / client-glue as the evidence that a single per-target keyStyle hides 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 Crawler scanRate drop had no fence behind their fixes. Nine types are now registered as NESTED_KEY_TARGETS, following the shape PR #1694 set for AWS::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 all provider-handled, each verified against the conversion that handles it rather than taken on the literal's word (toSdkEcsParameters / toSdkTarget for the ECS-target islands, CFN_TO_SDK_DYNAMODB_TARGET_KEYS for ScanRate, buildEncryptionConfiguration for the CFn-plural S3Encryptions → SDK-singular S3Encryption rename, and enforceIcebergTableInputAbsent for OpenTableFormatInput.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 (Table 36, Database 11, Trigger 9, SecurityConfiguration 2, 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. Table and SecurityConfiguration are builders that DO name their full member set, and Trigger is not a builder at all — its Predicate / Actions / EventBatchingCondition are verbatim properties['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.) Database 11 is a REAL SILENT DROP: buildDatabaseInput is a fresh-object builder naming only Name / Description / LocationUri / Parameters, while CFn and the SDK's DatabaseInput both also declare TargetDatabase, FederatedDatabase and CreateTableDefaultPermissions — none of which appears anywhere in src/, 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 in GLUE_DATABASE_DROPPED_PATHS plus a test, so fixing the provider fails that test and forces freshObjectMapper: true in the same change. AWS::Glue::Workflow is the one registered Glue type deliberately left out — its schema has no nested property at all, so a target would carry a minNestedKeys floor of 0 and fence nothing. Its fixture was re-captured for this: review caught that asserting nestedPropertyPaths === undefined against 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 #top is definitionShapes' only definition — the latter is the real discriminator, since definitionShapes alone proves nothing (it landed 2026-08-09, one day before nestedPropertyPaths, and AWS-ECS-Cluster.json is 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 needs node scripts/refresh-cfn-schemas.mjs <TypeName> with an explicit type argument — --only-missing skips any type that already has a fixture file, and the older captures predate the definitionShapes / nestedPropertyPaths sections 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 carry primaryIdentifier, which gen-enrichment-coverage and gen-sdk-attr-coverage auto-classify as not-a-gap, so AWS::Events::Rule Arn and the Glue::{Connection,SecurityConfiguration,Table} Id attributes 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: a ScanRate under ::Job is cleared by the CRAWLER class's literal and an IcebergTableInput under ::Crawler by 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: collectStringLiterals is 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 Crawler ScanAll (only MongoDBTarget spells it PascalCase) and Rule PlacementConstraints.Type / PlacementStrategies.Type are 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 (Crawler Targets, Database PrincipalPrivileges, Job DefaultArguments / NonOverridableArguments, Table IcebergTableInput / 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-handled means 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 — replacing EventBridgeRuleProvider.renameItemKeys's whole body with return item, so every ECS-target island stops being converted while every call site keeps naming the keys, leaves the shipped --check at 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 the read* / *ToCfn families, 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 the exact keyStyle; the per-path island credits including the ScanAll key-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 ::Crawler lose all four non-Targets shapes and ::Scheduler its whole FlexibleTimeWindow; 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.Targets 74/60, Scheduler.Target 45/36, Crawler.Targets 40/32) and an entire family fits inside it — including all eight crawler-target families, of which the 4-path Targets.DynamoDBTargets is the scanRate island's own home; the 11 pinned Glue Database drops; the unmatchedDefinitions set; the literal-bleed bound; and the Workflow exclusion) and 10 RED probes: 7 through loadReport's fixtureDir seam covering both blocking buckets across all three clients, and 3 through the SHIPPED --check against a scratch copy of the REAL providers tree — deleting EventBridge's and Scheduler's CapacityProviderStrategy lowercasing and Glue's scanRate rename bridge each exits 1 naming the re-bucketed paths. The provider-side trio is what proves the provider-handled credits 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, no src/** 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 into src/provisioning/emr-configuration.ts and the key pass's literal scan reads only providerFile without following same-package helper imports, so ConfigurationProperties / StepProperties would 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 shape IAM_ROLE_ARN_RE in src/utils/role-arn.ts already uses) rather than derived from a region through derivePartitionAndUrlSuffix — 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.isSnsServiceToken is the SNS-vs-Lambda ROUTING decision for the whole provider — it gates sendRequest (SNS publish + S3 poll vs synchronous Invoke), the delete path's backing-Lambda pre-check and recycleBackingFunctionExecEnv — so an arn: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 on arn:aws:iam::aws:, and AWS-managed policies exist under every partition (arn:aws-us-gov:iam::aws:policy/AdministratorAccess is a real, attachable ARN), so a GovCloud / China AdministratorAccess fell through and was ADOPTED as customer-managed — cdkd destroy would then run detachAllPrincipals against every user / role / group in the account before DeletePolicy was (always) rejected; the miss direction here is the dangerous one. (3) lambda-eventsource-provider.ts's classifyEventSource hand-enumerated arn:aws: and arn:aws-cn: per service and so returned 'unknown' for GovCloud, all four iso partitions and aws-eusc — 'unknown' is in neither KINDS_WITH_FUNCTION_RESPONSE_TYPES nor KINDS_WITH_SOURCE_ACCESS_CONFIGURATIONS, so the type-discriminator gating silently stopped emitting its placeholders and cdkd drift saw a one-sided difference on every clean run; the per-service startsWith chain 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 the aws-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 / :2709 stay 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: Snapshot resources 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-protection and DeletionPolicy: Snapshot sections) and the skill's CI bullets now cover the full teardown: --remove-protection for one-pass deletion, --skip-final-snapshot when the environment's data is disposable (the skill gates it on explicit user confirmation — it is a data-loss opt-out), and destroy --purge-events / events prune <stack> --all for a fully empty state bucket. Surveyed the full destroy option surface for completeness: -f/--force is 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::Table BillingMode 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 in DynamoDBGlobalTableProvider and left the sibling AWS::DynamoDB::Table provider — same two shapes, different path — out of scope. Before: the update-path requireConfigString onUnusable arm said "The table's current billing mode (X) is kept", where X is prevBillingMode — the RECORDED previous, not a DescribeTable reading. 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 the properties['BillingMode'] !== undefined branch, so the desired side is always defined when the message fires, and prevBillingMode therefore 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 reads AWS::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 bare AWS::DynamoDB::Table substring 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::GetAtt stops 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-GraphQLApi type, so a child adopted with cdkd import had no ARN in state, the resolver's REF_RETURNS_ARN_FROM_STATE lookup missed, and Ref fell back to the raw compound id — the pre-#1681 behavior — with Fn::GetAtt DataSourceArn / ResolverArn / Arn equally unresolved until the resource's next update (#1727) healed the record. It now calls the SAME childRefAttributes mapping create() / update() use, so the three spellings cannot drift, and records the COMPLETE set (Name / ApiKey too — import writes 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, import adopts 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: a try does NOT cover the credentials failure, because getAccountInfo CATCHES its own STS error and returns the hardcoded 123456789012 — so nothing throws, and an ARN built from it would be PERSISTED while carrying no wildcard for isPlaceholderArn to catch downstream, i.e. strictly worse than recording nothing. AwsAccountInfo gained an additive fabricated flag — set on the STS-failure arm AND on a SUCCESSFUL call that carries no Account, which lands on the identical hardcoded id (absent otherwise, so no existing consumer changes; the fabrication itself remains issue #1730). The refusal lives in buildAppSyncArn, NOT at the import call site, because a second review round showed the UPDATE path is the worse one: childRefAttributes rebuilds the ARN on every in-place update and an update() 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 / ApiKey come out of the physical id) while dropping only the ARN. The second: the ARN is now built from ResourceImportInput.region — the region import keyed the STATE RECORD by — rather than the provider client's own config, which can differ when AWS_REGION is unset and a profile region is set. #1729 — GetAtt. Ref and Fn::GetAtt read the same cached attribute and #1681 guarded only the Ref half, so a child created by a pre-#1681 binary still resolved {"Fn::GetAtt": ["MyDataSource", "DataSourceArn"]} to arn:aws:appsync:*:*:apis/... — structurally valid, unusable, indistinguishable downstream from a real ARN. rejectPlaceholderArnAttribute now refuses it, scoped to the REF_RETURNS_ARN_FROM_STATE types 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 — so AWS::AppSync::ApiKey.ApiKey, AWS::AppSync::DataSource.Name and every non-AppSync *Arn are untouched, each pinned by a test. It THROWS where the Ref half 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 mismatch guardedPhysicalIdFallback already 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 canonicalizeDesiredProperties twin, 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 a Bucket / BucketArn alias, and recorded it at the branch the TEMPLATE declared — while inventorySdkToCfn / analyticsSdkToCfn emit only the flattened form with BucketArn, so a record in either tolerated spelling could never match the readback and cdkd drift re-reported it forever with no warning anywhere (nothing is malformed, nothing is substituted); (b) the (#1686) Schedule -> ScheduleFrequency recording fold shipped without its canonicalizeDesiredProperties twin, so an UNCHANGED template redeployed as 1 to update forever (measured us-east-1, 2026-08-12); (c) applyAllSubConfigsForCreate skipped an empty lifecycle / CORS collection in SILENCE, so a fresh bucket came up without a declared configuration and nothing said so; (d) Enabled / IncludedObjectVersions / OutputSchemaVersion were 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 shared effectiveS3BucketDestination — are the ONE helper both the appliers (called with the values that went on the wire) and the new S3BucketProvider.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 making gen-nested-key-coverage report the InventoryConfigurations segmentRenames entry 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 in usedSegmentRenames — no entry removed, no staleness check widened. What did move is that file's withdrawn-name measurement (Enabled / ScheduleFrequency / BucketArn left 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 for IsEnabled / Bucket from 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 and cdkd diff keeps reporting it until the template is corrected. The (#1718) sibling audit the issue asks for was run and is not uniform: intelligent tiering defaults Status on the wire and reads it back, so it gets the same fold (effectiveIntelligentTieringItem, no sent parameter — that applier has no warn-and-substitute arm at all, since a malformed Status SKIPS 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 DECLARED null as 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 malformed Schedule CONTAINER ('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 the onSubstituted callback (all three call sites had been left passing a comment-only no-op, so the helper ran a second configStringRefusal per 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 the metrics-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, since Closes #1707 would otherwise orphan them) and the pre-existing Enabled: null coercion (issue #1751, where two reviewers proposed opposite fixes). The now-unused withDeepValue / withoutKey helpers were deleted with their rationale carried onto the folds.
  • The two replay-CREATE AWS::DynamoDB::GlobalTable arms #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 malformed GlobalSecondaryIndexes warns and toSdkGlobalSecondaryIndexes returns the well-defined EMPTY list, so CreateTable goes out with NO indexes — while the engine recorded the malformed desired blob, a record readCurrentState can 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.md replay-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: a Replicas[].GlobalSecondaryIndexes override is a separate template key this guard never read, and its wiring runs on its own rather than off sdkIndexes. (2) The BillingMode substitution now strips the capacity blocks it never sent (#1726). Substituting PAY_PER_REQUEST also skips createParams.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 only BillingMode was 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 what readCurrentState emits for a PAY_PER_REQUEST table, per member, not from the CFn schema: the top-level WriteProvisionedThroughputSettings emits {} and the per-replica / per-index / per-replica-index blocks are omitted — every one of those emissions is type-discriminator-gated on billingMode === 'PROVISIONED' in that same method. So the new pure stripProvisionedCapacityKeys removes exactly that set and deliberately PRESERVES the on-demand ceilings, which go on the wire under this very mode (OnDemandThroughput is attached when the mode is not PROVISIONED). It is non-mutating at every nesting level, because the replay caller's bag IS previousState.properties and the rollback executor spreads the answer shallowly. No canonicalizeDesiredProperties twin is needed for either arm, and the reason is the arms' own rather than an inherited carve-out: the bag ALREADY records a BillingMode differing 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 adopted packCompositeId at every composite packing site across glue / s3-tables / appsync / ec2 / apigateway / lambda-event-invoke-config and deliberately skipped route53-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: recordName is properties['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 — parseRecordSetCompositeId requires 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: create gets the ordinary CreateContext.replayingState downgrade (warn-and-pack), which required threading context through create() into createRecordSet — the provider declared no context parameter at all, the silent-ignore failure mode .claude/rules/providers.md records for SNSTopicProvider; update takes the updateRoute precedent and downgrades UNCONDITIONALLY. The first cut threw there, on the argument that every value update() packs is template-borne and therefore user-editable — FALSE for the two callers .claude/rules/providers.md names: rollback-executor.ts's revert arm and cdkd drift --revert both call update(..., 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 no CreateContext, 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; and import() neither throws nor adopts, taking the method's own adoptVerbatim escape 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 BEFORE ChangeResourceRecordSets and OUTSIDE the wrapping try, so a refusal can neither orphan a record AWS has already written nor be mis-reported as Failed to create / update record set. The import arm calls the bare predicate compositeIdSeparatorRefusal for the DECISION and packCompositeId for 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. parseRecordSetCompositeId asks only for three NON-EMPTY segments, so a record NAME carrying two pipes — a|b|c.example.com., which is exactly the CloudFormation physicalId cdkd import --migrate-from-cloudformation pre-populates from DescribeStackResources — parsed as a valid composite meaning zone a, name b, type c.example.com., at THREE sites that never reached the new packing guard: importRecordSet's early accept, resolveRecordSetIdentity's short-circuit, and deleteRecordSet's own parse. The last is where it costs something: cdkd destroy issued ChangeResourceRecordSets against hosted zone a, hit the NoSuchHostedZone already-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 through canonicalizeQueryName, 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|A on a record whose Type really is A agrees on the type and still decodes to zone a — 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 explicit replayingState: false create 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 on import'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 bare undefined fails 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 where cloudformation:DescribeType is unavailable, a Name change classifies in-place rather than as a replacement, so update() 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, leaving intrinsic-function-resolver.ts as the single recorded exclusion (excluded by KIND — its joins build Ref VALUES nothing decodes). Directions 1 (stop packing) and 2 (escape the separator) remain OPEN on #1672. No CLI flag, dependency, or state-schema change.
  • Ref to 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, new tests/unit/provisioning/appsync-child-ref-arns.test.ts, plus docs/state-management.md. Before: cfnRefValueFromPhysicalId translated a compound physicalId back to CloudFormation's Ref value 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::RecordSet stores <hostedZoneId>|<name>|<type> while CFn's Ref is "the name of the record", the MIDDLE segment (after-last yields the record TYPE A, before-first the zone id). The three AWS::AppSync::* children store <apiId>|… while CFn's Ref is the resource ARN, which is no segment of the id at all — and the ARN attribute the provider recorded for Fn::GetAtt was itself string-built as arn:aws:appsync:*:*:…, i.e. a literal * in the region AND account positions, so Fn::GetAtt DataSourceArn / ResolverArn / Arn were ALSO returning an unusable value (the ApiKey one additionally used the plural apikeys where the documented segment is the singular apikey). Route53 was the one likely to be hit in practice: CfnRecordSet.ref is what an L1 template and CfnOutput(value: record.ref) use, so a record name composed into a string emitted Z1D633PJN98FT9|www.example.com.|A. After: two mechanisms sit beside the existing Sets — REF_RETURNS_SEGMENT_AT_INDEX extracts 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), and REF_RETURNS_ARN_FROM_STATE recovers the ARN through the existing stateLookup seam the S3Tables / Backup / CodeCommit cases use. AppSyncProvider now records the REAL ARN: CreateDataSource / CreateResolver report one, so it is taken from the response verbatim, and AWS::AppSync::ApiKey (whose CreateApiKey carries no ARN field) is reconstructed from the deploy's own partition / region / account via the shared STS-backed getAccountInfo — the same helper CloudControlProvider uses. Two degradations are deliberate: an IMPORTED child records attributes: {}, so its Ref falls 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 four Ref semantics were docs-verified 2026-08-12. #1712 rode along as the stale-comment half: intrinsic-function-resolver.ts's Glue entry still said createTable packs 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 a getAccountInfo mock — they were reaching REAL STS, so their result depended on the machine's credentials.
  • An empty BucketEncryption / OwnershipControls collection 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: applySubConfigDiffs treated an empty collection in TWO incompatible ways. Lifecycle / CORS SKIP the Put and leave the live configuration alone (the #1671 arm), while OwnershipControls / BucketEncryption normalize BOTH sides through emptyListConfigToUndefined BEFORE diffSubConfig — so a non-empty previous against a declared-but-empty desired became undefined, took the onDelete arm, and issued DeleteBucketOwnershipControls / DeleteBucketEncryption. For BucketEncryption that silently drops a declared aws:kms default 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 an aws:kms default plus a BucketOwnerPreferred rule to ServerSideEncryptionConfiguration: [] / Rules: [] drives the stack to UPDATE_ROLLBACK_COMPLETE with The 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 same emptyCollectionSkip helper 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: readCurrentState ALWAYS emits BucketEncryption: { ServerSideEncryptionConfiguration: [] } / OwnershipControls: { Rules: [] } for a bucket with no explicit setting, so cdkd drift --revert feeds that placeholder straight back through update() and both sides must keep normalizing for empty-vs-empty to compare EQUAL and issue no call at all. A new predicate declaresEmptyCollection rides 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 asserted empty placeholder desired + real previous REMOVES (empty == not declared) on the reasoning that it is "a genuine declared -> not declared transition … 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 same undefined after 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: emptyListConfigToUndefined also folds a bare {} and a {listKey: null}-only block to undefined, #1466 pinned BOTH as removals, and the #1713 A/B exercised neither — so declaresEmptyCollection tests 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). readCurrentState spells "this feature is not set" as the SAME empty collection — readOwnershipControls returns {Rules: []} on OwnershipControlsNotFoundError — and cdkd drift --revert builds 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's retainPrevious writes the AWS-CURRENT value into effectiveProperties, which drift.ts persists into observedProperties, so an out-of-band change was laundered into the baseline and the next cdkd drift reported 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 new UpdateContext — the update() sibling of CreateContext, optional so none of the 77 providers implementing update() changed, with one field desiredFromAwsReadback set only by drift.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 is previousState.properties — a TEMPLATE recorded earlier — so {Rules: []} there means what the template meant and SKIP is right; they deliberately pass no context, and a stateBorne spelling 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 (readLifecycle returns {Rules: []} on NoSuchLifecycleConfiguration, readCors returns {CorsRules: []} on NoSuchCORSConfiguration — 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, the readCurrentState placeholder 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-band OwnershipControls addition is still removed by drift --revert (the pre-fix binary's behavior, preserved), while a collapsed-array redeploy still leaves a declared aws:kms default 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) — new src/provisioning/composite-id.ts, plus src/provisioning/providers/{glue,s3-tables,appsync,ec2,apigateway,lambda-event-invoke-config}-provider.ts, new tests/unit/provisioning/composite-id.test.ts + tests/unit/provisioning/composite-id-provider-guards.test.ts. Before: ResourceProvider passes a SINGLE physicalId: string as 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 named a|b in database mydb recorded mydb|a|b, which decodes to database mydb, table a. Both halves are non-empty, so every existing well-formedness guard passed and the DEPLOY SUCCEEDED; what broke was everything after it. cdkd destroy deleted the WRONG table if one existed under the decoded pair, or took deleteTable's warn-and-skip arm and reported success while the real table stayed alive and billing, and cdkd drift read back undefined forever. No warning was emitted at any point. AWS accepts such a name (live probe, us-east-1 2026-08-12: glue:CreateTable with TableInput.Name: 'a|b' succeeds) and CloudFormation manages the resource fine, so the limitation is cdkd's own. Now a shared packCompositeId(resourceType, logicalId, segments, options?) joins the segments and REFUSES with a ProvisioningError naming 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 sibling compositeIdSeparatorRefusal returns the same sentence WITHOUT acting on it, for the import() paths whose answer to an unusable id is warn-and-skip (skipped-not-found) rather than a throw that would abort the whole cdkd import over 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's TableInput.Name is the only value probed live (glue:CreateTable with Name: 'a|b' succeeds, us-east-1 2026-08-12). Its sibling DatabaseName is 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 — AppSync Name / TypeName / FieldName are GraphQL identifiers ([_A-Za-z][_0-9A-Za-z]*), a Lambda FunctionName is [a-zA-Z0-9-_]+, an S3 Tables namespace / table name is lowercase alphanumerics and underscores, and an EC2 IpProtocol is 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 EC2 IpProtocol and Lambda Qualifier / FunctionName: cdkd's own requireConfigString accepts 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::RecordSet is 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 (AppSync apiKeyId, EC2 EIP) are exactly the ones whose segments cannot carry a |, and both sit OUTSIDE the try whose catch re-wraps as ProvisioningError('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) whenever CreateContext.replayingState is 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 their update() already passes UNCONDITIONALLY, because those paths delete-then-re-create and a throw would strand a deleted route / revoked rule. S3TablesProvider.create gained a CreateContext parameter 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.md records a MISSING context parameter as its own failure mode — no type error, no warning, just a refusal that still fires on a replay, the SNSTopicProvider case — so three lines retire the reasoning burden rather than leaving it to be re-derived. GlueProvider.import() already refused the same shape via resolveTableIdentity (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 — and intrinsic-function-resolver.ts's two joins are excluded by KIND: they build a Ref VALUE, 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, not typeof 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-written Name: ['a|b'] is truthy, survives the provider's own required-field gate, arrives as an ARRAY, and String(['a|b']) is 'a|b' — the exact ambiguous id, formerly unrefused. CompositeIdSegment.value is typed unknown to 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 problem config-shape.ts owns, 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, ProvisioningError carrying the type + logical id, EVERY offending segment named rather than just the first, the onRefusal downgrade, 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 (AppSync createApiKey, EC2 createEip and its import() arm, EC2 createVpcGatewayAttachment, EC2 createNetworkAclEntry — the only site with number / boolean segments — and S3 Tables importNamespace — that last one is fenced at the HELPER level only, since parseNamespaceCompositeId'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-dispatch replayingState ? cb : undefined ternaries, updateRoute's unconditional callback, and the AppSync createResolver / 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 to typeof fails exactly the two array cases; reverting either EC2 create-dispatch ternary to a bare undefined fails exactly its replay case; dropping updateRoute's callback fails exactly the update-path case; moving either post-call pack back inside its try fails exactly the not-a-creation-failure case; dropping the S3 Tables context fails 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 as node node bin/app.js (issue #1714) — src/synthesis/app-executor.ts, tests/unit/synthesis/app-executor.test.ts. Before: AppExecutor.guessExecutable prefixed the current node executable whenever trimmed.endsWith('.js') || trimmed.split(/\s+/)[0]?.endsWith('.js'), then rewrote token 0. The SECOND disjunct expresses the intended rule ("a bare .js path needs an interpreter"); the FIRST is true for any command whose LAST token is a .js file, INCLUDING one that already names its runner — so node bin/app.js, the command cdk init --language javascript writes into cdk.json, became "<node>" "node" bin/app.js and every synth died with Error: Cannot find module '/path/to/app/node' (MODULE_NOT_FOUND). Now: only the first token decides, so bin/app.js (with or without args) still gets the interpreter while node bin/app.js / npx tsx bin/app.js / any .ts entrypoint go to the shell verbatim — matching upstream guessExecutable, 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 under tests/integration/** uses "app": "node bin/app.ts", whose last token ends in .ts and whose first token is node — 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 .js file, args preserved after a bare entrypoint, a quoted entrypoint, and a whitespace-padded .ts command passed through trimmed. No CLI flag, dependency, or state-schema change.
  • cdkd export no 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 are COMPOSITE_ID_SPLITTERS defects surfaced by the review of PR #1680, and both abort the WHOLE command because export.ts is all-or-nothing. #1691 — AWS::EC2::VPCGatewayAttachment: the entry produced {VpcId, InternetGatewayId} and its comment asserted both fields were writable Properties. Live DescribeType (us-east-1, 2026-08-12) says the identifier is [AttachmentType, VpcId] with AttachmentType in readOnlyProperties, so resolveCompositeId's field check threw did not produce field 'AttachmentType' on any stack with a VPC + Internet Gateway — i.e. essentially every public-subnet VPC. The splitter now derives AttachmentType rather than hardcoding InternetGateway: the Cloud-Control-written shape already carries it verbatim as segment 0 (a template declaring VpnGatewayId trips the #614 silent-drop routing, so VPN|vpc-… really occurs in state), the SDK-written <gatewayId>|<vpcId> shape derives it from WHICH gateway property state recorded, and a last-resort igw- / vgw- prefix read covers a state entry carrying neither; anything else refuses with a named error rather than guessing. propertiesOverlay narrows to VpcId, since writing a read-only property into Properties is rejected at changeset-create. #1692 — five AWS::ApiGateway::* children: Deployment / Stage / Authorizer / Model / RequestValidator all have composite CFn identifiers while cdkd stores a bare id, and none had a splitter, so cdkd export was unusable for REST v1 stacks (every CDK apigateway.RestApi emits a Deployment AND a Stage). They share one restApiChildSplitter factory that accepts BOTH stored shapes — the SDK providers' bare child value paired with RestApiId from 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: Deployment declares [DeploymentId, RestApiId] (child FIRST), every sibling declares RestApiId first. propertiesOverlay narrows to RestApiId for the three whose child field is read-only (Deployment / Authorizer / RequestValidator) and is left at the default whole-map for Stage / Model, which declare no read-only properties at all — verified live per type, with the reason recorded per entry. Model and RequestValidator have 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; the Deployment child-first ordering pinned separately from its siblings; and the three refusal shapes (blank id, >2 segments, missing RestApiId in 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::GlobalTable arms 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 the StreamSpecification arm; 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 drift readCurrentState can never clear. (1) BillingMode warn-and-SUBSTITUTE (create): under the replayWarn downgrade a malformed value is replaced by PAY_PER_REQUEST and 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.md licenses for a read of KNOWN warn-and-DEFAULT class. The callback is COMPOSED onto replayWarn's own and installed only when that callback exists, so a template-path create keeps refusing rather than silently gaining a downgrade. (2) desiredGsiUnusable warn-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-written typeof twin the way a re-derived shape check would), the retained array is COPIED rather than aliased (rollback-executor.ts spreads the answer shallowly), and the key is DROPPED when both sides are unusable. (3) BillingMode warn-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 one readCurrentState could never match. It needs no both-sides-unusable branch: the kept mode already falls back to what DescribeTable reports, 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 — substituting PAY_PER_REQUEST on the create path also skips every PROVISIONED-only capacity block, which stays recorded although nothing sent it (#1726). (4) the needsStream auto-enable (create) is NOT answered, on purpose. It is the same class — cross-region replication requires a stream, so cdkd enables NEW_AND_OLD_IMAGES on 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.compareProperties walks the key UNION, so an UNCHANGED template would classify an UPDATE on the next deploy; update() (whose stream gate is properties['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. The canonicalizeDesiredProperties twin that would fix it cannot simply be added: it is pure and synchronous and does not know the deploy region, while needsStream does. Tracked as issue #1723 together with the prior question of whether the arm is needed at all (drift-calculator descends only into keys present in state, and the observedProperties baseline 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: the dynamodb-globaltable integ 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-patching state.json (properties only; observedProperties came 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::Stream back-fills DesiredShardLevelMetrics + 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 in silentDrop, so a template declaring either was rejected at deploy-time pre-flight (with the --allow-unsupported-properties escape hatch). Now: MaxRecordSizeInKiB rides on CreateStreamInput at create and UpdateMaxRecordSize at update (that API has no StreamName member, so it resolves the ARN the same way the UpdateStreamMode path does), and DesiredShardLevelMetrics is applied via EnableEnhancedMonitoring / DisableEnhancedMonitoring — reconciled as a removal pass then an addition pass, since enhanced monitoring is a SET with no "replace" API. Both are read back by readCurrentState; MaxRecordSizeInKiB needed a second DescribeStreamSummary call because DescribeStream's StreamDescription shape does not carry it. The ALL shorthand is the interesting half. Measured us-east-1 2026-08-12: AWS EXPANDS ALL into seven metric names and never stores the literal, so recording the template's ALL would be permanent phantom drift that no readback could ever match. The provider therefore expands on the wire and records the expanded list via effectiveProperties — paired with canonicalizeDesiredProperties, which narrows BOTH diff sides through the same shared helper, per the .claude/rules/providers.md contract that the recording half alone breaks the next deploy (state would hold seven names while the template still said ALL, and every later cdkd diff would report a change the user never made). The integ asserts exactly that: after the ALL deploy, AWS reports the seven names, cdkd state records the seven names, and cdkd diff is CLEAN. The readback order is AWS-chosen (matched neither request order nor alphabetical), so the type also declares DesiredShardLevelMetrics in getDriftUnorderedPaths. 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. readShardLevelMetrics classifies the three and each caller picks the action: create REFUSES (template-borne, downgraded to a warning under CreateContext.replayingState), update WARNS and skips the whole reconcile (rollback / drift --revert replay state records through update(), 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. MaxRecordSizeInKiB is coerced through one helper on BOTH comparison sides (matching the sibling ShardCount), 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-found DescribeStreamSummary failure RETHROWS rather than returning a snapshot missing the key, which cdkd drift would report as 2048 -> undefined and --accept would 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 (ValidationException on 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 in silentDrop.
  • 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::ImportValue with export not found in any stack, Fn::GetStackOutput with Make 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 forcing cdkd export migrations to proceed leaf-first. Now a cdkd-state miss falls back to CloudFormation: Fn::ImportValue → paginated ListExports in the consumer's region (CFn's own semantic for the intrinsic — this was a template-compatibility gap), Fn::GetStackOutput → DescribeStacks outputs in the target region (same-account only; the RoleArn cross-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 into state.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 (missing cloudformation:ListExports / DescribeStacks permission) degrade gracefully: warn + the original not-found error (which now names both searched sources). Opt-out: --no-cfn-fallback on deploy + diff (threaded through DeployEngineOptions.cfnFallback into the resolver, inherited by nested-stack child engines via the options spread, and honored by cdkd diff's best-effort resolvers incl. the recursive child-parameter resolution so preview and apply resolve identically). The cdkd export cross-stack consumer scan's warn/refuse text is updated to match (consumers now keep resolving via the fallback; only --no-cfn-fallback consumers 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 the RoleArn path never takes the fallback; verified live with a real aws cloudformation deploy producer consumed by a cdkd deploy.
  • AWS::Route53::HostedZone import auto-resolves from the template's Name (issue #1702) — src/provisioning/providers/route53-provider.ts, tests/unit/provisioning/route53-provider.test.ts, docs/import.md. Before: docs/import.md listed the type under Auto-resolved, but importHostedZone() returned null unless 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 a skipped-not-found row the user did not expect, and under --migrate-from-cloudformation the 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's Name — the physical-name route the Auto-resolved section documents — through the SAME resolveHostedZoneId helper the RecordSet path uses, so it inherits #1678's canonicalizeQueryName query-side positioning (a bounded ListHostedZonesByName page 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: requirePrivateZone narrows the name match to the public or private side BEFORE the ambiguity decision, and the new module-level templateZoneVisibility derives that side from the template's own VPCs (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. absenceIsSound reasons 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: importOne in import.ts catches per resource and records outcome: 'failed'). A proven absence returns null -> skipped-not-found and the next deploy creates the zone; an ambiguous name or a failed lookup THROWS -> failed for that row alone, so a denied route53:ListHostedZonesByName is reported as a denial instead of being flattened into "no matching AWS resource" — which was wrong at exactly the moment --migrate-from-cloudformation retires the CFn stack. Both messages name the --resource remedy through a new subject option, 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 --resource override 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-VPCs refusals (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 no Config block, and both --resource verification arms. Every guard probed by mutation against the real provider: reverting the soundness rule fails 1, the naive VPCs.length check 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 CFn ScheduleFrequency and the SDK Schedule: { Frequency } (the #1605 fall-through), but inventorySdkToCfn emits only the former — and the live CFn registry schema declares ScheduleFrequency REQUIRED with no Schedule member at all, so the SDK spelling is a cdkd-only tolerance. A record carrying Schedule named a key the readback can never produce: every cdkd drift re-reported it and --revert re-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 declares Schedule, the effective item records ScheduleFrequency (the value that went on the wire) and DROPS Schedule via a new withoutKey helper — the removal twin of withDeepValue, removing the key rather than setting it undefined, since JSON.stringify drops an undefined member but a structuredCloned state record keeps it and the unionWalkObjects drift path walks Object.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 / CORS onPut arms 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 to LifecycleConfiguration: { Rules: [] } + CorsConfiguration: { CorsRules: [] } drives the stack to UPDATE_ROLLBACK_COMPLETE and 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 / CorsRules required, no minItems), 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 — undefined when 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 the readCurrentState round-trip the arm exists to absorb (drift --revert feeds an always-emitted empty-rules block back through update()) keeps working. Known residual: the fold ships WITHOUT its canonicalizeDesiredProperties twin, so a template declaring the SDK spelling keeps reporting InventoryConfigurations changed on every cdkd diff and re-issues an idempotent per-Id Put (measured: an unchanged template redeploys as 1 to update). Shipped anyway because the alternative is worse in the direction that MUTATES — without the fold, cdkd drift reports the key forever and --revert re-issues the call — and because the twin has a real obstacle: a canonicalizeDesiredProperties folding this key makes gen-nested-key-coverage report the still-correct plural->singular segmentRenames entry for InventoryConfigurations stale, while removing that entry surfaces genuine no-write-evidence divergences. Tracked in issue #1717. Tests: 20 unit rows — 10 for #1686 (SDK-spelling-only, the five malformed fall-through shapes, the redundant-Schedule case, the unmutated-desired-bag fence, and a convergence row driving the record and a readback-shaped snapshot through the real calculateResourceDrift) 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 / inventory Destination.S3BucketDestination nested branch plus its Bucket / BucketArn alias, 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 every update() caller honour effectiveProperties, 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 from previousState.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 #1544 replayWarn downgrade). That arm typed its local result as { physicalId; attributes? } — effectiveProperties was not even declared on the type — and rebuilt the state record from prev.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.md effective-properties table's replay-CREATE row unreachable in production for all of them. Now the arm mirrors its UPDATE-side twin recordAfterRollbackUpdate via a new recordedPropertiesAfterReplayCreate: the bag handed to create() IS previousState.properties, so a returned effectiveProperties replaces the record's properties wholesale, 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 keeping prev.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-injection rolls back CREATEs, which is a delete, not a re-create. It deploys an AWS::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 classifies reverse-replacement, then asserts the post-rollback record kept the SUBSTITUTED bag and that two consecutive cdkd drift runs converge. The vehicle is a route rather than the AWS::S3::Bucket the 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 same CreateContext.replayingState flag, 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 REGRESSION message — 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 fixture tests/integration/nlb-source-nat/. The open question: EnablePrefixForIpv6SourceNat and EnforceSecurityGroupInboundRulesOnPrivateLinkTraffic have no AWS API of their own — they ride on SetSubnets / SetSecurityGroups in updateLoadBalancer. 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 changes Subnets / 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 a Caveat: 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 held on across a 2 -> 3 subnet change, keeping the per-subnet auto_assigned prefixes and auto-assigning one to the new subnet; enforce held the non-default off across a 1 -> 2 security-group change. The delta: no behavior change — omitting is correct and re-sending would be redundant. The two Caveat: 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 the nlb-source-nat integ 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 the SubnetMappings arm; the plain Subnets arm 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. The integ-aws-commands invocation 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 matched CR_TRANSIENT_AUTHZ_LOG_SIGNALS. For every other terminal failure — a 404, a Python traceback, a JSON decode error — decodeInvokeLogTail ran and the result was discarded, so the user still saw only Custom 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 EPHEMERAL logger.warn, capped by CR_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 to deployments/{runId}.jsonl, which outlives cdkd destroy and 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. LogResult is NEVER absent on a LogType: 'Tail' invoke — Lambda always emits START / END / REPORT — so the first cut's logTail !== undefined guard filtered nothing and would have dumped several hundred characters of zero-value boilerplate on EVERY unexplained failure; hasHandlerLogOutput now 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 from pollS3Response and 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 SnapStart RESTORE_* 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 the FunctionError arm 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 own Task 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 !reasonIsAuthz noise guard 1, removing the cap 1, dropping the dispatch-invoke disclosure 1. No CLI flag, dependency, or state-schema change.
  • cdkd import in 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)", and import.ts rewrote stackInfo.template in place BEFORE buildStackState read tmplResource.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 classified NO_CHANGE, and the live resource was never corrected. For the AWS::IAM::Policy that s3deploy.BucketDeployment generates that is a silent split-brain: the handler role keeps a grant on cdk-<qualifier>-assets-* while SourceBucketNames names cdkd-assets-*, surfacing whenever the custom resource next runs as a runtime AccessDenied — and it silently opts the stack back out of the cdk gc protection issue #1002 exists to provide. cdkd diff showed nothing, so there was no signal at import time; the only recovery was cdkd drift --revert or re-importing before cdkd bootstrap. Now the template is still rewritten (that is what makes assets resolve, and nothing else about §7.1 changes), but a structuredClone snapshot taken immediately BEFORE the rewrite is what feeds buildStackState + resolveImportedProperties, so state.properties carries the CDK-bootstrap values AWS actually holds and the first post-import cdkd deploy produces the corrective UPDATE. The recursive --migrate-from-cloudformation child 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 old cdk-* names, so its diff always produced a real UPDATE); this brings the import path in line with it. Unchanged: --use-cdk-bootstrap-assets still 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 2 case 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-cloudformation walk. 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 from tests/once-leak-allowlist.json, and the list contains nothing but the deliberately-leaking once-leak-canary.test.ts, which is the documented goal state. The fix is priming-side rather than a mockReset() drain in beforeEach: 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-tables primed a ListTagsForResource that readTableCurrentState gates on resp.tableARN, a field the mocked GetTable response omitted; iam-role primed 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; and elbv2 primed a DescribeTags at seven sites although update() 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-less GetTable case was reading the previous test's leftover tags response, and the iam-role clear-on-removal trio was taking a leaked {} as its GetRole response), 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 with CDKD_ONCE_LEAK_DETECT=1 fails, naming the priming test), while expect(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 announced AWS::S3Tables::TableBucket while exercising ::Table). No src/** change: unit-test priming, the generated allow-list, its generator's now-stale $comment, and the two testing docs. Verified with the full suite under CDKD_ONCE_LEAK_DETECT=1 (594 files, 11,179 tests, zero leaks, exit 0) plus the detector canary step, which must still FAIL with the detector's own wording — confirmed exit 1 carrying primed 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 via CR_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's s3deploy.BucketDeployment is 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() lets subprocess.check_call raise, str(CalledProcessError) is only Command '[...]' 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: invokeLambda now passes LogType: 'Tail', so the invoke response carries the last 4 KB of THAT invocation's log inline (LogResult, base64). sendRequest returns the decoded tail alongside the cfn-response, and when the reason itself carried no authz wording the retry consults CR_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:GetLogEvents on 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: CreateContext carries only replayingState, so there is no plumbing from the deploy engine to the provider today, and adding it would land in deploy-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 the FunctionError throw. A thrown message is captured by extractDeploymentEventError into deployments/{runId}.jsonl — a store that OUTLIVES cdkd destroy and whose own header restricts it to error + metadata, explicitly never anything that may carry secrets — so a print(event) in a handler would have written that resource's ResourceProperties into permanent state. Round two: the fix for that folded the matched LINE into cfnResponse.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 fixed CR_TRANSIENT_AUTHZ_LOG_SIGNALS strings, 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 ephemeral logger.warns, themselves capped by CR_LOG_TAIL_WARN_MAX_CHARS because CI logs and scrollback are not nothing either. The tail is also returned UNDECODED from sendRequest and 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 the onEvent wrapper 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 on isAsyncPattern to 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 of BucketDeployment (its Python handler returns None) — gating on it would have switched off exactly the case the change exists for. A bare 403 / forbidden is likewise deliberately not a signal. Accepted cost: a handler that merely LOGS a genuine permanent AccessDenied buys the bounded retries, so the failure is DELAYED, never masked. Tests: 36 unit cases pinning the reporter's shape VERBATIM (the real BucketDeployment reason 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 undecodable LogResult, 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: dropping LogType: 'Tail' fails 12 cases, disabling the log-tail retry arm 6, folding the VERBATIM handler line into the reason 4, appending the tail to the FunctionError throw 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 via subprocess.check_call returned the reason Command '['/bin/cat', '/nonexistent/asset.zip']' returned non-zero exit status 1. with no trace of the cause, while the same invoke's LogResult decoded to cat: /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.deleteTable sent DeleteTableCommand({DatabaseName, Name}) with NO CatalogId while createTable and importTable both 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 answered EntityNotFoundException, the delete path's warn-and-continue idempotency treated that as "already gone", and cdkd destroy reported SUCCESS while the table survived. cdkd's physical id (<databaseName>|<tableName>) encodes no catalog, but the properties bag ResourceProvider.delete receives does. A new shared deleteCatalogId() / catalogIdForApi() pair reads the field so an unresolved intrinsic OBJECT is DROPPED rather than sent (the importableString guard #1651 introduced) and a YAML-numeric account id is COERCED to a string rather than dropped — --migrate-from-cloudformation reads the stack's ORIGINAL template, where an unquoted CatalogId: 123456789012 parses 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" — on cdkd destroy the state record is already gone). The account-id pseudo parameter ({Ref: AWS::AccountId} / the two Fn::Sub spellings — what @aws-cdk/aws-glue-alpha renders 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-wrong CatalogId (a typo, a stale account id) is diagnosable from the log. Sibling audit of every Glue delete path: deleteTable was the missing one; deleteDatabase and GlueConnectionProvider.delete already forwarded it but through a bare as string | undefined cast that would have sent an intrinsic object, and both moved onto the shared guard; DeleteWorkflow / DeleteSecurityConfiguration / DeleteJob / DeleteCrawler / DeleteTrigger have no gap (neither the CFn type nor the SDK request shape declares CatalogId), 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 three readCurrentState readers and GlueConnectionProvider.import (the straggler #1651 left on a bare cast), so cdkd drift stops handing GetTable / GetConnection an 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 wrong Ref: AWS::Glue::Table was in neither of cfnRefValueFromPhysicalId's compound-id Sets although GlueProvider stores <databaseName>|<tableName>, so {Ref: <Table>} resolved to mydb|my_table where CloudFormation returns the TABLE NAME — and that value was pushed to AWS by whatever consumed it (a crawler's Targets.CatalogTargets[].Tables, a CfnOutput, a Lake Formation permission). The type joins REF_RETURNS_SEGMENT_AFTER_PIPE; the fix also reaches cdkd orphan's {Ref: <orphan>} rewriter, which shares the same pure helper. Pre-#1651 the path was unreachable for cdk deploy-managed stacks (they could not be imported at all), which is what widened its reach. Composite-type sibling audit (every row of docs/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 because Ref is a synthetic / AWS-generated id — ApiGateway::Method, EC2::NetworkAclEntry, EC2::Route, EC2::VPCGatewayAttachment, Lambda::EventInvokeConfig; correct to exclude because the docs page documents no Ref at all — EC2::SecurityGroupIngress (and its Egress sibling) and Lambda::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 (Ref returns the resource ARN, so it must be RECONSTRUCTED like the WAFv2 WebACL case, not extracted) and Route53::RecordSet (Ref returns "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 — createTable packs ${db}|${name} UNGUARDED, so a deploy-created table named a|b is already broken independently of this entry (the create-path half of #1672). Tests: 24 new Glue cases (exact DeleteTableCommand input with CatalogId; the drop-on-intrinsic, no-CatalogId-declared, absent-bag, numeric-coercion, non-finite, empty-string and explicit-null arms; both polarities of the pseudo-parameter carve-out; the Database / Connection skip-helper call sites by kind; the malformed-physicalId skip arm the issue quotes as the leak surface; the four read-path sites) plus 3 resolver cases asserting the RESOLVED value my_table — bare, through Fn::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.ts is in the integ-broad markgate 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 an onUnusable wrapper) and were the same bug class unfixed. The issue named three — applyAnalyticsConfigurations' StorageClassAnalysis.DataExport.OutputSchemaVersion and destination Format, plus applyInventoryConfigurations' destination Format — and review found a FOURTH inside the same method: the inventory ScheduleFrequency -> Schedule.Frequency fall-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 holds V_1 / CSV / the fallen-back cadence; analyticsSdkToCfn / inventorySdkToCfn read 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 four Id-keyed appliers return a PerItemApplyOutcome ({skipped, substituted}) instead of a bare index list, and both call-site recorders fold the substituted item into effectiveProperties: 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 CFn Destination block is accepted flattened AND nested, so a hardcoded branch leaves the malformed value alive at the other key and adds a stray one — s3BucketDestinationSegments derives it from the picked bag's IDENTITY rather than re-running isFlattenedDestination, since a third copy of that branch condition is what the predicate's own header warns about); readSubstitutedConfigString hands the recorder the value the read RETURNED rather than the fallback literal, and forwards ONE ConfigStringOptions bag to both the configStringRefusal probe and the readConfigString it 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 by DiffCalculator afterwards. Without onUnusable there 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: analyticsSdkToCfn emitted the SDK's NESTED Destination: { S3BucketDestination: … } wrapper, a shape the CFn schema does not declare at all — tests/fixtures/cfn-schemas/AWS-S3-Bucket.json contains zero occurrences of that key and its nestedPropertyPaths end at StorageClassAnalysis.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 analytics Format half of this fix would have done nothing. The mapper now emits the flattened CFn shape with the same !== undefined per-field guards as inventorySdkToCfn (keeping the Bucket -> BucketArn rename, and the SDK's BucketAccountId spelling, which is AccountId on the inventory side — the two mappers look alike and are NOT interchangeable on that member). Transitional note: a bucket whose observedProperties were captured by an older binary holds the nested shape, so the first cdkd drift after upgrading reports the analytics destination once; the next deploy (or cdkd drift --accept) re-captures the flattened shape and it converges. Deliberately NO canonicalizeDesiredProperties twin, 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's createOnlyProperties is BucketName / BucketNamePrefix / BucketNamespace and neither array property is named in either half of the type's ReplacementRulesRegistry entry, so isClassified is 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, since narrowIngressIpProtocol also throws without an onUnusable and canonicalizeDesiredProperties bridges 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-Id loop, 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 diff keeps 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: StorageClassAnalysisSchemaVersion has exactly one member (V_1), AnalyticsS3ExportFileFormat exactly one (CSV), InventoryFormat is CSV | ORC | Parquet with no aliasing, and both reverse mappers copy the fields straight back — there is no service-side value MAPPING (the IpProtocol: 6 -> tcp shape), 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 hardcoded 0 passes all 5727 provisioning tests otherwise), the replay-CREATE item kept rather than dropped with its wire value asserted, the ScheduleFrequency fall-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 real calculateResourceDrift reports 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 — effectiveProperties undefined, 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 the ScheduleFrequency record fails its row, and reverting the analytics mapper to the nested shape fails the convergence row. Live coverage: tests/integration/s3-analytics-inventory gains 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 new CDKD_TEST_UPDATE=malformed-substitute mode blanks the three template-expressible fields (a BLANK STRING is what an unresolved intrinsic produces, is exactly what configStringRefusal refuses, 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 by analyticsSdkToCfn from 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 requires cdkd diff --json to report no analytics/inventory difference, twice — with a NEGATIVE twin that rewrites the recorded destination into the pre-fix NESTED shape and requires cdkd diff --fail to NAME it, so a non-zero exit for an unrelated reason cannot satisfy it. diff rather than drift because the drift comparator prefers observedProperties and 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. The ScheduleFrequency fall-through stays unit-only — the CFn schema declares no Schedule member 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 StreamSpecification and Lambda URL AuthType warn arms now report effectiveProperties (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 — readCurrentState could never match it, every later cdkd drift re-reported the same difference, and drift --revert re-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 PREVIOUS StreamSpecification — the UpdateTable never ran, so AWS still holds the previously-applied configuration. Lambda URL's previous-value arm records the previous AuthType, which is literally what went on the wire. Lambda URL's OMITTED arm (previous side unusable too, so AuthType is 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 the UpdateFunctionUrlConfig response would be what AWS HOLDS rather than what cdkd SENT — a read-back value belongs in observedProperties, not in the properties baseline the #1160 absent-field removal derivation reads — while a dropped key is never compared by drift-calculator, so the phantom drift is gone either way. Neither gains a canonicalizeDesiredProperties twin, 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 the NEW_AND_OLD_IMAGES default 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 (StreamViewType only — GlobalTable's StreamSpecification declares no StreamEnabled, 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 sets replayingState discarded effectiveProperties — 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 via AWS::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 same configStringRefusal predicate the desired side runs before being retained: previousProperties is a STATE record, so on a replay it can hold null / '' / 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. And LambdaUrlProvider.create gained a replay warning for an ABSENT AuthType: an absent value is not malformed, so neither the refusal nor replayWarn fires 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 absent AuthType stays 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 no effectiveProperties); 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 — BillingMode warn-and-SUBSTITUTE, desiredGsiUnusable warn-and-SKIP, the needsStream auto-enable and the StreamSpecification: {} 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::Table import 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 by createTable because GetTable / UpdateTable / DeleteTable all need both segments while ResourceProvider's read-side methods (getAttribute, readCurrentState) receive a single string. CloudFormation's physicalId for the same type is the TABLE NAME ALONE — Ref returns it, DescribeStackResources reports it, and it never contains |. Auto-mode import merges CFn-derived ids into the overrides map before the loop (#1128 / #1130), so knownPhysicalId legitimately arrived as "my_table"; importTable did const [dbName, tName] = knownPhysicalId.split('|') and returned null on the resulting tName === undefined WITHOUT ever calling AWS. Every cdk deploy-managed Glue table therefore reported skipped-not-found under cdkd import and cdkd import --migrate-from-cloudformation, while the accompanying hint told the user to pass --resource <LogicalId>=<physicalId> — pointing them at exactly the id the code had just rejected. The template-derived fallback below would have succeeded (CDK renders DatabaseName as a Ref to the sibling AWS::Glue::Database, whose CFn physicalId IS the database name, so substituteOverrideRefs resolves it to a literal before import() runs), but the presence of knownPhysicalId short-circuited past it — which is what makes this an early-return defect rather than a missing feature. docs/import.md lists the type under "Auto-resolved (no --resource flag needed)", a claim the fix makes true. The fix: resolveTableIdentity resolves the (databaseName, tableName) pair from EITHER shape — a | in knownPhysicalId means the composite, its absence means CloudFormation's bare table name, paired with the template's DatabaseName. The probe result is ALWAYS normalized back to <db>|<table> before it is recorded, since updateTable / deleteTable / readTable all 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 a cdkd destroy that 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) showed glue:CreateTable ACCEPTS a table literally named a|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, and updateTable / deleteTable / readTable all destructure the stored id into exactly TWO segments, so that record decodes as database <db>, table a — a DIFFERENT table, which deleteTable would 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's import() 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: resolveTableIdentity refuses any resolved segment containing |, whatever branch produced it (the composite and bare branches are |-free by construction, but a template TableInput.Name of a|b is 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, so mydb|a|b read as database mydb, table a — 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 named a|b types exactly it. The composite branch now requires EXACTLY two segments. The refusal also stopped guessing its own reason: resolveTableIdentity returns 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 --resource hint 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's TableInput.Name — the user named a specific resource to adopt. importableString also covers the override itself, so a --resource-mapping entry of "" (which parseMappingJson accepts, unlike the --resource flag) stays not-found instead of sending GetTable({Name: ''}), whose InvalidInputException is not EntityNotFoundException and would abort the whole import run. A bare id with no template DatabaseName to pair with still returns null with 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: CatalogId was read as properties['CatalogId'] as string | undefined and forwarded to GetTable / GetDatabase whenever truthy. @aws-cdk/aws-glue-alpha sets catalogId: Stack.of(this).account, which renders as {"Ref": "AWS::AccountId"} for an environment-agnostic stack; a pseudo parameter is never in the overrides map, so substituteOverrideRefs leaves the intrinsic in place and the cast handed an OBJECT to the Glue API as if it were a catalog id. The new importableString guard 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 to CatalogId, DatabaseName and TableInput.Name on the import path in both importTable and importDatabase. 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 an AWS::Glue::Table entry to COMPOSITE_ID_SPLITTERS in src/cli/commands/export.ts would be dead code. DescribeType reports primaryIdentifier: ["/properties/Id"] — a SINGLE field — so resolveResourceIdentifier takes 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 as Id) 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 template DatabaseName present (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), EntityNotFoundException with 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, the a|b refusal with no AWS call, an empty-string override staying not-found, an unresolved CatalogId intrinsic dropped on both the Table and Database paths, a literal CatalogId still forwarded, and an unresolved DatabaseInput.Name intrinsic 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: the import-auto-mode fixture (which exists for #1128, the RESOLUTION half of this same path) gains a Glue database + table pair. DatabaseName is a Ref to 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 let importTable'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 of intrinsic-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 unverified knownPhysicalId, the silent variant of this same class. The suite's beforeEach also gained a mockGlueSend.mockReset(), since vi.clearAllMocks() does not drain the mockResolvedValueOnce queue 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 report effectiveProperties, 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, so DeployEngine.propertiesToRecord recorded the desired bag, malformed value and all, for a Put that never reached AWS. readCurrentState could never match it, every later cdkd drift re-reported the same difference, and drift --revert re-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 have drift --revert and 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 / applyReplicationConfiguration return a Promise<boolean> "applied", and the four per-Id appliers (applyMetricsConfigurations / applyAnalyticsConfigurations / applyIntelligentTieringConfigurations / applyInventoryConfigurations) return the indexes of the items they skipped — and create() / update() fold those into effectiveProperties. Reporting the skip EXPLICITLY rather than by wrapping onUnusable is load-bearing: that callback is shared by SKIP-class guards (configStringRefusal / requireConfigObject / requireConfigArray) and warn-and-DEFAULT reads (readConfigString with the options bag, where the applier proceeds WITH a substituted default — the analytics OutputSchemaVersion and the analytics / inventory destination Format), 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 same Id IN PLACE — dropping it when the skipped item was an ADD — preserving the DESIRED order, because DiffCalculator compares 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 NO canonicalizeDesiredProperties twin, which is the opposite of what .claude/rules/providers.md and docs/provider-development.md previously 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 asserting effectiveProperties stays 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 plain for…of rather than configs.entries(), because the nested-key critic's write-evidence walk resolves config as a value read off the desired property bag and cannot follow an array-destructuring pattern — the tidier spelling reported 9 false no-write-evidence divergences. No CLI flag, dependency, or state-schema change. The audit of item 3's non-S3 warn-and-skip sites found AWS::EC2::Route already covered by #1591, and two live gaps filed as #1653 (DynamoDB GlobalTable StreamSpecification) and #1654 (Lambda URL AuthType).
  • EC2: the two IpProtocol residuals 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, but canonicalizeDesiredProperties still folded only the TYPE (#1633's stringification). So a template edit rewriting IpProtocol: 6 to 'tcp' read as a CHANGED property, and since IpProtocol is create-only on AWS::EC2::SecurityGroupIngress the 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 for AWS::EC2::SecurityGroup's inline SecurityGroupIngress[] / SecurityGroupEgress[] lists (lower stakes there — not create-only — but the diff previewed an update() that, since #1643 keys both spellings identically, issues no revoke/authorize at all). The fold sits in canonicalizeDesiredProperties and deliberately NOT in narrowIngressIpProtocol, which also feeds the CREATE path: what cdkd puts on the WIRE is not the defect, since AWS accepts either spelling and stores tcp for both. #1649 — Tags were in neither camp. Every SIBLING standalone type (SecurityGroupIngress, Route, VPCGatewayAttachment, SubnetRouteTableAssociation, NetworkAclEntry, SubnetNetworkAclAssociation) declares Tags in getDriftUnknownPaths because its read cannot return them. AWS::EC2::SecurityGroup was neither declared NOR read, so a templated tag block compared against undefined and reported permanent phantom drift on the properties-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: DescribeSecurityGroups returns Tags in 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. normalizeAwsTagsToCfn strips the reserved aws:-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 the drift-revert-arrays step 6b Tags workaround 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-wait CI-minutes rationale, force-unlock after 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 prNumber context + name suffix (state keyed by (stackName, region), per-stack locks → concurrent PR deploys), the OIDC base-role → --role-arn/CDKD_ROLE_ARN switch into a dedicated admin-equivalent deploy role (base role holds ONLY sts:AssumeRole; trust policy pinned back; cdk-hnb659fds-* roles unusable), a minimal GitHub Actions workflow (deploy on open/sync/reopen; destroy on close via state destroy --yes with no checkout/npm ci/synth), and housekeeping (state list --json sweeps, gc outside deploy hours because of the lock abort, force-unlock, state show --json for 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 --yes is 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.ts ttlMinutes ?? 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 by mockResolvedValueOnce and 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 one logger.warn that was never called, and locating it took a full instrumentation pass. After: every *Once spelling funnels through mock.mockImplementationOnce in @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 unless CDKD_ONCE_LEAK_DETECT=1, so the default vp run test is byte-for-byte unchanged and no in-flight branch is disturbed — the new once-leak-detect CI job (vp run test:once-leak) is what arms it. Three pre-existing offenders are grandfathered in tests/once-leak-allowlist.json (regenerate with vp 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 requiring mockReset() wherever a *Once primer 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 with mockReset() in beforeEach (a setup-file afterEach runs BEFORE the next test's beforeEach), 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 a detector canary CI 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: consumeOnce is a function declaration and therefore constructable, so tests/setup.ts's pre-existing wrapConstructableImplementation saw a constructable value, passed it through unwrapped, and an ARROW implementation underneath died on Reflect.construct — making vp run test:once-leak diverge behaviourally from vp run test. Both patches now share tests/constructable-implementation.ts. The same review found the construction test was a SURVIVOR (@vitest/spy already reaches the wrapper via Reflect.construct, so a this-mutating probe passes under .apply too) and that the vi.spyOn arm 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 in tests/unit/utils/aws-region-resolver.test.ts made it exit non-zero naming the leak, and the allow-list suppressed it.
  • drift --revert and the rollback revert arms now honour a provider's effectiveProperties (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: effectiveProperties is how a provider says "this is the bag I ACTUALLY sent" — the loop breaker behind #1591 (AWS::EC2::Route) and #1633 (AWS::EC2::SecurityGroupIngress) — and DeployEngine.propertiesToRecord records it in place of the desired bag. The three OTHER provider.update() call sites discarded the return value entirely: cdkd drift --revert and 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 next cdkd drift reported the identical difference, and --revert re-issued the identical revoke + re-authorize with the identical warning — forever, for anyone using --revert as their repair tool. The deploy path self-heals, so a single cdkd deploy converged; the loop persisted only on these commands. Now all three record it. The two shapes differ because the bag handed to update() differs. The rollback arms send previousState.properties verbatim, so effectiveProperties is its complete replacement and the arms store {...restored, properties: effectiveProperties} — physical id, attributes, dependencies and policies of the restored record all survive untouched. --revert sends buildRevertNewProperties'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 --revert into --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 explicit undefined the caller turns into a delete (a {...baseline, ...delta} spread would otherwise leave an undefined-valued key in the JSON). Three details of that delta were found by the PR review and each closes a route back to --accept behavior: presence is decided by key in effective rather than by comparing to undefined, so a genuinely null-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 than JSON.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 NO observedProperties only a DROP is recorded, never a value — that baseline is the raw template and buildRevertNewProperties ran in preserveUntemplated mode (#1626), so the sent value deliberately carries every AWS-authored path the template never declared, and writing it into properties would 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 own try, so a provider handing back a non-comparable bag cannot re-report an already-succeeded revert as AWS update failed. The delta lands in the same field the drift comparator uses as its baseline — observedProperties when the resource has one, else properties — matching --accept, so properties keeps 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 same expectedEtag optimistic-locking and migrateLegacy handling as --accept; a run where no provider narrowed anything writes no state at all, so the pre-existing "--revert does 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 next cdkd drift (i.e. exactly the pre-fix state). Tests: 6 drift cases (observed-baseline write, properties fallback, the ONLY-provider-changed-keys fence with a drifted key and an AWS-only Tags addition 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 keeps previousState by 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 every effectiveProperties producer, so AWS::EC2::Route and AWS::EC2::SecurityGroupIngress converge on these paths today and any future producer inherits it. No CLI flag, dependency, or state-schema change.
  • IpProtocol readback canonicalization for cdkd 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 made AWS::EC2::SecurityGroupIngress record the IpProtocol it 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: for IpProtocol: 6 cdkd sent and recorded '6', AWS held 'tcp', readSecurityGroupIngressCurrentState returned 'tcp', and every cdkd drift reported the difference forever while --revert revoked and re-authorized into the same state — permanent phantom drift via a third route, and one #1633 deliberately left untouched for the inline AWS::EC2::SecurityGroup.SecurityGroupIngress[] / SecurityGroupEgress[] rules as well. Now a pure canonicalizeIpProtocols(baseline, aws, resourceType) pass runs on BOTH comparison sides in drift.ts, immediately after the #1515 principal pass. This is the issue's option 2 (readback normalization) rather than option 1 (a send-side table in narrowIngressIpProtocol): 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 the properties-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, 132 and 255 all read back as the number. So the rename set is the closed set of protocols EC2 has a NAME for, and -1 being 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 / tcp authorized on one port collapsed into ONE tcp permission — 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 YAML IpProtocol: 47 parses to a number while AWS always reads back a string. Scope is a CLOSED path table (the two standalone rule types plus the AWS::EC2::SecurityGroup inline arrays) rather than a walk for any key named IpProtocol, because a blanket rewrite would turn an unrelated '6' into 'tcp'; EVERY number is stringified (not only an integer one, so a malformed 6.5 keeps its pre-#1633 key and NaN / Infinity do not collapse onto one JSON null bucket); 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 that readSecurityGroupIngressCurrentState "returns tcp"; it does not — it returned undefined, and cdkd drift reported 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 for IpProtocol: 6 the tuple pre-filter (sg-…|6|… vs an AWS IpPermissions[].IpProtocol of tcp) matched nothing, and the full-signature sgRuleKey compare 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 — so ec2-provider.ts's sgProtocolKey now routes through the same canonicalizeIpProtocolValue, which heals the standalone lookup AND the inline-rule reconcileSgRules ordering 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 its tcp from reading as drift against a recorded '6'. 39 unit tests, each half mutation-probed (deleting the 6 -> tcp row fails 7 comparator tests including the headline case; reverting sgProtocolKey fails the provider lookup test). The drift-revert-arrays fixture gains BOTH shapes: an inline numeric rule injected at the L1 (the L2 addIngressRule can 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 live IpPermissions while 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 carrying Tags still phantom-drifts on the properties-fallback baseline, because readSecurityGroupCurrentState never reads tags back while AWS::EC2::SecurityGroup — unlike every sibling standalone type — is not in getDriftUnknownPaths. Filed as issue #1649; the fixture's step 6b drops the Tags key 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.md warned that a "lossless" coercion needs no warn arm, which a reader could take as "number -> string is the bar" — the measured 6 -> '6' -> AWS tcp case 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.createRoute refused a template declaring more than one of DestinationCidrBlock / 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 classify NO_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 inside ProviderRegistry.validateResourceProperties on 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 (an Fn::If arm resolving to AWS::NoValue is 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 mirrors narrowRouteDestinations' 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 diff is deliberately NOT wired: #1591's canonicalization warning in DiffCalculator.compareProperties already 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 own DeployEngine inside NestedStackProvider, 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 shape validateResourceTypes has always had, not a regression introduced here. Live-verified via cdkd deploy --dry-run on a scratch CDK app — the two-literal route is refused with the full message, and the Fn::If pair 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.IpProtocol no 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 an IpProtocol that 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. createSecurityGroupIngress warn-substitutes the -1 default for a MALFORMED IpProtocol ('' / {} / true / an explicit null) on the replay-reachable paths and sends the default; and an unquoted YAML IpProtocol: -1 is a NUMBER, accepted by design since #1513 and STRINGIFIED before it is sent. Either way readSecurityGroupIngressCurrentState returns what AWS holds ('-1') and could never match the record, so every cdkd drift reported the difference and drift --revert "repaired" it by calling update() again — which revokes, re-authorizes and re-emits the same warning, forever. Now both halves of the #1591 remedy are in place. A new shared narrowIngressIpProtocol helper 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() returns effectiveProperties (from the success arm AND the idempotent "already exists" arm, which also writes state) and updateSecurityGroupIngress forwards it, so state records what was SENT; and canonicalizeDesiredProperties gains an AWS::EC2::SecurityGroupIngress arm so the template side is narrowed identically. The second half is not optional: IpProtocol is 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 no onUnusable downgrade — 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: -1 and '-1' name the same protocol, so nothing is lost, and recording the string additionally fixes the delete path, which forwards the state record to buildIpPermission and would otherwise hand the EC2 API a number. An ABSENT IpProtocol deliberately 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: generateResourceName produces {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 the lambda-durable-replacement fixture, whose function declares no functionName and whose message named CdkdLambdaDurableReplacementExample-DurableFn91E3F4D8. Now a new best-effort looksLikeCdkdGeneratedName(physicalId, logicalId, stackName) runs the naming scheme backwards, and the engine's shared replacementNameOrigin helper 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 — the NAMED_REPLACEMENT_COLLISION throw, the UpdateReplacePolicy: Retain refusal beside it, the --replace info line (now "collided with the existing resource's name" rather than "the custom-named resource"), and the NAMED_REPLACEMENT_IDEMPOTENT_CREATE arm — 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-type maxLength / lowercase / allowedPattern options 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 active withStackName scope — answers false, which merely keeps the old wording. Unchanged: the CloudFormation-parity framing, the --replace escape 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 --replace is 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: applyGsiUpdates issued one UpdateTable per GSI op, and everything after it in update() — PITR, TTL, ResourcePolicy, Kinesis streaming, Contributor Insights — could still throw. cdkd writes state only once update() RETURNS, so any op that already succeeded was left unrecorded and the next deploy re-emitted it: a created index's Create was re-sent and AWS rejected it (the index now exists), and a landed per-index throughput Update was 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 ran cdkd drift --accept — the same permanently-wedged class #1617 fixed for the Delete arm, on its two siblings. Now both arms consult the live DescribeTable snapshot update() already holds: a Create is skipped (with a WARN, since state and AWS disagreeing is worth surfacing) for an index name AWS already has, and a throughput Update is 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 (DescribeTable reports ProvisionedThroughput: {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, which gsiHandledByBillingFlip already suppresses by a different route). The capacity comparison is member-by-member as numbers rather than structural, because a DescribeTable readback carries NumberOfDecreasesToday / LastIncreaseDateTime alongside the two capacities and a deepEqual against 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.Targets becomes visible to cdkd 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 pass canonicalizeUnorderedArraysAtPaths (via ResourceProvider.getDriftUnorderedPaths) handled PLAIN-STRING arrays only, so a type whose unordered list is an array of OBJECTS had no way to declare it. TargetGroup.Targets was therefore parked in getDriftUnknownPaths — meaning cdkd drift could not see a console-side target registration AT ALL — even though the provider can read it back via DescribeTargetHealth. Now the pass sorts object arrays too, at a declared path only, keyed on a private key-order-independent canonicalJson: 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 raw JSON.stringify sort 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, a null element, 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). ELBv2Provider then moves Targets out of the TYPE-level getDriftUnknownPaths into getDriftUnorderedPaths and 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 NO Targets — 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 --revert would deregister the tasks the service just placed (the #1498 class, which undeclaredEmptyObservedKeys only covers when the capture happened to be EMPTY — for a redeployed running service it is not). Targets is therefore compared only when the template declares it; an explicit Targets: [] 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, so DescribeTargetHealth keeps reporting a just-removed target as draining for minutes — including one would freeze it into the deploy-time observedProperties snapshot and produce PERMANENT phantom drift once it finally disappeared. The provider excludes exactly draining and INCLUDES every other state (initial / unused / unhealthy / unavailable are all registered; health is not registration), drops the AvailabilityZone: 'all' AWS substitutes for an unscoped ip target, and leaves the key ABSENT (never empty) when the health read fails, so a missing elasticloadbalancing:DescribeTargetHealth permission reports nothing rather than reporting every target as removed. Integ: drift-revert-arrays gains a standalone targetType: 'ip' TargetGroup (no load balancer — free, and its unused targets exercise the include-non-draining decision live) with three deliberately unsorted IP targets, a SECOND back-to-back cdkd drift assertion (two consecutive runs disagreeing is this class's signature — one run cannot detect it), and an out-of-band RegisterTargets of 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's Status, applyLoggingConfiguration's destination + TargetPrefix, and applyInventoryConfigurations' ScheduleFrequency / Schedule.Frequency pair were readConfigString refusals carrying no onUnusable, so they THREW on the replay-reachable paths — rollback-executor.ts's revert arm and cdkd drift --revert (which call update(..., previousState.properties, ...)) and its reverse-replacement arm (which calls create(..., 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 of state.json as 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 the Suspended fallback 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, since PutBucketLogging replaces 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 malformed ScheduleFrequency FALLS THROUGH to Schedule.Frequency, and the item is SKIPPED when there is nothing to fall back to — inventing a Weekly cadence 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): ScheduleFrequency is the only spelling the CFn schema declares — tests/fixtures/cfn-schemas/AWS-S3-Bucket.json has no Schedule member — so a record with a broken ScheduleFrequency almost never carries one, and treating the absent container as usable let the fall-through land on readConfigString's own Weekly default, 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 threads CreateContext into applyConfiguration, whose versioning read was reachable from the reverse-replacement create with no way to downgrade — the sibling applyAllSubConfigsForCreate had 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 a GlobalSecondaryIndexes[] entry AND flipped AWS::DynamoDB::Table from PAY_PER_REQUEST to PROVISIONED could not succeed by any template-side edit. The dropped index is still LIVE when the flip runs, so AWS demands per-index ProvisionedThroughput for it (ValidationException: ProvisionedThroughput must be specified for index: <name>, measured 2026-08-11) while the template no longer declares any — and cdkd's Delete op lived in applyGsiUpdates, 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 — one UpdateTable per 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 TO PROVISIONED (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-level ProvisionedThroughput (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 desired GlobalSecondaryIndexes (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 the Delete arm 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 after update() RETURNS, so a mid-update failure leaves the deleted index still in the previous side and the next deploy would emit a Delete for an index AWS no longer has, failing with ResourceNotFoundException on every deploy forever and re-creating the very unconvergeable class this change removes. Skipping a Delete for 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 a DescribeTable with no GSI list while removing a GSI — a response real DynamoDB never sends — were corrected to list the index live; the dynamodb-ondemand fixture's BillingRemovalTable grew a second baseline GSI that the flip deploy removes, asserted present before the flip and gone after.
  • AppSync DataSource / Resolver opted 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 shared APPSYNC_WRITE_FLOORS), tests/fixtures/cfn-schemas/AWS-AppSync-{DataSource,Resolver,ApiKey,GraphQLSchema}.json (re-captured), new tests/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 into NESTED_KEY_TARGETS — their schema fixtures pre-dated the definitionShapes / nestedPropertyPaths captures 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 (Resolver measured CLEAN on the first run at 9 audited paths; DataSource measured 10 findings at 27 paths, all fixed here), and the whole tree is back to 0 no-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; and DynamoDBConfig.DeltaSyncConfig (+ BaseTableTTL / DeltaSyncTableName / DeltaSyncTableTTL) plus DynamoDBConfig.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 shared applyDataSourceConfig mapper) with matching readCurrentState reverse 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 sending NaN, 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 one APPSYNC_WRITE_FLOORS constant because both collector outputs are per-FILE, not per-type; minWrittenMembers moved 100 -> 105 in the process, since the file grew past the point where the old value still sat inside the hygiene band. Integ: the appsync fixture 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 (SigningRegion and 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, new tests/integration/lambda-durable-replacement/. Before: isNameCollisionError matched /already exists/i, and CreateFunction raises ResourceConflictException: Function already exist: <name> — SINGULAR — so NO AWS::Lambda::Function could take the collision path. The consequence was not cosmetic: a property-driven replacement (dropping DurableConfig, changing the create-only TenancyConfig) create-firsts into its own still-live name, and the unmatched collision meant the raw SDK exception escaped instead of cdkd's actionable NAMED_REPLACEMENT_COLLISION error AND cdkd 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 accepts already exist / already exists as 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 --replace works. New fixture lambda-durable-replacement is 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 drops DurableConfig WITHOUT --replace and asserts the deploy refuses actionably while the live function keeps its old properties, phase 3 re-runs WITH --replace and 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), new tests/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 + stale PlacementStrategy bogus-tolerance removed), coverage matrices regenerated. Before: AvailabilityZoneRebalancing / DeploymentController / ForceNewDeployment / Monitoring / Role / ServiceConnectConfiguration / VolumeConfigurations / VpcLatticeConfigurations were silent-drops, so any Service template carrying one flipped to the #614 Cloud Control fallback route (PlacementStrategies was already wired since #613 — its backfill entry was simply stale). Now all are wired through CreateService / UpdateService and PROPERTY_COVERAGE_BY_TYPE reports an EMPTY silentDrop for the type, so every ECS Service is SDK-routed — the ecs-fargate fixture (ServiceConnect + managed EBS volume) is the live route-flip proof (provisionedBy=sdk pinned + deployment-level read-backs). The config blobs go through the shared recursive PascalCase->camelCase converter as whole-blob hand-offs (ManagedEBSVolume -> managedEBSVolume and SizeInGiB -> sizeInGiB are exact first-char-only flips, pinned by unit tests; ServiceConnectConfiguration.LogConfiguration.Options is a free-form log-driver map copied verbatim). ForceNewDeployment is the one CFn-only shape: an object {EnableForceNewDeployment, ForceNewDeploymentNonce} with NO per-member SDK counterpart — resolveForceNewDeployment translates Enable=true OR a nonce change into UpdateService's plain forceNewDeployment: true (create-path no-op; two nested-key allow-list entries document the collapse). Role moved OUT of unhandledByDesign (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's createOnlyProperties through 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: AvailabilityZoneRebalancing is surfaced verbatim and DeploymentController always (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 new getDriftUnknownPaths so they cannot become permanent phantom drift — Role deliberately so, since AWS reports the service-linked AWSServiceRoleForECS ARN for templates that never set it. Integ: ecs-service-update-props live-covers AvailabilityZoneRebalancing (describe-services + observedProperties), the explicit DeploymentController: {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 new force-nonce phase where ONLY the nonce changes and deployments[0].id must 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: the LoadBalancerAttributes and ListenerAttributes diff arms pushed EVERY removed key back as Value: '', 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: ModifyLoadBalancerAttributes accepts '' for the numeric idle_timeout.timeout_seconds (and genuinely resets it to 60), but REJECTS it for the boolean deletion_protection.enabled — "The value of 'deletion_protection.enabled' must be 'true' or 'false', but was ''" — and ModifyListenerAttributes rejects it identically for routing.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 the TARGET_GROUP_ATTRIBUTE_DEFAULTS sibling, 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) and ipv6.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_policy is 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 --revert passes the FULL readCurrentState snapshot as the previous side, so against a state record with no observedProperties every 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: the alb fixture now templates a LoadBalancerAttributes entry (idle_timeout 120 -> 180 -> dropped) and drops the Listener's ListenerAttributes in the removal phase, asserting both reset to AWS's documented defaults (60 / true). deletion_protection.enabled is 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 L2 ApplicationLoadBalancer emits its own deletion_protection.enabled entry, 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 (the feedback_cdk_l2_emits_explicit_defaults_in_removal_fixtures shape; synthesizing both phases and diffing the rendered attribute lists is what catches it). Also in this batch: the TargetGroup Targets update 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), and readCurrentState'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 return undefined, 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 let TargetGroup.Targets leave getDriftUnknownPaths is 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 the GraphQLSchema.DefinitionS3Location no-op sibling (issue #609 appsync batch 2) — src/provisioning/providers/appsync-provider.ts, new tests/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 using CachingConfig / CodeS3Location / MaxBatchSize / MetricsConfig / RequestMappingTemplateS3Location / ResponseMappingTemplateS3Location / SyncConfig, and DataSource templates using ElasticsearchConfig / EventBridgeConfig / MetricsConfig / OpenSearchServiceConfig / RelationalDatabaseConfig, were rejected by the #608 pre-flight — and with --allow-unsupported-properties, silently dropped (ElasticsearchConfig sat in unhandledByDesign as 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, the applyGraphQLApiConfig pattern, so create and update cannot diverge), hand-mapped member-by-member with the casing traps pinned by tests (dbClusterIdentifier for CFn's DbClusterIdentifier, awsSecretStoreArn, openSearchServiceConfig, lambdaConflictHandlerConfig.lambdaConflictHandlerArn; MetricsConfig is a scalar enum on both types, not a block), with matching readCurrentState reverse maps gated on the response's own Type/Kind discriminator so nothing becomes permanent phantom drift. The three *S3Location properties have NO SDK member at all — CloudFormation fetches the S3 object and passes its BODY as the inline member, and cdkd now mirrors that: an s3://bucket/key URL (the form CDK's asset bindings emit; anything else is refused with a clear error naming the property, never coerced) is fetched via GetObject and passed as code / requestMappingTemplate / responseMappingTemplate, the same helper also replacing the GraphQLSchema.DefinitionS3Location warn-and-drop no-op; inline wins over its S3 sibling with a loud warning (the StepFunctions DefinitionS3Location precedent), 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 / UpdateDataSource are 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's MetricsConfig, asserts AWS cleared it to DISABLED, and keeps the EventBridge data source's own MetricsConfig as the retained sibling so a blanket wipe cannot pass. Integ: the appsync fixture gains an AMAZON_EVENTBRIDGE data source (real AWS::Events::EventBus + events:PutEvents role, EventBridgeConfig + MetricsConfig + description update), resolver-level MetricsConfig on the existing resolver (the API's EnhancedMetricsConfig is already PER_RESOLVER_METRICS), and a second resolver whose mapping templates come from S3 via aws-s3-assets Assets — 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 the provisionedBy == 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) and RelationalDatabaseConfig (Aurora Data API) stay unit-only, recorded in the fixture README. Deliberately NOT done, recorded here: no ReplacementRulesRegistry entries (unlike ApiType/Visibility last batch, the registry schema for both types DOES carry createOnlyProperties — ApiId/TypeName/FieldName and ApiId/Name — so the DescribeType fallback classifies them, with the provider's existing immutability refusals as defense-in-depth), and no NESTED_KEY_TARGETS opt-in for the two types: their schema fixtures pre-date the #1378/#1464 definitionShapes / nestedPropertyPaths captures and re-capturing requires live cloudformation: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-existing HttpConfig.AuthorizationConfig nested 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, new tests/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 — including updateRoute, 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. readRouteCurrentState can only ever return the ONE key AWS holds, so the losers were PERMANENT phantom drift: reported by every cdkd drift, and drift --revert "repaired" it by calling update() 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 return effectiveProperties (a new optional field on ResourceCreateResult / ResourceUpdateResult via the shared EffectivePropertiesResult) 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) and updateRoute forwards 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 in observedProperties; 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; every AWS::EC2::Route destination key is create-only, so that classifies as a REPLACEMENT, and the engine's replacement create passes no context — so createRoute hits the #1566 refusal and a previously-green no-op deploy starts FAILING. Without create-only knowledge (no DescribeType) it classifies in-place instead and delete-and-recreates a live route on EVERY deploy. So BOTH comparison sides are narrowed identically: a new optional ResourceProvider.canonicalizeDesiredProperties(resourceType, properties) (pure, synchronous) that DiffCalculator applies to the resolved desired properties via a new canonicalizeDesired argument — 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" rule drift-normalize.ts already 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 diff was never wired, so the preview forecast a REPLACEMENT the deploy would never perform — the phantom moved one command over rather than being fixed — so diff.ts now builds the SAME normalizer through the shared makeCanonicalizePropertiesFn and threads it through buildDiffTree / computeStackDiff into every nested child; and the diff test re-implemented the provider's gate in a local closure, so gutting EC2Provider.canonicalizeDesiredProperties to return properties produced 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 with undefined was 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, new tests/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 / TlsConfig were 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 ride CreateIntegration / UpdateIntegration with the matching reverse map in readCurrentState; PROPERTY_COVERAGE_BY_TYPE reports an empty silentDrop for the type, which closes the last of the five AWS::ApiGatewayV2::* types. Nine are exact-spelling pass-throughs; ResponseParameters is 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>"}} — Destination becomes 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. toSdkResponseParameters performs 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), and toCfnResponseParameters performs the inverse in readCurrentState — without which the AWS-current side would carry the flat map against a CFn-shaped baseline and every cdkd drift run 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 carry NESTED_KEY_ALLOW_LIST entries rationale'd as a shape difference (the same shape as the existing AWS::AppSync::GraphQLApi Tags.Key entry), and the target's minNestedKeys floor 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: the apigatewayv2-update-removal fixture now carries THREE integrations, because the properties are scoped to different integration types — an HTTP HTTP_PROXY one (TlsConfig + ResponseParameters), an HTTP AWS_PROXY service integration invoking EventBridge under its own IAM role (IntegrationSubtype + CredentialsArn), and a WebSocket MOCK one (RequestTemplates / TemplateSelectionExpression / PassthroughBehavior / ContentHandlingStrategy, which the previous batch's fixture comment had recorded as unreachable while they were still silent-drop). verify.sh therefore resolves integration ids by IntegrationType instead of taking Items[0], and the ResponseParameters assertion reads the values back under their Destination KEYS — 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. ConnectionId stays 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: Source was skipped unless it was a STRING, but CFn types ResponseParameters as free-form object and coerces scalars, so an unquoted Source: 403 under the canonical overwrite:statuscode destination — the exact shape a YAML author writes — was dropped with a green deploy, re-introducing the very class the fold exists to close. Source is now coerced from a number / boolean; a non-string Destination (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-sent IntegrationSubtype now 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), toCfnResponseParameters sorts its pairs by Destination (it turns an unordered SDK map into an array, and the drift comparator compares arrays positionally), and all ten update gates use != null so an explicit null cannot produce a field-less UpdateIntegration on every deploy. The fixture gained a provisionedBy assertion 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's Unable to complete operation due to concurrent modification. Please try again later. — the service serializes mutations per API while cdkd deploys siblings of one ApiId in parallel by design, and src/deployment/retryable-errors.ts carried 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's IpamPools wrapper on create; ModifyIpPools on update with RemoveIpamPools: ['ipv4'] on removal), EnforceSecurityGroupInboundRulesOnPrivateLinkTraffic (no CreateLoadBalancer member — applied via post-create / update SetSecurityGroups), MinimumLoadBalancerCapacity (post-create / update ModifyCapacityReservation; removal issues ResetCapacityReservation because a retained reservation keeps billing), and EnableCapacityReservationProvisionStabilize (CFn-only orchestration flag with NO SDK member — cdkd polls DescribeCapacityReservation until every zone is provisioned, bounded ~10 min, timeout warns-and-continues, a failed zone errors; skipped under --no-wait; declared in getDriftUnknownPaths since it has no AWS readback). TargetGroup: IpAddressType + TargetControlPort on 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 with ResourceUpdateNotSupportedError and the engine falls back to replacement), TargetGroupAttributes (post-create ModifyTargetGroupAttributes + key-diff on update via the new shared diffAttributes helper — also now used by the LB / Listener attribute diffs; the removal arm sends the documented default from TARGET_GROUP_ATTRIBUTE_DEFAULTS because 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), and Targets (post-create RegisterTargets + full-tuple diff on update: register-first, then DeregisterTargets; deliberately NOT read back by readCurrentState — declared in getDriftUnknownPaths because 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). readCurrentState reads back the new LB fields (flattening IpamPools to the CFn spelling, capacity via DescribeCapacityReservation emitted only when units > 0) and the TG fields (full sorted TargetGroupAttributes set, same model as LB/Listener attributes). Live-verified end-to-end by the extended alb integ (baseline attrs/targets/IpAddressType → update-phase attribute diff + target swap → removal-phase traffic-port reset + attribute 300 reset → destroy clean). The capacity-reservation pair is unit-only: the integ account lacks the LCU entitlement — a live ModifyCapacityReservation attempt 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 to NESTED_KEY_TARGETS in 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, new tests/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-rule Status, the intelligent-tiering per-item Status, the inventory per-item IncludedObjectVersions, and the replication per-rule Status (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 a readConfigString carrying no onUnusable. rollback-executor.ts's revert arm and cdkd drift --revert both call update(..., 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 NOT readConfigString'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 new configStringRefusal(container, key, fallback, containerPath, options?) in config-shape.ts exports the predicate WITHOUT the action clause — it returns the refusal sentence or undefined — and requireConfigString is 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-written typeof twin disagrees on exactly the blank string, the explicit null and 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 (versioning Status, the logging reads, the inventory ScheduleFrequency / Schedule.Frequency pair) — each is a separate per-site decision. 85 new unit tests (79 provider-side + 6 on configStringRefusal); 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: flipping continue -> return on 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 the good Put (the rows now put the malformed item FIRST); mutating the probe's fallback from '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 BillingMode flip to PROVISIONED now carries per-GSI ProvisionedThroughput, so a table WITH an index can be flipped at all (issue #1588) — src/provisioning/providers/dynamodb-table-provider.ts, new tests/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-level ProvisionedThroughput — exactly what the provider sent — is rejected outright with ValidationException: One or more parameter values were invalid: ProvisionedThroughput must be specified for index: gsi1, applying NOTHING; the same call carrying GlobalSecondaryIndexUpdates[].Update.ProvisionedThroughput is 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() builds GlobalSecondaryIndexUpdates in the SAME UpdateTable as the flip, mirroring what dynamodb-globaltable-provider.ts does for (#1387). Three scoping decisions, each with a reason a reviewer can challenge: the gate is the FLIP specifically (live PAY_PER_REQUEST -> desired PROVISIONED with at least one live index), because AWS rejects an UpdateTable that 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 LIVE DescribeTable, 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. The dynamodb-ondemand fixture'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, and verify.sh waits 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::Function silent-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's silentDrop entries 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 — CodeSigningConfigArn and DurableConfig / TenancyConfig on CreateFunction, RuntimeManagementConfig via a post-create PutRuntimeManagementConfig under the same delete-on-failure atomicity contract RecursiveLoop / ReservedConcurrentExecutions already used (the three now share one extracted applyPostCreateConfig helper instead of three copies of the same 30-line block). Each also reads back in readCurrentState, 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: CodeSigningConfigArn removal maps to DeleteFunctionCodeSigningConfig (leaving it attached would keep enforcing a security control the template dropped — the #1160 class); RuntimeManagementConfig has no delete counterpart, so removal re-sends the AWS default UpdateRuntimeOn: Auto; and DurableConfig is the one field in the UpdateFunctionConfiguration block that deliberately does NOT go through clearOnUpdateRemoval, 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 new conditionalReplacements predicate while a both-sides-present edit stays in place. TenancyConfig is create-only in the CFn registry schema AND the SDK, so it is an unconditional replacementProperties entry. Deliberately NOT wired: CapacityProviderConfig + FunctionScalingConfig, which are one coupled feature — PutFunctionScalingConfig fails with AccessDeniedException: The function provided by the arn does not contain a capacity provider configuration unless the function already carries a capacity provider, which must be provisioned separately (issue #1616); and PublishToLatestPublished, now declared unhandledByDesign — it is a CloudFormation version-publishing directive with no member on ANY @aws-sdk/client-lambda request shape (0 hits across the whole dist-types tree, while the other six all resolve). Tests: 30 new unit cases across the two files, plus the lambda-config-field-removal integ extended to assert all four properties reached AWS, both removal spellings took effect, and provisionedBy=sdk on both functions (a silent-drop re-route to Cloud Control would fail the run). Three existing test fixtures that used RuntimeManagementConfig as their canonical Lambda silent-drop example were moved to FunctionScalingConfig, which cannot be backfilled on its own and so stays silent-drop.
  • Lambda ImageConfig kept-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, and ImageConfig was one of the three left UNPROBED — if UpdateFunctionConfiguration MERGED its sub-shape, a template that keeps ImageConfig while dropping Command would 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 by UpdateFunctionConfiguration with {EntryPoint} alone left ONLY EntryPoint live; and CloudFormation reaches the same end state, an update-stack that drops Command + WorkingDirectory from a kept ImageConfig block 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 (SDK ImageConfig: {} and a CFn template dropping the whole block both leave ImageConfigResponse absent). What shipped: no behavior change — the classification is recorded at the clearOnUpdateRemoval site and in §2a next to the LoggingConfig whole-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: ECS DeploymentConfiguration (its file is owned by the in-flight #609 ECS lane) and ASG CapacityReservationSpecification (needs a billed Capacity Reservation).
  • AWS::DynamoDB::Table BillingMode removal is a RESET to PROVISIONED, and no longer emits an empty UpdateTable (issue #1553) — src/provisioning/providers/dynamodb-table-provider.ts, new tests/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 default PROVISIONED for an absent BillingMode, but update() left it undefined. Against a recorded previous of PAY_PER_REQUEST that read as a change, and the updateInput.BillingMode = … assignment was gated on the value being truthy — so UpdateTable({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, stack CdkdIssue1553BillingModeAb): a table deployed with BillingMode: PAY_PER_REQUEST and then UPDATE'd with the property REMOVED and no ProvisionedThroughput fails the stack — UPDATE_FAILED … Property ProvisionedThroughput cannot be empty -> UPDATE_ROLLBACK_COMPLETE — while the same removal WITH ProvisionedThroughput: {3, 4} reaches UPDATE_COMPLETE and reads back PROVISIONED at 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 to PROVISIONED, so the update path agrees with create(), 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 no ProvisionedThroughput in 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 --revert replay of a state record the user cannot edit. The GlobalTable sibling was checked and is clean — AWS::DynamoDB::GlobalTable's own CFn default is PAY_PER_REQUEST and its update() already resolves an absent value through requireConfigString(…, 'PAY_PER_REQUEST', …), so newBilling is never undefined and the empty-UpdateTable shape cannot occur there. Integ: the dynamodb-ondemand fixture gained an unconditional hand-written L1 BillingRemovalTable (an L2 dynamodb.Table always emits billingMode, and the property has to be genuinely absent) whose CDKD_TEST_UPDATE=true phase removes BillingMode and declares 3/4 instead, plus a phase-1.6 readback asserting PROVISIONED at 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, new tests/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's GlobalSecondaryIndexes was 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 dropped MaxReadRequestUnits live in AWS while cdkd reported success. Now buildLiveRecoveryGsiBaseline carries 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: ProvisionedThroughput is gated on the LIVE BillingModeSummary (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 *CapacityAutoScalingSettings keeps an identity-only capacity (the live number belongs to Application Auto Scaling while the desired side is MinCapacity, so comparing them issues a scale-down nobody asked for — detected from the TEMPLATE by the new collectAutoScaledGsiNames), and KeySchema / Projection / WarmThroughput are always copied from the desired side so they can never differ (the first two are immutable on an existing index and AWS does not guarantee NonKeyAttributes readback order; warm throughput reads back with a Status member the translated shape lacks and is increase-only). The whole entry is built by SPREADING the desired one so key ORDER is preserved — deepEqual is JSON.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 at cdkd drift --accept + redeploy, which does put the index back on the previous side. Integ: the dynamodb-globaltable fixture gained an unconditional GsiRecoveryTable (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-junk renders GlobalSecondaryIndexes as an unfolded Fn::Join over the region pseudo-parameter (Token.asAny is what gets it past the generated L1 validator) so cdkd resolves it to a STRING and RECORDS the junk block, then gsi-state-recovery corrects 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, new tests/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 dropped AccessLogSettings / ClientCertificateId / DeploymentId / RouteSettings and Route dropped ApiKeyRequired / 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 ride CreateStage / UpdateStage / CreateRoute / UpdateRoute as exact-spelling pass-throughs, with the matching reverse map in readCurrentState; PROPERTY_COVERAGE_BY_TYPE reports an empty silentDrop for 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: UpdateStage MERGES, so a stage keeps its AccessLogSettings verbatim 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, so update() now issues DeleteAccessLogSettings on removal, DeleteRouteSettings per DROPPED route key, and DeleteRouteRequestParameter per dropped parameter key — the same out-of-band shape DeleteCorsConfiguration already had on ::Api, and each fires even when no Update*-able field changed. The probe also CONFIRMED the two sentinels the review flagged as unverified: UpdateStage accepts ClientCertificateId: '' and clears it, and UpdateRoute accepts an empty-string RequestModels value and clears the model, so clearableUpdate / mapWithRemovals are right for those. ApiKeyRequired resets to the CFn default false. Only DeploymentId and the two selection expressions stay pass-through — no delete API, no documented sentinel. Integ: the apigatewayv2-update-removal fixture 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 rejects RequestParameters anywhere 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 carries AccessLogSettings (against a real log group) plus RouteSettings, whose members are written in PascalCase on purpose: CfnStage.routeSettings is typed any, 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 RETAINED RouteSettings sibling 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 with AutoDeploy off, and AWS::ApiGatewayV2::Deployment is not a registered cdkd type) and RequestModels (needs AWS::ApiGatewayV2::Model, also unregistered) stay unit-test-only, recorded in the fixture README. AWS::ApiGatewayV2::Api FailOnWarnings is NOT part of this batch — it was already unhandledByDesign (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, new tests/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 neither readConfigString (which needs a string read to reach its rule 2) nor requireConfigArray (which needs a list) ever fired on them. Before: a present-but-non-OBJECT lifecycle Filter (Filter: 'logs/', an array, an unresolved intrinsic) indexed EVERY member probe in gatherScope — Prefix / TagFilters / ObjectSizeGreaterThan / ObjectSizeLessThan — to undefined, so the rule kept NO location scope and fell through to the empty-prefix V2 Filter, 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 analytics StorageClassAnalysis indexed the DataExport probe to undefined and shipped StorageClassAnalysis: {}, 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 new requireConfigObject(value, path, options?) in config-shape.ts — the object-shaped sibling of requireConfigArray, same overloaded onUnusable state-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-rule Filter has the identical shape, and its fall-through arm emits Filter: {} — 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 falsy Filter: '' skipped the branch entirely and fell to the top-level-Prefix arm; the gate is now != null. applyReplicationConfiguration gained the onUnusable parameter and both call sites thread it (update always warns, create passes replayOnUnusable). The StorageClassAnalysis.DataExport container was previously refused only INDIRECTLY, by the readConfigString(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's isPlainPrefixOnly gate was hardened from !== undefined to != null to match, so an explicit Filter: null means "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-ITEM config containers of the five appliers — intelligent tiering, inventory and the lifecycle rules already refuse a non-object item through an existing readConfigString(config | rule, …), while metrics and analytics read only Id off the item, whose absence makes PutBucket{Metrics,Analytics}Configuration reject 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 on requireConfigObject itself) pin refusal / warn-skip / valid-and-absent-still-apply / the array-vs-object case a bare typeof === 'object' check would wave through; reverting either guard fails the suite.
  • A malformed non-array TagFilters is 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, new tests/unit/provisioning/s3-bucket-provider-tagfilters-shape.test.ts. Before: all three S3 per-item filter builders (metrics / analytics / intelligent-tiering) read TagFilters with a blind cast, so a present-but-non-array value (a single tag OBJECT, an unresolved intrinsic, a string) read as ZERO predicates via ?.length and the Filter block 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: requireConfigArray accepts the same onUnusable state-replay downgrade #1556 gave readConfigString (overloaded so existing throw-only callers keep their non-undefined return type), and all four S3 sites validate the container: REFUSE on a template-path create; WARN and skip on the replay-reachable paths (replayingState create, 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, matching requireConfigArray's existing contract (element garbage inside a real array still fails loudly at S3). 24 new unit tests (21 provider-side + 3 on requireConfigArray itself) 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 === undefined compares to == null — an explicit TagFilters: null means "no entries" per the list-block contract and used to crash .length with a raw TypeError; the sibling malformed-Filter-CONTAINER class (one level up) is follow-up issue (#1581).
  • A committed raw NUL made efs-provider.ts invisible to every grep/rg audit (issue #1587) — src/provisioning/providers/efs-provider.ts, new scripts/check-source-control-bytes.ts + tests/unit/scripts/source-control-bytes.test.ts. Before: the creationToken hash separator at line 435 was written as a LITERAL U+0000 byte rather than the \0 escape. Identical at runtime, so typecheck / lint / format / tests all passed — but grep and rg classify a NUL-carrying file as BINARY and skip its contents, and the system grep here is ugrep, which omits GNU grep's "Binary file matches" notice entirely. So grep -rn '<term>' src/provisioning/providers/ returned zero hits from this provider, with no warning and exit 0. That is not hypothetical: a /work-issues pass audited the #1160 SUSPECT row for this file, got zero hits for ThroughputMode, 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 diff were 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 \0 escape — byte-identical, so the cdkd-${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.gif is 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 into efs-provider.ts fails the sweep naming efs-provider.ts:435 ... offset 17230, reproducing the original defect exactly.
  • AWS::EC2::Route refuses a multi-destination template instead of silently narrowing it (issue #1566) — src/provisioning/providers/ec2-provider.ts, new tests/unit/provisioning/ec2-route-multi-destination.test.ts. Before: createRoute picked the destination via DestinationCidrBlock || 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 with InvalidParameterCombination. 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 a ProvisioningError naming 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 via CreateContext.replayingState, mirroring the SecurityGroupIngress idiom in the same create() switch) and update()'s delete-and-recreate, which cdkd rollback / drift --revert also 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, new tests/unit/provisioning/s3-bucket-provider-filter-predicates.test.ts. Before: applyAnalyticsConfigurations / applyIntelligentTieringConfigurations chose the wire shape from the COUNT OF PREDICATE KINDS (prefix present? + tags present?), so TagFilters: [A, B] with no Prefix counted as one kind, fell into the single-Tag arm, and sent Filter = { Tag: A } — every tag after the first SILENTLY DROPPED (found by the PR #1574 review while spot-checking the AnalyticsConfigurations.TagFilters terminal rename against its write site). applyMetricsConfigurations had the same class in a different arm: Prefix was tested FIRST in an else-if chain, so Prefix + TagFilters (or Prefix + AccessPointArn) sent Filter = { Prefix } alone and dropped the other predicate(s); it also sent And: { Tags: [] } for a bare empty TagFilters: []. Now: all three builders count PREDICATES — more than one routes through the And operator, 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]} and And{Prefix,AccessPointArn} (real access point) on PutBucketMetricsConfiguration. The readCurrentState reverse maps already read the And members 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 KMSKeyId removal 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 a PendingDeletion orphan 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, so PendingDeletion IS 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-associate since #958, plus propagation-races-2 and s3-vectors). Both issues are therefore closed with an ordinary per-run key and a PendingDeletion assertion — 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 back KmsKeyId: null (CFn RESETS), UpdateTrail with 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), and KmsKeyId: '' was ACCEPTED and cleared it (so '' is the payload). update() now routes the field through the same clearOnUpdateRemoval shape as SnsTopicName. The probe also disproved the in-code note claiming UpdateTrail rejects an empty string with KmsKeyId is not in valid ARN format — the last field that note covered, after #1160 had already disproven it for SnsTopicName / CloudWatchLogsLogGroupArn. IsOrganizationTrail is 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 for KMSKeyId, and the aggregate one-call reset. Integ: the cloudtrail-trail fixture gains a per-run key whose reference (not the key itself) is dropped in the CDKD_TEST_REMOVAL phase, so phase 2 is a property removal on a LIVE trail; phase 1 captures the live KmsKeyId and 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.ReplicaKmsKeyID and its required partner SourceSelectionCriteria.SseKmsEncryptedObjects — wired by #1495 but unit-only — are now read back off the live bucket in s3-replication-and-filter. Neither bucket's default encryption is switched to SSE-KMS: SseKmsEncryptedObjects is a source-object FILTER and needs no bucket-level change, while adding BucketEncryption would widen the drift comparison surface (the calculator compares arrays WHOLESALE, so an AWS-defaulted BucketKeyEnabled member would read as drift) for no extra coverage. Declaring SseKmsEncryptedObjects also retires that fixture's own phantom-drift hypothesis — assert_drift_clean was 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.Owner stays unit-only and is NOT a follow-up: Owner: Destination is 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, new tests/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: handledProperties listed only Name / 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 through CreateGraphqlApi / UpdateGraphqlApi (AdditionalAuthenticationProviders, ApiType, EnhancedMetricsConfig, IntrospectionConfig, LambdaAuthorizerConfig, MergedApiExecutionRoleArn, OpenIDConnectConfig, OwnerContact, QueryDepthLimit, ResolverCountLimit, UserPoolConfig, Visibility) plus EnvironmentVariables through its own PutGraphqlApiEnvironmentVariables API pair, with a matching reverse map in readCurrentState so the new properties cannot become permanent phantom drift. PROPERTY_COVERAGE_BY_TYPE now reports an EMPTY silentDrop for 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 flip openIdConnectConfig / 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 into NESTED_KEY_TARGETS (freshObjectMapper: true, keyStyle: 'lower-first', measured at 33 audited paths / 0 findings, with Tags.Key / Tags.Value allow-listed because AppSync models tags as a flat Record<string, string> rather than CFn's [{Key, Value}] list). (2) The nested UserPoolConfig under an ADDITIONAL auth provider is a DIFFERENT AWS shape (CognitoUserPoolConfig, no defaultAction) from the top-level one, so the two have separate mappers. (3) ApiType / Visibility are create-only on AWS (UpdateGraphqlApi has no member for either) while the registry schema ships an EMPTY createOnlyProperties list — so the create-only-properties.ts fallback classifies NOTHING and the change would have been routed to an in-place UPDATE that silently drops it; both are now hand-authored replacementProperties in ReplacementRulesRegistry, with a defense-in-depth ResourceUpdateNotSupportedError in the provider. Removal semantics were measured, not assumed: AppSync treats an OMITTED UpdateGraphqlApi member 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 and MergedApiExecutionRoleArn, which have none, WARN with a destroy-and-redeploy pointer instead of no-op-ing silently. One pre-existing bug the fixture surfaced live: every update() path in this provider returned attributes: {}, and the deploy engine resolves result.attributes ?? currentResource.attributes — so a PRESENT-but-empty object wiped the create-time ApiId / Arn / GraphQLUrl and the stack's GraphQLApiUrl output 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: the appsync fixture gained a verify.sh with baseline / CDKD_TEST_UPDATE / CDKD_TEST_REMOVAL / destroy phases that read every value back off GetGraphqlApi + GetGraphqlApiEnvironmentVariables (a green deploy proves nothing here), asserts provisionedBy == sdk as 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-level UserPoolConfig (needs Cognito as the PRIMARY auth mode), MergedApiExecutionRoleArn (needs ApiType: MERGED) and Visibility: 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: EnvironmentVariables rides a SEPARATE API that can only run after CreateGraphqlApi succeeds, so a failure there threw before create() 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, and AdditionalAuthenticationProviders goes through the shared requireConfigArray); a malformed EnvironmentVariables was 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 replays update() 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 --revert would act on. Two review points were deliberately NOT taken and are recorded in code: UserPoolConfig.DefaultAction is 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-op UpdateGraphqlApi on a removal-only redeploy.
  • AWS::CloudTrail::Trail EventSelectors removal 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 ride UpdateTrail; EventSelectors has its own PutEventSelectors call and its branch fired the Put only if (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 an InsightSelectors branch 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 with EventSelectors: [{ReadWriteType: WriteOnly, IncludeManagementEvents: true}] and then UPDATE'd through real CloudFormation with the property REMOVED reads back as ReadWriteType: All — CFn CLEARS the custom selector to the default a selector-less trail has. And the wire shape is NOT the sibling's: PutEventSelectors with EventSelectors: [] 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 removed IsLogging (desired undefined, previous false) compared as a change and took the else arm, silently issuing StartLogging — unreachable from a valid template (IsLogging is CFn-required) but reachable from a rollback / drift --revert replay of a partial state record, so the branch now requires a defined desired value. The integ fixture gains the WriteOnly baseline (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-removal cdkd diff --fail no-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, regenerated docs/_generated/nested-key-coverage.{json,md}. Before: a CFn nested key with no same-spelled SDK member was classified provider-handled whenever 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 / TransitionInDays were 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 declared terminalRenames entry 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 reviewed passes: ['key'] allow entries for conversions no walk can see (S3 presence-encoded EventBridgeEnabled, S3 lifecycle destructured-gatherScope TagFilters, CloudFront legacy CustomOrigin.OriginSSLProtocols). Red-direction proofs added per the checker rules: stripping the analytics Filter.And write flips AnalyticsConfigurations.TagFilters to no-sdk-member while 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 cdkd skill 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 — the cdkd 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-run first, keep the default --older-than 30d age guard; also added to the destructive-guard list), and orphan (ownership paragraph distinguishing per-resource cdkd orphan <stack/ConstructPath> from whole-stack cdkd state orphan <stack>, both leaving AWS resources intact, paired with removing the construct from the app). Command reference gained drift + gc --dry-run. Verified: plugin loads as 0.2.0 via real claude --plugin-dir (on-invoke ~4.4k tok), no Japanese, new doc link returns 200.
  • Distributable cdkd skill 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-scoped use-cdkd skill, so end users needed a full repo clone plus claude --add-dir every session, and non-Claude agents had no install path at all. Now: the end-user guidance ships as a standalone cdkd skill 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, and npx 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-level skills/ symlink). The repo-internal use-cdkd skill 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-dir remains documented as the contributor path. Distributed-skill links were converted to absolute GitHub URLs (all live-checked 200). Verified: real claude CLI end-to-end — plugin marketplace add (local path) + plugin install cdkd-skills@cdkd + inventory check (1 skill, ~90 tok always-on) — and real npx skills add --skill cdkd (installs exactly the distributed skill; the bare form lists all 14 repo skills, which is why the README pins the --skill form). npm-package contents are unaffected (files allowlist excludes plugins/), and chore: commits do not trigger semantic-release.
  • The last top-level ?? defaulting site: DynamoDB GlobalTable BillingMode (issue #1513) — src/provisioning/providers/dynamodb-globaltable-provider.ts, src/provisioning/config-shape.ts (header), new tests/unit/provisioning/dynamodb-globaltable-provider-billing-mode-shape.test.ts, .claude/rules/code-layout.md. The gap: PR #1524 decided every top-level properties['X'] ?? 'default' site per site and shipped all but one — dynamodb-globaltable-provider.ts was owned by a parallel lane at the time, so BillingMode ?? 'PAY_PER_REQUEST' kept substituting on a present-but-unusable value. That default is the consequential direction: a BillingMode: null (an Fn::If the resolver could not resolve, a hand-authored L1 typo) on a template that says PROVISIONED created an ON-DEMAND table, and every WriteProvisionedThroughputSettings / per-GSI ProvisionedThroughput block 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 through requireConfigString(properties['BillingMode'], 'PAY_PER_REQUEST', …, replayWarn(this.logger, context)) and the update() read through the same helper with an unconditional onUnusable warn. Three per-site decisions follow #1524's recorded precedent verbatim: BillingMode is ENUM-valued so it does NOT take coerceNumber (a number there is a template bug, not the unquoted-YAML scalar CFn coerces); the create refusal downgrades to a warning under CreateContext.replayingState, which required adding the optional context parameter create() did not previously declare, because rollback-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 replays update(…, previousState.properties, …) and a refusal there would leave the table UN-ROLLBACKABLE. The previousProperties sibling stays unguarded for the same state-side reason #1493 recorded. One thing the review of the sibling guard surfaced: the read sits OUTSIDE create()'s try, so the refusal is wrapped in a ProvisioningError exactly as the StreamSpecification guard 70 lines below already is — without it an untyped Error would escape into the deploy engine's retry loop. 14 unit tests cover the five malformed shapes, the typed wrap, the absent-key default, a PROVISIONED pass-through that positively asserts the provisioned block survived the billing gate, both replayingState polarities, and the update-path warn plus the unguarded previous side.
  • AWS::Route53::HostedZone HostedZoneTags + QueryLoggingConfig removal resets (issue #1160 route53 batch) — src/provisioning/providers/route53-provider.ts, new tests/unit/provisioning/route53-provider-removal-resets.test.ts, tests/integration/route53/{lib/route53-stack.ts,verify.sh}. The gap: both entries on the umbrella's route53-provider.ts SUSPECT row were REMOVAL paths the provider had no way to express. applyHostedZoneTags only ever sent AddTags, so a tag dropped from the template stayed on the zone forever — and its tags.length === 0 early return made clearing ALL tags a silent no-op. applyQueryLoggingConfig returned early on an absent block, so removing QueryLoggingConfig left the live config in place: query logging kept writing to CloudWatch — and kept billing — while the template said otherwise and cdkd diff reported 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 threads previousProperties into both helpers, which is what turns an add-only apply into a diff. Tags send RemoveTagKeys for the previous-minus-desired key set alongside AddTags in ONE ChangeTagsForResource call (computing removals that way guarantees no key lands in both lists); query logging routes an absent desired block to the provider's existing deleteQueryLoggingConfigForZone, gated on the PREVIOUS side having carried a config so an ordinary hosted-zone update does not pay a ListQueryLoggingConfigs probe per deploy. Both helpers keep previousProperties OPTIONAL, so create() 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 validates Value as well as Key, since Add and Remove share one ChangeTagsForResource call 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 own readHostedZone emits for "no live config", which cdkd drift --revert feeds 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 with cdkd diff clean — 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 a CdkdError through rather than re-labelling it, which the update-wrap-coverage critic 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 the previousProperties arguments fails 3 tests, disabling the lossy-read guard 2, reverting the removal throws 2, re-classifying {} as malformed 1). The route53 integ fixture gained a CDKD_TEST_REMOVAL phase (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::Project BuildBatchConfig removal 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 14 UpdateProject fields, but a live CFn A/B (2026-08-10, NO_SOURCE project) showed CloudFormation itself RETAINS Description / TimeoutInMinutes / QueuedTimeoutInMinutes / ConcurrentBuildLimit / AutoRetryLimit / Cache / LogsConfig through a real removal update — CFn's own handler omits them from UpdateProject and 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, cloudWatchLogs ENABLED, all probed alone), which is exactly what makes resetting them an easy and wrong change. The one real gap: BuildBatchConfig came back null after 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 — so update() now routes the property through clearOnUpdateRemoval with 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 / BadgeEnabled need 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: new codebuild-project fixture with a CDKD_TEST_REMOVAL phase 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 emits Cache: { 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 uses addPropertyDeletionOverride('Cache') to make phase 2 a true removal.
  • AWS::CloudTrail::Trail removal resets (issue #1160 cloudtrail batch) — src/provisioning/providers/cloudtrail-provider.ts, src/deployment/retryable-errors.ts. Before: UpdateTrail is a merge-semantics API — a live probe with only Name + S3BucketName left 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 next cdkd diff said "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, and update() 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 / CloudWatchLogsRoleArn kept 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 claimed UpdateTrail rejects an empty string for ARN-shaped fields; the probe shows '' is ACCEPTED for SnsTopicName and CloudWatchLogsLogGroupArn and nulls them out, which is what makes the string resets possible. The IncludeGlobalServiceEvents reset is GUARDED on the effective IsMultiRegionTrail: 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 retained IsMultiRegionTrail: true would manufacture a hard failure on a combination CFn was never measured on. retryable-errors.ts gains the CloudTrail Verify in IAM that the role has adequate trust relationships IAM-propagation pattern: CreateTrail validates 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: new cloudtrail-trail fixture with a CDKD_TEST_REMOVAL phase 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 — KMSKeyId and IsOrganizationTrail could NOT be A/B'd (a customer-managed KMS key's 7-day minimum deletion window would orphan a PendingDeletion key every run; an org trail needs an Organizations management account), so both stay pass-through, recorded as unmeasured rather than guessed.
  • AWS::ElasticLoadBalancingV2::TargetGroup HealthCheckPort removal resets to traffic-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's SslPolicy, 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 / UnhealthyThresholdCount and SslPolicy all kept their customized values through a real CFn removal update — so cdkd's pass-through (absent -> undefined -> ModifyTargetGroup / ModifyListener merge) was ALREADY CFn parity for those, and they are now pinned by negative tests rather than "fixed" into a divergence. The one real gap: HealthCheckPort is the single field CFn resets, to its create default traffic-port; update() now mirrors that via clearOnUpdateRemoval. 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: the alb fixture gained a baseline HealthCheckPort: 8080 + a CDKD_TEST_REMOVAL phase (run 2026-08-10: reset to traffic-port live-verified, destroy 16 deleted / 0 errors, 0 orphans).
  • AWS::EFS::FileSystem throughput removal resets provisioned mode to bursting (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 a ThroughputMode removed 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' when ThroughputMode + ProvisionedThroughputInMibps are both removed (provisioned@1MiBps -> bursting, provisioned value cleared). Before: update() skipped UpdateFileSystem entirely 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 sends ThroughputMode: 'bursting' explicitly. Removing ONLY ProvisionedThroughputInMibps while keeping ThroughputMode: '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-standalone gained a provisioned@1MiBps baseline + a CDKD_TEST_REMOVAL phase asserting the live in-place reset to bursting.
  • AWS::SNS::Topic DeliveryStatusLogging protocol canonicalization: the whole HTTP family folds onto the one prefix AWS accepts (issue #1529) — src/provisioning/providers/sns-topic-provider.ts. Before: normalizeDeliveryStatusProtocol had two pre-existing gaps that made the entire HTTP-family protocol unusable. (1) The canonical CFn / CDK L2 spelling 'http/s' — what sns.LoggingProtocol.HTTP and the CFn schema's allowed-value list both emit, so what real templates actually carry — hit the switch's default and threw unsupported DeliveryStatusLogging protocol "http/s" at create/update time. (2) The 'https' spelling it did accept mapped to an HTTPS prefix, producing attribute names like HTTPSSuccessFeedbackRoleArn that do not exist — SetTopicAttributes rejects them with InvalidParameter: Invalid parameter: AttributeName — and the reverse map in readCurrentState probed 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 to HTTP, HTTPS is dropped from SNS_DELIVERY_STATUS_PROTOCOLS (which holds AWS attribute PREFIXES), and the fail-fast error lists the accepted TEMPLATE spellings via the new SNS_DELIVERY_STATUS_PROTOCOL_SPELLINGS (application, firehose, http/s, lambda, sqs) rather than the prefixes — listing prefixes would have told users to write HTTP, which the CFn schema does not accept. The existing stateProtocolCaseMap mechanism carries the state-recorded spelling through the reverse map unchanged, so a topic recorded as http/s reads back as http/s and no phantom drift fires on the respelling. Folding the family made a same-prefix collision reachable for the first time (a template declaring both http/s and https); it WARNS and lets the last entry win rather than throwing, because this function's throw side is also fed by the rollback executor's update() 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 carrying Protocol: http/s produced HTTPSuccessFeedbackRoleArn / HTTPSuccessFeedbackSampleRate / HTTPFailureFeedbackRoleArn on the live topic; SetTopicAttributes with HTTPSSuccessFeedbackRoleArn on that same topic was rejected. Tests: new tests/unit/provisioning/sns-topic-provider-protocol-canonicalization.test.ts (every HTTP-family spelling folds to HTTP; no input ever yields HTTPS; 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 the HTTPS prefix 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 a startsWith('HTTPS') prefix, since HTTPSuccessFeedbackRoleArn also starts with those five characters — the collision that made this bug subtle. Integ: sns-sqs-event's delivery-status topic gains a second, http/s protocol entry with Phase-1 assertions that it lands under HTTP with no HTTPS* 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 three Update*Namespace change objects MERGE (an absent field keeps the live value) and each update method gated on properties['Description'] !== undefined / a desired-side-only SOA extraction, so a Description (all 3 namespace kinds) or Properties.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 shared clearOnUpdateRemoval with the '' clear sentinel (raw-SDK probed: UpdateHttpNamespace accepts '' and GetNamespace then omits the field), and the new resolveSoaTtlChange() 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::Service Description stays 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-namespaces gained baseline-Description assertions + a CDKD_TEST_REMOVAL phase asserting both Descriptions cleared and the SOA TTL reset to 60 in place (no replacement).
  • AWS::SNS::Topic DeliveryStatusLogging removal resets (issue #1160 sns batch) — src/provisioning/providers/sns-topic-provider.ts, src/deployment/retryable-errors.ts. Before: SNS SetTopicAttributes is per-attribute merge, and update() iterated only the DESIRED DeliveryStatusLogging list — so removing the whole property (or a sub-field / protocol entry) silently kept the live per-protocol feedback attributes, and a SuccessFeedbackSampleRate of 0 was truthiness-skipped on create and update. Now: the new shared buildDeliveryStatusAttributeMap() 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 '' and SuccessFeedbackSampleRate reset 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 a 0 sample 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.ts gains the SNS is not a valid role to allow SNS IAM-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-path 0 sample rate) + the retryable-errors pattern pin. Integ: sns-sqs-event gained a delivery-status topic + feedback role and a CDKD_TEST_REMOVAL phase 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 nonexistent HTTPS attribute prefix) — since RESOLVED, see the #1529 entry above.
  • AWS::S3Express::DirectoryBucket Tags becomes a handled property (issue #609 batch) — src/provisioning/providers/s3-directory-bucket-provider.ts, new dep @aws-sdk/client-s3-control. Before: Tags was in the type's silent-drop set, so a tagged directory bucket needed --allow-unsupported-properties and 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 forwards Tags on CreateBucketConfiguration.Tags (the only create-time tag write the s3express split supports; PutBucketTagging is documented not-supported for directory buckets); update applies the tag diff via S3 Control TagResource / UntagResource (additive-only semantics compensated — removed keys, including whole-property removal, are explicitly untagged); readCurrentState surfaces Tags via ListTagsForResource for drift. Both deferred items recorded on #609's 2026-08-02 note ship in the same change: (1) the standard-bucket deleteBucketWithEmptyRetry bounded 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) the s3-directory-bucket integ 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 a CDKD_TEST_UPDATE=true tag-mutation phase (TagResource value change + UntagResource removal, asserted via aws 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-coverage pinned no-aws set updated (the provider's update() 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, new tests/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 no context parameter at all, so the REPLAYING_STATE_CREATE_CONTEXT the rollback executor's reverse-replacement arm passes as the 4th argument was silently ignored and buildDeliveryStatusAttributeMap'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-path AuthType and the GlobalTable update-path StreamSpecification / GlobalSecondaryIndexes guards 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_IMAGES re-points a live stream, and an empty GSI list reads as "delete every index". After: the downgrade is per site — keep the PREVIOUS AuthType (omitting the field entirely when the previous side is unusable too, since UpdateFunctionUrlConfig has merge semantics and retains the live value), SKIP the StreamSpecification block, and SUPPRESS the GSI diff. The GSI helper's onUnusableIndexes hook 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 against BillingMode: null, reads a flip, and sends a same-mode UpdateTable that 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 the DescribeTable each update() 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).
  • readCurrentState hid a console-side CloudWatch Logs enable on AWS::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: the CloudWatchLogsLogGroupArn / CloudWatchLogsRoleArn pair 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 to cdkd drift forever. 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 which drift --revert of 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 via drift --accept, re-enables it out-of-band, and requires cdkd drift to 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 (a null survives 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 through emptyToUndefined for 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, new tests/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 of AWS::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 in handledProperties, 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 empty SimplePrefix: {} is preserved, since its PRESENCE is the signal), the four ReplicationConfiguration.Rules.Destination blocks via a new buildReplicationDestination helper — AccessControlTranslation (cross-account replication kept SOURCE ownership), EncryptionConfiguration.ReplicaKmsKeyID (replicas were not encrypted with the declared key; CDK's aws-s3 L2 emits this for a replicationRules entry with a KMS key), and ReplicationTime + Metrics (a template asking for S3 Replication Time Control — a billed SLA — got plain asynchronous replication) — plus rule-level SourceSelectionCriteria (replica-modification / SSE-KMS-object selection), LifecycleConfiguration.TransitionDefaultMinimumObjectSize (it sits on the PutBucketLifecycleConfiguration REQUEST, not inside LifecycleConfiguration, which is why the rules-only mapper never reached it), and BucketEncryption…BlockedEncryptionTypes (a bucket kept accepting the SSE-C it declared it would refuse). The matching readCurrentState reads land in the same change, because a field cdkd SENDS but does not READ becomes permanent phantom drift that cdkd drift --revert then re-applies forever; TransitionDefaultMinimumObjectSize is read back UNCONDITIONALLY. The first cut filtered out varies_by_storage_class as "the account default" and review caught the polarity inverted — AWS defaults buckets created after September 2024 to all_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: declaring AWS::S3::Bucket's segmentRenames and 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, opting AWS::CloudFront::Distribution into the write-evidence pass at 0 findings (issue #1475) — scripts/gen-nested-key-coverage.ts (the fourth recognizer + the CloudFront target opt-in + two Tags.* allow-list entries), tests/unit/scripts/gen-nested-key-coverage.test.ts, docs/_generated/nested-key-coverage.{json,md} regenerated. The shape: CloudFrontDistributionProvider.convertToSdkFormat seeds const 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-be same-spelling paths 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 subsequently deletes off the binding, resolved through the Object.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, and deliversWholeBlob now 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.Value allow-listed with passes: ['write'] — genuinely written by toSdkTags, 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 the IPV6Enabled -> IsIPV6Enabled rename entry still exits 1 (key pass, case-divergence); inserting delete result['Aliases'] exits 1 naming the path (the delete exclusion live); an unresolvable delete result[dynamicKey] exits 1 (fail-closed refusal); replacing the seed with {} exits 1 via the minHandoffPoints collapse 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 / DeleteTable no 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 its UpdateTable calls, and it waited on TableStatus between them — but a table is ACTIVE while one of its indexes is still CREATING / UPDATING / DELETING, and AWS then rejects the next call with Attempt to change a resource which is still in use: Index is being updated. The PAY_PER_REQUEST -> PROVISIONED flip 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 own cdkd destroy then hit the same rule from DeleteTable (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: the gsi-billing-flip step failed 3 runs out of 4 on 2026-08-10, and a run from a pristine origin/main worktree failed identically — so it is pre-existing and timing-sensitive, not a regression from the branch that surfaced it. The change: waitForTableActiveAfterUpdate now also requires that no index is in a transitional state, read off the SAME DescribeTable response it already fetched (no extra API traffic), and delete() waits for the indexes to settle before DeleteTable — 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 a DescribeTable per 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::GlobalTable warns 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}CapacityUnits run every value through toFiniteNumber, so a present-but-unresolvable member (an unresolved {Ref: …} / Fn::If, '', an object) returns undefined and both the table-level and per-GSI translations fall through to DEFAULT_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 requires ProvisionedThroughput on the table and on every GSI of one. The change: warn-and-default. A DECLARED-but-unreadable member now produces a ThroughputDiagnostic on the #1503 channel naming the member and the substituted default, from the three places the value is actually consumed — derivePerCallProvisionedThroughput (create, and the PAY_PER_REQUEST -> PROVISIONED flip), the per-GSI PROVISIONED branch of toSdkGlobalSecondaryIndexes, and toSdkReplicaThroughputOverrides (whose message reports SUPPRESSION instead of a default, because an absent ProvisionedThroughputOverride means "inherit the source table"). The blamed member is re-derived in the same order the derivation consults members, so a flip names SeedCapacity while a create names MinCapacity (#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-shaped ProvisionedThroughput stands in for (that value IS what reaches AWS); the table-level value while the billing mode holds still (nothing sends a table-level ProvisionedThroughput outside 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 dedicated dynamodb-globaltable fixture table whose READ capacity is an Fn::Join over 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 an addPropertyOverride of the block hits the same validator) and resolves at DEPLOY time to us-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 through update() 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 naming MinCapacity for a value the failing call never consulted; the ThroughputDiagnostic JSDoc no longer claims unresolved-member always means "nothing was sent"; and derivePerCallProvisionedThroughput gained the Array.isArray guard its sibling translator already had, since it now routes a user-facing message.
  • cdkd drift no longer reports an IAM principal's ARN and its AROA… unique id as drift (issue #1515) — new src/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-time observedProperties capture stores whichever form the read happened to return, so a later cdkd drift compared two spellings of ONE principal on AWS::S3::BucketPolicy's PolicyDocument.Statement[].Principal.AWS and reported drift; --revert wrote 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 CDK autoDeleteObjects custom-resource role is created concurrently with the bucket policy referencing it, which is why two back-to-back drift-revert fixture 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 alongside drift-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 only Principal / NotPrincipal positions, where the forms are interchangeable — a unique id in a Condition value is left alone, where they are not) and one iam:GetRole / GetUser lookup goes through an injected resolver that drift.ts supplies and caches per ARN for the whole command. Direction 2 (declaring the path in getDriftUnknownPaths) was rejected for the reason the issue gives and drift-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 missing iam:GetRole permission 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 / GetUser are ACCOUNT-LOCAL and take a NAME, not an ARN, so arn:aws:iam::<other-acct>:role/Foo and arn:aws:iam::<self>:role/prod/Foo both resolve to this account's root-path Foo — 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 missing iam:GetRole surfaces as unexplained permanent drift, visible only under --verbose), folded the two collection walks into one, and recorded that --accept now 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, the GetUser / AIDA branch, 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, new tests/unit/provisioning/toplevel-config-defaults.test.ts, .claude/rules/{providers,code-layout}.md. The gap: #1493 rolled readConfigString across 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: a null / mis-nested / unresolved-intrinsic VALUE silently became a default the template never asked for, and at AWS::ApiGateway::Method AuthorizationType that default is NONE, i.e. a PUBLIC method. The change: requireConfigString takes an optional ConfigStringOptions, and each site was decided on its own rather than swept. coerceNumber stringifies 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: 5 is still refused). onUnusable warns and defaults instead of throwing, used at UPDATE-path sites ONLY: rollback-executor.ts replays a rollback via provider.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 (the update-refusal-breaks-rollback-replay lesson, applied pre-emptively rather than caught in review). Guarded on create: API Gateway AuthorizationType, EC2 InstanceType / EIP Domain / SecurityGroupIngress IpProtocol, IAM access-key Status, Lambda event-invoke Qualifier, RDS DB-proxy TargetGroupName; the latter three also warn on update. Left unguarded, with the reason in-code rather than invisible: EC2Provider.buildIpPermission's IpProtocol — textually a top-level read, but the helper is also reached from deleteSecurityGroupIngress and 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 recorded IpProtocol is null). Every delete() / readCurrentState read stays unguarded for the same state-side reason. Left open by this PR and closed by the follow-up below: the GlobalTable BillingMode site, whose file was owned by a parallel lane.
  • drift --revert preserves 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: buildRevertNewProperties overwrites each drifted top-level key wholesale, so for Tags AWS ends up with exactly what cdkd state recorded. ECS attaches AmazonECSManaged to 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: Tags is template-DECLARED (any CDK ASG carries a Name tag) 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 string Key), an AWS-side entry the baseline lacks is kept IFF its key is service-managed — AmazonECSManaged or any aws:-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 --revert plan 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 the drift-revert integ at its final assertion — that fixture injects an IntegInjected tag and requires --revert to 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 two AuthType sites 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 new tests/unit/provisioning/dynamodb-globaltable-provider-stream-spec-shape.test.ts. The gap: #1471 / #1490 rolled readConfigString across (cfg['K'] as string) || 'default'; ?? 'default' fires on the same undefined a malformed container indexes to — and on an explicit null besides. 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 form as [A-Za-z]+\) \?\? ' (20 hits in providers/) is blind to every as 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 through readConfigString — CodeBuild Source / SecondarySources[] / Artifacts / SecondaryArtifacts[] / Environment.{Type,ComputeType} (a string Source: 'GITHUB' built a NO_SOURCE project; a string Environment silently downsized the compute type), DynamoDB GlobalTable StreamSpecification (a string container invented a NEW_AND_OLD_IMAGES stream), Route 53 HostedZoneConfig on BOTH paths (create dropped the comment, update WIPED the live one — UpdateHostedZoneComment is a whole-value write), CloudFront OAI config on both paths, and ECS DeploymentController (a string container claimed the ECS rolling-update controller, under which cdkd sends parameters AWS rejects for a CODE_DEPLOY / EXTERNAL service). mapSource / mapArtifacts take a containerPath so a refusal names the right block. Two AWS::Lambda::Url AuthType sites are the #1471 sweep's OWN residual — its grep keyed on as string and that site casts to FunctionUrlAuthType, so a blank / null AuthType kept defaulting to 'NONE', i.e. a PUBLIC function URL — and now use requireConfigString. 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, so Source: '' still built a NO_SOURCE project — the gates are != null now, which is what .claude/rules/providers.md already required; and four sites threw the helper's plain Error outside any wrapping catch (CloudFront OAI create + update, ECS update, GlobalTable create), escaping untyped into the deploy engine's retry loop instead of surfacing as a ProvisioningError. A re-review of the fix-back itself then found the same class TWICE MORE, one level up: CodeBuild's SecondarySources / SecondaryArtifacts ARRAY containers were still truthiness-gated (a blank string was silently dropped; a truthy non-array died with a raw TypeError: .map is not a function), now guarded by a new requireConfigArray; and the GlobalTable StreamSpecification read on the UPDATE path was still unguarded while create refused the identical template, sending StreamViewType: undefined — the exact create/update asymmetry the rule exists to prevent. Not rolled, recorded in config-shape.ts's header rather than left invisible: the TOP-LEVEL properties['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 YAML IpProtocol: -1 is a NUMBER today; issue #1513); nested reads whose value is an identity key or a warning label and never reaches AWS; EC2's two VpcId ?? '' 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 in s3-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. No src/** 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, but resolveLiterals resolves mapped to the EMPTY seed and stops, so Configuration scoped to nothing and all three of its children reported no-write-evidence. That was a FALSE POSITIVE, and it is what kept AWS::CloudWatch::AnomalyDetector out of the pass; S3BucketProvider uses the same idiom in applyWebsiteConfiguration / applyObjectLockConfiguration / applyReplicationConfiguration. The change: collectWriteEvidence gained resolveBuilders + 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 by out.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's ContainerPortRange. Declaration identity required fixing declarationOf itself, 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-named const cfg builders in different if arms collapsed onto one declaration and merged their member sets (each vouching for the other's blob, the false-CLEAR direction), and a const cfg in a nested arrow declared textually first captured the enclosing function's own cfg, inverting both verdicts at once. const / let are BLOCK-scoped, so declarationOf now 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 from recordAt, i.e. only where a write takes the value, and those sites are already filtered by feedsOnlyComparison / 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::AnomalyDetector went 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::Bucket 125 -> 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 like WebsiteConfiguration.RoutingRules.RedirectRule -> the SDK's Redirect, plus plural-vs-singular per-item PUT APIs — segmentRenames work) and 20 never written anywhere in the file, i.e. confirmed-looking write-side silent drops: the LoggingConfiguration.TargetObjectKeyFormat family, the four ReplicationConfiguration.Rules.Destination blocks (AccessControlTranslation / EncryptionConfiguration / Metrics / ReplicationTime), Rules.SourceSelectionCriteria, LifecycleConfiguration.TransitionDefaultMinimumObjectSize and BucketEncryption…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 named readCurrentState* that uses the builder idiom previously contributed only its empty seed and now contributes a fully populated SCOPE (s3-bucket-provider.ts's readLifecycle, whose const out = {} is filled with CFn-spelled out['Id'] / out['Status']; ecs-provider.ts's volumesToCfn). Inert today — S3 is not opted in and ECS is lower-first, so a CFn-spelled terminal misses the exact compare — but S3 is exact-style, where a CFn-spelled reverse write vouches for the forward mapper verbatim, so widening REVERSE_MAP_FUNCTION_PREFIXES to 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. minWriteScopes stays 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 writing ExcludedTimeRanges" into "parser regression?" — the miscalibration minHandoffPoints already records for API Gateway v2. A recognizer collapse is not silent at 2: it re-surfaces the three paths as no-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, nested readCurrentState* helper with a non-reverse-named control that proves the branch load-bearing), the five bound-(8) pins (non-literal initializer, late-seeded let, whole-binding reassignment, Object.defineProperty onto 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 (deleting mapped.ExcludedTimeRanges = … exits 1 naming all three paths; deleting the hand-named EndTime: toDate(r['EndTime']) inside the value that assignment carries exits 1 naming ONLY Configuration.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 --revert stops 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: observedProperties is 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 standalone AWS::AutoScaling::LifecycleHook -> the ASG's LifecycleHookSpecificationList, standalone SG ingress/egress rules) was captured empty and later populated: permanent phantom drift on a fresh, untouched stack, and drift --revert then live-verifiedly DETACHED the capacity provider, deleted ALL lifecycle hooks (including the cdkd-managed sibling still in state), and stripped ECS's required AmazonECSManaged tag 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 existing ignorePaths; 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 binary drift exit 1 with 2 phantom resources; fixed binary reports only the CFn-parity Tags delta, and --revert's blast radius shrank to that declared key — capacity provider attached, hooks intact. The declared-Tags residual (revert strips the service-authored AmazonECSManaged entry) 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, and readCurrentState stops 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::GlobalTable carries 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 spelling Billing.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 synth fixture already carried the value with no assertion over it. Both halves now go into one OnDemandThroughput on CreateTable / UpdateTable, non-local replicas get {Provisioned,OnDemand}ThroughputOverride on their {Create,Update}ReplicationGroupMemberAction, and readCurrentState reverse-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 the DeletionProtectionEnabled block 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 own UpdateTable after 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), and readCurrentState synthesizes the LOCAL Replicas entry when DescribeTable omits 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 -1 sentinel, 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 — -1 is stored literally there and the empty-block form wedged a table in UPDATING for 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-shaped ProvisionedThroughput / OnDemandThroughput / *ThroughputOverride was 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 good WriteOnDemandThroughputSettings from the same template), and fired regardless of billing mode (a provisioned block on a PAY_PER_REQUEST table, which AWS rejects). mergeExplicitThroughputBlock now coerces with a tightened number | string predicate (bare Number() 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 on previousProperties, so a garbage value already in STATE made every subsequent update() throw including the deploy that would have removed it, and a throw from the replica path fired AFTER CreateTable had committed a real table (which DeletionProtectionEnabled: true then 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. collectRawOnDemandDeclarations became 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 classified modified, 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 not modified at all) and across a BILLING FLIP. It now fires from toSdkGlobalSecondaryIndexes, 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: readCurrentState wrote the raw SDK GlobalSecondaryIndexDescription[] into the CFn-shaped state key, so cdkd drift compared 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 in getDriftUnknownPaths) 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 and getDriftUnknownPaths stays EMPTY — the stopgap would have turned drift OFF for the entire GSI subtree. readAutoScalingSettings is generalized to the dynamodb:index:* dimensions through the shared autoScalingResourceId, so the read cannot spell the ResourceId differently from the register / deregister calls. #1421 (integ): the PAY_PER_REQUEST -> PROVISIONED flip 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 “GlobalSecondaryIndexUpdates alongside BillingMode in one UpdateTable” the unit suite stayed green and every such deploy failed in the field. A third fixture table plus a gsi-billing-flip mode 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 its Delete is issued later, so it too must carry throughput in the flip call); the surviving index and the table are pinned onto their SeedCapacity rather than MinCapacity, 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 explicit ProvisionedThroughput forwarded onto a PAY_PER_REQUEST table, and a present-but-unresolvable value forwarded raw (Number({Ref: …}) is NaN) 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 under tests/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. No src/** change — pure tooling / docs / fixtures. The gap: #1448 made the write-evidence pass path-scoped, but the FIXTURE stopped it one level short. nestedProperties is a flattened transitive closure per top-level property, so Environment.Type and Environment.EnvironmentVariables[].Type were literally the SAME audited path — measured then, deleting either one's write from a scratch copy of the real codebuild-provider.ts exited 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.mjs gained extractNestedPropertyPaths, emitting a nestedPropertyPaths capture ALONGSIDE the flattened one — full Top.A.B chains, $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 shared seenRefs set collapses), arrays transparent so EnvironmentVariables.Name matches the write side's envs.map((v) => ({ name: … })). Per-branch ancestry is combinatorial where the flattened walk was linear, so the capture is CAPPED (MAX_NESTED_PATHS_PER_PROPERTY 5000 / MAX_NESTED_PATH_DEPTH 12) with a NAMED throw: a diamond-shaped definition graph measured 786,430 paths at k=18, and processType runs the walk for every refreshed type. nestedKeyPathsForTarget reads the new capture; collectWriteEvidence keys its scope index by the write PATH and descends, so environment and environment.environmentVariables carry different type facts; 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 an Items hand-off reached 217 unrelated CloudFront members. reachableSdkMemberNames survives only as the minHandoffPoints parser floor. Acceptance, spawned through the --providers-dir= seam against a scratch COPY of the real tree (never src/): deleting type: ((environment?.['Type'] …)) exits 1 naming AWS::CodeBuild::Project: Environment.Type [no-write-evidence] with Environment.EnvironmentVariables.Type clean; deleting type: (v.Type ?? 'PLAINTEXT') exits 1 naming Environment.EnvironmentVariables.Type with Environment.Type clean. 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 CFn EFSVolumeConfiguration is SDK efsVolumeConfiguration and an exact parent match reported 16 members ecs-provider.ts demonstrably 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 unrelated name/Name-style scope pairs the same file carries. A genuine RENAME is out of the fold's reach: CFn ProxyConfiguration.ProxyConfigurationProperties is the SDK's ProxyConfiguration.properties, which would have re-broken the ECS opt-in PR #1484 had just landed, so the target declares a segmentRenames map applied to NON-TERMINAL segments only. It is STALENESS-FENCED like NESTED_KEY_ALLOW_LIST (findStaleSegmentRenames): --check fails 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-looking cloudWatchLogs), and the suppression follows a .map(v => ({ … })) callback because resolveLiterals does, while refusing to cross an opaque JSON.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 every minNestedKeys floor 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's minWriteScopes (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 — --check stays 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 is AWS::ECS::Service's DeploymentLifecycleHook.HookDetails moving scalar -> mixed in definitionShapes. Tests: 199 runtime cases, including the two inverted bound fences, the extractNestedPropertyPaths walker (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 --check probes for the two acceptance directions, the stale-rename exit and the missing-capture throw.
  • ResourceProvider.create gains an optional CreateContext, 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 calling create() with previousState.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. replayRollback catches 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 in state.json, editable only by hand. #1454 had already moved AWS::Glue::Table's IcebergTableInput refusal from create+update to create-only for exactly this reason on the update path (rollback-executor.ts calls provider.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 parameter context?: CreateContext on ResourceProvider.create, mirroring the DeleteContext that delete() has taken since the region-check work — a shape the codebase already establishes rather than a new idea. It is defined in src/types/resource.ts next to the ResourceProvider interface that consumes it, NOT beside DeleteContext in region-check.ts: that type earns its home there because its expectedRegion feeds assertRegionMatch, whereas CreateContext has 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 to DeleteContext so 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 shared REPLAYING_STATE_CREATE_CONTEXT constant 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.enforceIcebergTableInputAbsent is the only consumer today and warns on replay, reusing the existing shared message builder — a third create-replay lead 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 (whose UpdateTableCommandInput has no OpenTableFormatInput member 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 spelling IcebergTableInput the 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 spelling CreateIcebergTableInput the value IS sent and Glue rejects the call, failing that one operation. Either way the message names cdkd deploy with 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 five deploy-engine.ts sites (CREATE, the property-driven replacement, the --recreate-via-* destroy-then-create, the --replace delete-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 own update() (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's properties argument, which IS a state record during a rollback replay (rollback-executor.ts's revert arm, and drift --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, in rollback-executor.ts, in docs/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 inside update(). There is no live gap — reviewers verified that all five (plus iam-access-key and fsx-filesystem) validate required fields only, which CreateContext'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 85 implements ResourceProvider classes walked. 84 declare the plain 3-parameter create (4 of them with _-prefixed unused names) and are valid against an optional 4th; GlueProvider is the one that gained it. No overloads, no rest params, no class-property create, no Parameters<ResourceProvider['create']> extraction, no .length / arguments.length read, and no wrapper that forwards create with a fixed argument list (withRetry takes a zero-arg thunk, so it cannot drop arguments). The four TS-Compiler-API checkers were checked too: gen-handled-property-wiring and gen-nested-key-coverage seed their taint walks by parameter NAME (PROPERTY_BAG_PARAM_NAMES / HANDOFF_BAG_PARAM_NAMES), never by position, so a trailing context param contributes no evidence and both matrices are byte-identical; gen-update-wrap-coverage and gen-sdk-attr-coverage never inspect parameters. Tests: 8 added, 1 updated. The load-bearing one is that a reverse-replacement create of a Glue table whose STATE carries IcebergTableInput now SUCCEEDS with a warning and forwards the blob verbatim to CreateTableCommand — 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 asserts call[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: the create-replay lead cites #1454 as well as #1463, so the existing expectIcebergMessageBody helper 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 pin create to exactly 3 arguments on the --replace delete-first fallback (both its collided create-first and its post-delete re-create), the --recreate-via-* destroy-then-create, and the update-failure replacement — asserting call.length === 3 rather 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 the cdkd deploy fix-forward. (3) The replay path covered only the CFn spelling; it is now parameterized over both, matching the template-path coverage. Also: assertIcebergTableInputAbsent is renamed enforceIcebergTableInputAbsent (it can now warn and return, so assert contradicted the behavior), the createTable comment claiming the divergent member "is refused pre-flight just above" is corrected to describe what a replay actually forwards, and the helper's unused physicalId parameter — 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's HostedZoneFeatures.AcceleratedRecoveryStatus is transitioning (ENABLING / DISABLING / *_HOSTED_ZONE_LOCKED), Route 53 rejects EVERY mutation with HostedZone <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 a cdkd destroy issued during the window exited with per-record errors plus the knock-on zone-delete failure ("contains non-required resource record sets"), state preserved. deleteRecordSet now 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; *_FAILED and timeout surface as operator-recovery errors; same env-overridable poll knobs as the zone guard), then retries the change once. The message is also in RETRYABLE_ERROR_MESSAGE_PATTERNS (generic exponential cadence, not the dense IAM one) as the net for the create/update paths and sub-budget windows. The route53 integ fixture deliberately keeps racing the ENABLING window (deploy asserts ENABLING|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) and LoadBalancers[].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. convertPortMappings built a fresh SDK PortMapping naming five members and never wrote containerPortRange, so a dynamic host-port range registered with the range silently dropped; convertLoadBalancers never converted AdvancedConfiguration, 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 shared pascalToCamelCaseKeys hand-off (future CFn additions inherit). With the #1445 walk merged, BOTH ECS targets opt into the write-evidence pass in the same change (freshObjectMapper: true with 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. No src/** 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.convertLinuxParameters is the clean demonstration — return pascalToCamelCaseKeys(config) delivers Capabilities / Devices / Tmpfs / Swappiness / MaxSwap / SharedMemorySize / InitProcessEnabled and everything beneath them, all correctly wired and all invisible. That is why only AWS::CodeBuild::Project opted in, with 327 would-be same-spelling paths across the other targets sitting UNMEASURABLE. The fix teaches collectWriteEvidence the three-step walk gen-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, const bindings, spread-only literals, and this.f(…) / free-function / SIBLING-MODULE calls (pascalToCamelCaseKeys lives in agentcore-case-convert.ts, so loadReport supplies a same-directory resolveImportSource); DELIVERY — the callee RETURNS the blob and the value is written, with feedsOnlyComparison still refusing a diff-only literal. The collector gains handoffPoints, and the new expandGenericHandoffScopes folds each point into its scope by expanding it through the SDK model's own reference graph (reachableSdkMemberNames; collectSdkInterfaces now captures the ARRAY ELEMENT ref too, since LinuxParameters.tmpfs is Tmpfs[]). 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 — so pascalToCamelCaseKeys (whose only write is the computed result[camelKey]) qualifies while convertContainerDefinitions (45 named members), convertLoadBalancers (4) and CloudFront's spread-and-patch convertToSdkFormat (30+) are all rejected. The TRANSITIVE part is not tidiness: a body-local test accepts the DELEGATING GUARD convertLog(cfg) { if (!cfg) return cfg; return this.buildLog(cfg); }, because convertLog names nothing and the delivery test is existential — the return cfg arm satisfies it while buildLog does the member-naming work. Tightening delivery to .every is not the alternative fix (pascalToCamelCaseKeys returns result, 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, with glue-provider.ts's renameRecordKeys named as the shape that is one opt-in away. The credit is bounded to the BLOB rather than the enclosing scope: ContainerDefinitions carries both the LinuxParameters hand-off and convertPortMappings, 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-delete DistributionConfig: config (a config read straight off GetDistributionConfig and 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-be same-spelling paths 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 five AWS::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 new minHandoffPoints floor 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: 1 is recorded as fencing NOTHING on this target — its only non-empty scope is attributes, a cdkd-internal ResourceCreateResult field 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 in ecs-provider.ts, and they are why ECS stays out: PortMappings[].ContainerPortRange (SDK PortMapping.containerPortRange, never written — issue #1472) and LoadBalancers[].AdvancedConfiguration plus its AlternateTargetGroupArn / ProductionListenerRule / TestListenerRule / RoleArn members, 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: 168 it( 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 its config bindings 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), 5 reachableSdkMemberNames / expandGenericHandoffScopes cases incl. the anti-wildcard assertion, 6 real-repo cases (the LinuxParameters members 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 new spawnSync --check probes against a scratch COPY of the real providers tree: both DefaultRouteSettings forwards 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 real pascalToCamelCaseKeys -> 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 #1448 BuildBatchConfig.ServiceRole / BatchReportMode fences are unchanged and still fire. NO AWS integ (offline static analysis).
  • AWS::Glue::Table / ::Database: AWS-authored Parameters survive 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: updateTable built TableInput purely from the template and Glue's UpdateTable replaces TableInput WHOLESALE, so every Parameters entry AWS itself had written was erased by the first unrelated edit. Parameters is a general bag AWS writes into, and for an Apache Iceberg table the two entries it writes — table_type: ICEBERG and metadata_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 ONLY TableInput.Description, aws glue get-table --query 'Table.Parameters' returned null and the deploy reported success. The table silently degraded to a plain external table pointing at Iceberg data files. Same exposure for a crawler-written classification, 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 in previousProperties was 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 broken cdkd 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. updateDatabase had 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.DefaultRunProperties and Crawler.Configuration are all user-authored bags AWS does not write into on its own, and preserving a console-side addition there would actively BREAK drift --revert. The read fails CLOSED: only EntityNotFoundException degrades to "no live parameters" (so UpdateTable's own, more actionable error is not pre-empted); anything else throws a ProvisioningError naming glue: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 update try so the catch wrapper cannot re-label it, and the original error rides as cause so a transient throttle stays retryable by the deploy engine's outer withRetry. The read is also ordered AFTER the pre-flight validation, so an update refused for a malformed physical id or a missing TableInput issues no Get* 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 declared Parameters; an empty live map leaving the payload byte-identical; drift --revert still clearing a console addition, since that path passes the AWS-current snapshot as previousProperties; CatalogId forwarding; the read not issued on a pre-flight refusal; not-found not masking the real error; the fail-closed throw asserted UNWRAPPED and with no UpdateTable issued; 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: the data-analytics fixture gains an UPDATE phase (CDKD_TEST_UPDATE=true). The Iceberg table's ONLY phase-2 change is its Description, and verify.sh asserts that edit landed FIRST (otherwise the parameter assertions would pass vacuously against a table nothing updated) and then re-asserts BOTH table_type == ICEBERG and a populated s3:// metadata_location through the same helper used after create. The removal half rides the sibling plain events table — phase 1 sets {classification, owner_team}, phase 2 drops owner_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.ts called provider.update() WITHOUT withRetry at both its revert and revert-failed-update arms, unlike deploy-engine.ts and drift.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 the UpdateTable would be UNDONE by writing back the metadata_location cdkd read, pinning the table to an older snapshot — strictly worse than the bug being fixed. UpdateTableRequest.VersionId (whose ConcurrentModificationException is a documented UpdateTable error) 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 — and drift --revert is exactly that shape, deliberately overwriting whatever AWS holds, so guarding it would convert an intentional overwrite into a spurious failure. UpdateDatabaseRequest has no VersionId member 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 update try — hoisting buildTableInput / buildDatabaseInput out with it turned a Parameters: null template into a raw TypeError with no resource context; the build + merge moved back inside (neither raises a control-flow class, so update-wrap-coverage stays green), and the null value is additionally coerced to "no declared parameters". (4) CatalogId was forwarded by the new readers but still not by readTable / readDatabase — same file, same API, fixed on one side only, so on a cross-account Data Catalog cdkd drift read the account-default catalog and could report the resource gone; readCurrentState's already-declared properties argument 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 the EntityNotFoundException degradation 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 via aws 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.sh pinned 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.CreateTime is now captured before phase 2 and asserted equal after. Plus nits: optional chaining on the previously-unused previousProperties, conditional-spread CatalogId on UpdateTable to match the readers, buildDatabaseInput stringifying its values like buildTableInput always did, the phase-specific #609 / #1461 diagnostics restored to the shared assertion helper, a deliberate (and commented) env -u asymmetry between phase 1 and phase 3, and test arms for parameterKeySet's array / non-object branch and an absent live Parameters field. 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 explicit undefined), making the omit-VersionId assertion vacuous — the helper now takes an explicit null sentinel. The semantic price is now documented rather than implicit: a parameter added out-of-band is PERMANENT (preserved on every deploy) and INVISIBLE (post-deploy readCurrentState folds it into observedProperties, so cdkd drift stops 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 ignored provider.disableOuterRetry — which CustomResourceProvider and NestedStackProvider BOTH set AND implement update() 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 dropped isInterrupted / 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 one updateWithRollbackRetry helper so the three concerns (retry / opt-out / interrupt) cannot drift apart again, with tests for each. (2) The VersionId scoping 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 wholesale TableInput replace, 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 the ConcurrentModificationException message 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.VersionId is 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's VersionId, advances it with an out-of-band UpdateTable, 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 plain events table so a failed probe cannot leave the Iceberg fixture half-written. Plus two nits: the replayFailedOperations test block got the same beforeEach mock-clear as replayRollback (an inline mockClear left the documented cross-test leak in place), and both jq payload rebuilds switched from field allow-lists to del(<read-only members>) — an allow-list silently dropped CreateTableDefaultPermissions / 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. No src/** change — pure tooling / docs. The gap: the write-evidence pass #1432 added was sound for what it claimed, but its evidence was NAME-GLOBAL. collectWrittenMemberNames returned one flat Set<string> per provider FILE, so a member written ANYWHERE vouched for every CFn key with that spelling. Measured on codebuild-provider.ts: 11 of the 55 same-spelling keys had more than one write site (Type 9, Location 5, Name 5, and ComputeType / EncryptionDisabled / SecurityGroupIds / ServiceRole / SourceIdentifier / SourceVersion / Status / Value 2 each), so deleting the forward write of any one of them stayed silent. BuildBatchConfig.ServiceRole was the sharpest case — the SIBLING of the member that motivated #1432, cleared by the unrelated top-level serviceRole: 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: nestedKeysForTarget yielded de-duplicated NAMES, so top-level ServiceRole and BuildBatchConfig.ServiceRole were literally the same audited key. The fix moves both sides. CFn side: nestedKeyPathsForTarget yields one audited unit per (handled top-level, nested key) pair — 488 names become 587 paths across the 11 targets — and every classification carries topLevelProperty / terminalKey alongside the Top.Key path. Provider side: collectWriteEvidence returns { written, scopes }, where each written name is indexed to every member written BENEATH the value it is written with, resolving this.mapSource(source) calls, const / let bindings, ?: / ?? arms, array elements, spreads and .map(cb) callbacks — the same reach as the #1404 taint walk, because CodeBuild's source: this.mapSource(source) and its let buildBatchConfig delivered 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 forward serviceRole: write from a scratch copy of the real codebuild-provider.ts makes the shipped --check exit 1 with AWS::CodeBuild::Project: BuildBatchConfig.ServiceRole [no-write-evidence], while the name-global set still contains serviceRole — 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 of gen-handled-property-wiring's diff-is-not-delivery rule); compound assignments (??= / ||= / +=) and Object.defineProperty(sdk, 'x', {...}) are recognized as writes, with the descriptor literal suppressed so value / get / set never enter the set (value IS a real CodeBuild member); allow-list entries gained a passes dimension (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 future no-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 to collectStringLiterals as well (REVERSE_MAP_FUNCTION_PREFIXES, renamed from WRITE_EVIDENCE_EXCLUDED_*), removing the asymmetry where the write pass excluded readCurrentState and the key pass did not — measured free on the real tree (no key moves into a blocking bucket in either pass, while BucketOwnerAccess / ResourceAccessRole / Value stop being credited by the read path alone). A --providers-dir= test seam (mirroring gen-handled-property-wiring) lets spawnSync drive the SHIPPED --check against 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-target minWrittenMembers (CodeBuild 60, measured 82) and a per-target minWriteScopes (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. minWriteScopes deliberately 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 in MIN_WRITTEN_MEMBERS_PER_PROVIDER. The measured blind-spot table was re-run, not predicted: under scoped evidence the would-be no-write-evidence counts 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's nestedProperties capture is FLATTENED per top-level, a duplicate name inside the SAME top-level still vouches — deleting environment: { type: … } or source: { type: … } from a scratch copy of the real codebuild-provider.ts leaves --check at exit 0, covered by environmentVariables[].type and auth.type respectively, and both are genuine silent drops. Scopes are also keyed by NAME and unioned across write sites, so two unrelated environment: { … } literals share one scope. Closing either needs a per-PATH fixture capture (a refresh-cfn-schemas.mjs change 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: 131 it( blocks / 137 runtime cases in the file (up from 88 / 91) — 9 new synthetic collector cases (scoping, binding / call / .map resolution, conditional + spread, depth, compound assignment, defineProperty descriptor suppression, comparison-only literals both directions), path-model + allow-list passes + path-vs-terminal lookup + staleness cases, per-SHAPE real-code anchors (the aggregate written.size floor cannot see a partial collapse), red-direction probes for both new floors, and 12 spawnSync --check probes: exit 0 on the real tree, and exit 1 for BuildBatchConfig.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: peeled await in the value walk and let the identifier search climb to the module scope (both fail-loud-on-correct-code directions), moved filter / find out of the callback-returning set (their callback returns a predicate; the value comes from the receiver), restricted same-file callee resolution to this.helper(…) / helper(…) so an unrelated client.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 every minNestedKeys floor 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 every freshObjectMapper target 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'] = 5 in CloudFront's forward completeRequiredUpdateFields for 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 the this.helper-only restriction guards a FALSE CLEAR on a CI-blocking bucket — rejected any unrecognized flag or positional argument (a --chekc typo, and the space form --providers-dir /tmp, both fell through to the WRITER path and rewrote the committed matrix while exiting 0; --help now prints usage), stopped the identifier climb at a PARAMETER of the nearest scope and stopped outer scopes from being descended into sibling functions (a parameter cfg had resolved to an unrelated method's const cfg), and made concat deliver 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-named volumesToCfn / metricsSdkToCfn is not skipped (no live impact — the only opted-in target keeps its reverse map inside readCurrentState), and the blind-spot counts are described as UNMEASURABLE rather than as confirmed non-drops. NO AWS integ (offline static analysis).
  • AWS::Glue::Table: IcebergTableInput is 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 whose OpenTableFormatInput.IcebergInput carries the nested table spec — under the CFn registry spelling IcebergTableInput OR the SDK spelling CreateIcebergTableInput — now fails BEFORE any AWS call on create(), 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.ts calls provider.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: UpdateTableCommandInput has no OpenTableFormatInput member, 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 calling create() with previousState.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 a CreateContext on ResourceProvider.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 raw glue:CreateTable shapes + 5 CloudFormation stacks) showed the spec is undeployable on BOTH paths, and the raw-API half is the decisive one because cdkd calls glue:CreateTable directly 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 table without a TableInput.StorageDescriptor, Table metadata information present at multiple parts of input request with one; the spec's own Location is never read, so no combination exists. CFn: every variant rolls back with Table 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'}, optionally Version) is unaffected and keeps its real-AWS coverage in the data-analytics fixture (PR #1453). The #1390 rename is REMOVED, not merely bypassed: toSdkOpenTableFormatInput existed only to rename the CFn spelling to CreateIcebergTableInput so 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 while CreateTable reports 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 the try -- the catch wrapper EMBEDS the original message, so every toContain still matched and mockSend was still un-called; the shared helper now pins PLACEMENT (message starts with the raw AWS::Glue::Table <id>: prefix, carries no Failed to create/update Glue Table relabel, and has no cause). (2) toEqual on the forwarded blob could not see a member dropped to undefined -- exactly the #1390 failure mode -- so it is toStrictEqual. (3) Only the OUTER non-object guard was covered; the inner one (IcebergInput itself 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 spelling CreateIcebergTableInput, the working shape forwarding MetadataOperation to CreateTableCommand, the working shape accepted by update, the update-side WARNING (asserting the shared body plus the update-specific lead), and delete() NOT refusing -- destroy of a table that somehow carries the key must stay possible, and hoisting the assert into the shared delete dispatcher 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 at MinCapacity (issues #1419 + #1435) — src/provisioning/providers/dynamodb-globaltable-provider.ts, a new tests/unit/provisioning/dynamodb-globaltable-provider-index-autoscaling.test.ts, and the tests/integration/dynamodb-globaltable/ fixture. Shipped as ONE change because either half alone makes things worse, which is what #1435 spells out: creating at MinCapacity while 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 for dynamodb:table:* only. Nothing anywhere registered dynamodb:index:*, so a per-GSI writeCapacity: Capacity.autoscaled(...) yielded a correct INITIAL capacity and then dropped MinCapacity / 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() called applyAutoScalingDiff ZERO times (only update() 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, because update()'s replica loops all continue on the deploy region — only CROSS-REGION replicas ever got a read target. The fix generalizes applyAutoScalingDiff to the four DynamoDB scalable dimensions (index targets use ResourceId: 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 pure collectAutoScalingTargets(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 from create() (all specs, after the table AND replicas are ACTIVE), from a new update() step 6b (after the GSI diff, so an added index exists and a dropped one is gone), and from delete() (index names read from the live DescribeTable, 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 costs 2 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 batched DescribeScalableTargets per 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 / PutScalingPolicy now carry a throttle-only retry: every error in this path is swallowed into a WARN, so an un-retried ThrottlingException would 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-coverage gains a WRITE-EVIDENCE pass, so same-spelling stops 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's same-spelling bucket 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::Project BuildBatchConfig.BatchReportMode is the measured case: CFn declares it, @aws-sdk/client-codebuild declares batchReportMode, and CodeBuildProvider.mapProperties rebuilt buildBatchConfig naming 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.md telling contributors that "a type inside NESTED_KEY_TARGETS cannot regress" was over-promising for the fresh-object shape, which is common (CodeBuildProvider, DynamoDBGlobalTableProvider, ECSProvider). The fix adds freshObjectMapper to NestedKeyTarget. For a target that sets it, a would-be same-spelling key 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-blocking no-write-evidence bucket. 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 an exact-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 — so WRITE_EVIDENCE_EXCLUDED_FUNCTION_PREFIXES skips reverse-map bodies by word-boundary PREFIX — exact-name matching missed the split reverse maps that really exist (readCurrentStateService / readCurrentStateTaskDefinition / the six readCurrentState* methods in apigateway-provider.ts). Measured withdrawal: 8 names from s3-bucket-provider.ts, 71 from codebuild-provider.ts, 42 from ecs-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-spelling keys 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.convertLinuxParameters is return pascalToCamelCaseKeys(config), delivering Capabilities / Devices / Tmpfs / Swappiness at 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 walk gen-handled-property-wiring already does one level up) is issue #1445. Proved against real code, in both directions, per the repo checker rules: deleting ONLY the forward batchReportMode: write from the real codebuild-provider.ts — leaving readCurrentState's bbc['BatchReportMode'] = project.buildBatchConfig.batchReportMode intact — makes --check exit 1 naming BatchReportMode [no-write-evidence], while the SAME regression with freshObjectMapper removed 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 the BuildBatchConfig.ServiceRole case 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, and BuildBatchConfig.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, and toSdkGlobalSecondaryIndexes runs every value through toFiniteNumber — which returns undefined for a present-but-unparseable value (an unresolved {Ref: …}, '', an object). Such a GSI was therefore indistinguishable from a removal and was answered with the -1 sentinel, 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 adds collectRawOnDemandDeclarations(properties, region) beside toSdkGlobalSecondaryIndexes — a per-IndexName map 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-array GlobalSecondaryIndexes is refused loudly by toSdkGlobalSecondaryIndexes, while a malformed per-ENTRY shape is deliberately passed through untouched but carries no usable IndexName, 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 in toSdkGlobalSecondaryIndexes, 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-shaped gsi['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 leftover WriteOnDemandThroughputSettings suppressed 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 keys Ref / Fn::*) routes it into the same suppression + warning, while a genuine {} still counts as a removal. The same pass made the non-string IndexName skip 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: removing WriteOnDemandThroughputSettings from a template never set flatChanged, so no UpdateTable went 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. The oldBilling / newBilling pair 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 -1 behaves exactly as the issue assumed: {MaxWriteRequestUnits: -1} was accepted and DescribeTable then 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 in UPDATING for over an hour with the override unchanged and every later call returning ResourceInUseException (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 only IndexName is rejected outright (ValidationException: There are no actions specified in the Replica Update Action). ProvisionedThroughputOverride is the same story by construction — the registry schema declares ReadCapacityUnits / MaxReadRequestUnits "minimum": 1, and live -1 / 0 both fail validation while {} returns InternalServerError. 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 canonical Billing.onDemand({maxReadRequestUnits})) was never wired AT ALL — dropped on the way in rather than merely un-reset, and the repo's own verbatim-cdk synth fixture 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::Bucket joins NESTED_KEY_TARGETS, and the first run found the EventBridgeConfiguration boolean 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.json re-captured, docs/_generated/nested-key-coverage.{json,md} regenerated, and a new unit-test file. Why the target was missing: .claude/rules/providers.md requires a provider that FORWARDS a nested CFn config blob to be a critic target, and S3BucketProvider forwards 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 stored AWS-S3-Bucket.json predated the nestedProperties / definitionShapes capture 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 flags Transition / 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) and TransitionInDays (2) were already named by readCurrentState's reverse map, so the file-global literal heuristic calls them provider-handled however broken the write path is (#1393 item 2), and rule-level ExpiredObjectDeleteMarker is not shape-audited at all because @aws-sdk/client-s3 spells the interface LifecycleRule, leaving CFn's Rule in unmatchedDefinitions. 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's EventBridgeConfiguration carries a REQUIRED boolean EventBridgeEnabled (that is exactly what CfnBucket renders), while the SDK's EventBridgeConfiguration is an EMPTY structure whose PRESENCE enables delivery — so the boolean has no SDK member to land on and the critic bucketed it no-sdk-member. (1) Write side: the provider emitted the SDK block whenever the CFn block existed, so an explicit EventBridgeEnabled: false silently ENABLED EventBridge notifications — the inverse of the template's intent. It now emits the block only when the boolean is not false, 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: readCurrentState returned the SDK's {}, but cdkd's state baseline holds the CFn spelling and drift-calculator only 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 / TableNamespace are the FIRST real instance of the unreachable-definition false positive classifyTargetShapes documents — all three live only under MetadataConfiguration / 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.md both still said CodeBuild was not a critic target, though #1431 added it in the same PR.
  • The two handledProperties wiring 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 (both HANDLED_WIRING_ALLOW_LIST entries removed), src/provisioning/property-coverage.generated.ts + docs/_generated/handled-property-wiring.{json,md} regenerated, .claude/rules/code-layout.md, and the tests/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.MaxDrainDurationSeconds is not a CreateNatGatewayRequest member (@aws-sdk/client-ec2 models/models_1.d.ts), and EC2 ships no ModifyNatGateway* operation at all — the ONLY two SDK inputs carrying the field are DisassociateNatGatewayAddressRequest (models_5) and UnassignPrivateNatGatewayAddressRequest (models_7), i.e. it is a per-call drain timeout for RELEASING secondary addresses, which is why the CFn registry schema lists it under writeOnlyProperties. cdkd's updateNatGateway rejects 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 under createOnlyProperties, so recreating a gateway on a drain-timeout change would diverge from CloudFormation and needlessly break the data plane. AWS::Logs::LogGroup.ResourcePolicyDocument took option 2 of #1412 for the reason the issue itself gives — it maps to the separate AWS::Logs::ResourcePolicy type whose logs:PutResourcePolicy is 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 now unhandledByDesign with rationales. Correcting a premise carried in both issues: neither provider sets disableCcApiFallback, 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 is cc-api -> cc-api ONLY (provider-registry.ts short-circuits on that value alone), so a resource already recorded provisionedBy: 'sdk' whose template sets one of these properties DOES re-evaluate and flip to Cloud Control on its next UPDATE — deploy-engine.ts does 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 from handledProperties, present in unhandledByDesign with a rationale, reported by findSilentDropProperties, and that ProviderRegistry.getProviderFor routes to CC for a template setting it and to SDK for one that does not), plus a guard that CreateNatGateway never carries the field and a fence that neither retired property may return to handledProperties; tests/unit/scripts/gen-handled-property-wiring.test.ts re-pointed its real-code stale-entry probes onto a still-live allow-list entry (IAMAccessKeyProvider#Serial). Revert-proofed: re-adding both properties to handledProperties fails 6 of 10 and makes audit:handled-property-wiring:check fail naming both. The critic now reports 0 gaps, 2 allow-listed (down from 4). Integ: the vpc-nat-gateway fixture 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 is writeOnlyProperties and no EC2 API returns it, so a read-back-and-compare assertion is structurally impossible; verify.sh asserts 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 and available on AWS, and both gone after destroy).
  • AWS::CodeBuild::Project: seven nested Source / Environment / Cache sub-keys wired, plus the SourceIdentifier create failure (issue #1386) — src/provisioning/providers/codebuild-provider.ts, unit tests, and the tests/integration/ci-cd/ fixture. The class: mapSource / mapProperties build 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's NESTED_KEY_TARGETS. Wired (create AND update — both share mapProperties, and the drop is WORSE on update because UpdateProject is read-modify-write, so an unnamed sub-key was actively WIPED off a live project): Source.Auth.{Type,Resource} -> auth, Source.GitSubmodulesConfig.FetchSubmodules -> gitSubmodulesConfig and Source.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.SourceIdentifier was never mapped at all, and AWS REQUIRES it on every SecondarySources entry — so any template using secondary sources failed CreateProject outright (a loud failure, not a silent drop, which is why #1386's static sweep missed it). SecondarySources route through the same mapSource helper, so all four Source sub-keys cover them automatically (pinned by a test). Read side: readCurrentState reverse-maps each newly-wired sub-key emit-when-present, so the drift baseline stays symmetric with the AWS-current snapshot — except Source.Auth, deliberately excluded because BatchGetProjects echoes it back partially and emitting a partial shape would fire phantom drift on every project that sets it (documented in-code). Not mapped: Environment.HostKernel exists in the CFn registry schema but has NO member anywhere in the installed @aws-sdk/client-codebuild dist-types tree, so there is nothing to map it onto until an SDK bump; it is a NESTED key, so it cannot live in unhandledByDesign (top-level-only); it is recorded instead as a NESTED_KEY_ALLOW_LIST entry with the rationale, which the critic fails on once an SDK bump makes the member reachable. handledProperties is unchanged (all seven sit under the already-declared top-levels Source / Environment / Cache), so property-coverage.generated.ts correctly needed no regeneration. Verified against the authoritative schemas, not the issue text: every SDK member name and nesting read off @aws-sdk/client-codebuild's models_0.d.ts, every CFn spelling off the live cloudformation:DescribeType registry schema (the repo's captured AWS-CodeBuild-Project.json fixture 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 L1 CfnProject on a PUBLIC GitHub source, the cheapest shape that accepts both and needs no source credential. verify.sh asserts all four covered values off a SINGLE batch-get-projects blob (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) and Source.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 the CreateTable SDK 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 the tests/integration/dynamodb-globaltable/ fixture. The class: the provider cast the CFn GlobalSecondaryIndexes blob RAW to the SDK's GlobalSecondaryIndex[], 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 failed CreateTable outright (AWS requires ProvisionedThroughput on every GSI) and every TableV2 per-GSI on-demand limit vanished. TableV2 is 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) WriteProvisionedThroughputSettings has EXACTLY ONE member, WriteCapacityAutoScalingSettings — there is no literal WriteCapacityUnits, because write capacity on a GlobalTable is always auto-scaled; CreateTable needs a concrete number, so the mapping takes SeedCapacity (the documented "initial provisioned capacity units") before MinCapacity. (SUPERSEDED by issue #1435 — see the entry above: the precedence is context-dependent, and CloudFormation creates at MinCapacity; SeedCapacity applies only to the PAY_PER_REQUEST -> PROVISIONED flip.) (2) Per-GSI READ capacity is not on the top-level GSI at all — CDK synthesizes it to Replicas[?Region==<deploy region>].GlobalSecondaryIndexes[].Read{Provisioned,OnDemand}ThroughputSettings, so the SDK's single ProvisionedThroughput / OnDemandThroughput object 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 live cloudformation:DescribeType schema cross-checked against a real cdk synth under both billing modes; the repo's captured fixture stores top-level names only and could not settle it. Call sites: create(), addReplica(), the update() replica-modify action, the update() GSI diff (both sides are now translated BEFORE diffing, so emitted Create / Update actions carry real throughput — a side benefit is that an auto-scaling-only edit, invisible to the DynamoDB API, no longer emits a bare Update: {IndexName} that AWS rejects as empty), and one site the issue did not name: the PAY_PER_REQUEST -> PROVISIONED billing-mode flip, where AWS requires per-index ProvisionedThroughput in the SAME UpdateTable call — 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 no unhandledByDesign map): Replicas[].ReplicaStreamSpecification.ResourcePolicy and Replicas[].ResourcePolicy (both need PutResourcePolicy, not any UpdateTable field) and Replicas[].GlobalSecondaryIndexes[].ContributorInsightsSpecification (needs a per-index UpdateContributorInsights). handledProperties is 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 its Delete is 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 the modified loop wholesale on ANY flip, which silently dropped every per-GSI Max{Read,Write}RequestUnits on a PROVISIONED -> PAY_PER_REQUEST flip (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-array GlobalSecondaryIndexes (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. WarmThroughput also rides the update-ADD path, so a GSI added later matches what create() sends for the same template. Tests: 29 in a new file, with both property bags copied VERBATIM from a real cdk synth rather 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 UNCONDITIONAL TableV2s (not gated behind CDKD_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, since TableV2 exposes every property needed. verify.sh reads 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.
  • handledProperties WIRING 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 + --check steps), .claude/rules/code-layout.md. No src/** change — pure tooling / docs. The class: handledProperties can LIE. gen-property-coverage proves a property is ACCOUNTED FOR and gen-nested-key-coverage audits spellings INSIDE a forwarded blob, but neither proves an entry is WIRED — ECRProvider declared ImageTagMutabilityExclusionFilters handled 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 orthogonal delegated tag, 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, a getDriftUnknownPaths entry, the declaration itself, and a readCurrentState write-back all fail to vouch (each pinned by a test). Two rules keep table-loop from 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; RDSDBProxyProvider shows the discrimination, its immutable-field loop losing credit while its mutableFields loop 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.ts really does declare result x12). Each wired property records seededBy so 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 via hasCdkAutoDeleteTag(properties) in delete(); measured cost 0 of 1063), and a previousProperties read 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 single element-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) and AWS::Logs::LogGroup.ResourcePolicyDocument (issue #1412, already admitted in an in-code comment); IAMAccessKeyProvider#Serial and NestedStackProvider#TemplateURL are 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 real ecr-provider.ts to its pre-#1406 state exits 1 naming the property (a FIRST probe that stripped only the lowercase-p reads PASSED — the surviving previousProperties read cleared it, and that false clean is what drove the exclusion); stripping the real VpcId reads 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 real glue-provider.ts must not borrow buildJobCommonFields's local table. The shipped --check is driven via spawnSync against a scratch COPY of the providers tree (--providers-dir= seam), so exit code and failure text are covered without ever writing to src/. Tests: 68 (shape units, per-SHAPE real-repo floors — 84 classes / 1063 declared / 43 table-loop of which 29 sole-evidence — allow-list stale + per-property keying, and the real-code probe set). NO AWS integ (offline static analysis).
  • ModifyInstanceFleet needs BOTH capacities, and every EMR InstanceTypeConfigs conversion site now has real-AWS coverage (issue #1400) — src/provisioning/providers/emr-instance-fleet-config-provider.ts (update()), new fixture tests/integration/emr-instance-fleets/, .claude/integ-coverage-allowlist.json (the AWS::EMR::InstanceFleetConfig entry 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 three InstanceTypeConfigs conversion sites — EMRClusterProvider.toInstanceFleetConfig (inline Cluster.Instances.{Master,Core}InstanceFleet), EMRInstanceFleetConfigProvider.create (AddInstanceFleet), and the same provider's ModifyInstanceFleet update — proven only by mocks. #1383 was precisely a send-side-looks-fine / AWS-silently-discards bug (CFn ConfigurationProperties vs the SDK's Properties), 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, Ec2SubnetIds plural — Ec2SubnetId is the group form) plus a standalone TASK fleet, each carrying a per-InstanceTypeConfig Configurations marker that verify.sh reads back through ListInstanceFleets (SDK, Marker-paginated). The bug it found on its first run: AddInstanceFleet tolerates an absent capacity (AWS defaults it to 0) but ModifyInstanceFleet rejects 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 omits undefined members, 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 to 0, which also matches CFn desired-state semantics (an omitted capacity means zero) and the provider's own targetCapacity() helper and delete-path scale-to-0, both of which already sent both members. The pre-existing unit tests all passed a BASE_PROPS carrying 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 (an Object.keys check, since toMatchObject passes on an absent key whose expected value is undefined). A generalization of memory rule update_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 #top key; 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-maintained QUANTITY_ITEM_FIELDS list a NEW AWS array member would silently miss) and relocated/renamed members like the CachedMethods sibling-vs-nested placement (the hardest #1370 member). The pass: SDK interfaces are parsed with member type kinds (collectSdkInterfaces; a Quantity-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 CFn array member whose same-spelled SDK members are all wrapper refs) and definition-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); ambiguous shapes 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: legacy S3Origin, 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-stripping Aliases from the REAL provider makes --check exit 1 naming Aliases [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, extractDefinitionShapes walker, real-repo shape fences + floors, real-code probes, --help guard spawn tests). No src/** 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 + --check steps), 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-only property-coverage pre-flight cannot see, previously found only by live failures (#1165/#1167 ECS, #1160 API GW v2, #1304 MetricTimeZone, #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 over dist-types/models), with the provider's AST-level string literals as evidence of explicit handling; exact vs lower-first key style per target; buckets same-spelling / provider-handled / allow-listed / case-divergence (blocks CI, names the SDK near-miss) / no-sdk-member (blocks CI); per-target minNestedKeys + 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 #1370 AcmCertificateArn rename 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) CloudFront OriginCustomHeaders was never renamed to the SDK's CustomHeaders, 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 in convertOrigin, with the inverse restoring the CFn spelling for drift (note: distributions deployed BEFORE this fix have observedProperties baselines recording the old read-back key CustomHeaders, so their first post-upgrade cdkd drift reports a one-time Origins key-rename drift until the next deploy or drift --accept — same accepted tradeoff as #1372's OriginSSLProtocols restore); (2) ECS TaskDefinition S3FilesVolumeConfiguration (the new S3 Files volume) was not mapped in convertVolumes at all AND is unreachable by the mechanical first-letter flip — the SDK member is the irregular all-lowercase-prefix s3filesVolumeConfiguration; now mapped both directions (write + volumesToCfn read-back). Eight keys (CloudFront CacheTagConfig; ECS Service deployment-lifecycle members; TaskDef AccessPointArn / 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-level Monitoring property (registered in _todo-backfill.json per the property-coverage backfill flow). Shape-level divergences ({Quantity, Items} wrappers, sibling-vs-nested CachedMethods) 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 as provider-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 in tests/unit/provisioning/cloudfront-distribution-provider.test.ts and an UPDATE phase + casing assertions added to the tests/integration/s3-cloudfront/ fixture. (#1370) convertToSdkFormat passed most DistributionConfig keys through verbatim, but three ViewerCertificate members (AcmCertificateArn / SslSupportMethod / IamCertificateId) and top-level IPV6Enabled differ in ACRONYM CASING between the CFn schema and the CloudFront API (ACMCertificateArn / SSLSupportMethod / IAMCertificateId / IsIPV6Enabled), and Restrictions.GeoRestriction carries a bare CFn Locations array 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 + the convertToCfnFormat inverse, so cdkd drift compares in CFn spelling with no phantom drift). The pre-flight property-coverage check cannot catch this class (it compares TOP-LEVEL properties only; DistributionConfig is 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's DistributionConfig verbatim (only CallerReference was reused), while UpdateDistribution is 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 new mergeUpdateConfig merges 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's default annotations + 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. convertToSdkFormat also stopped mutating its input's nested Logging object (state-sourced previousProperties now flow through it). The live UPDATE run then surfaced a SECOND required-field layer: the API accepts SPARSE Origins / 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), so mergeUpdateConfig now runs completeRequiredUpdateFields — an absent-only fill of the empirically probed required set (per origin: OriginPath '' + CustomHeaders empty + CustomOriginConfig read/keepalive timeouts 30/5; per cache behavior: SmoothStreaming false, FieldLevelEncryptionId '', LambdaFunctionAssociations/FunctionAssociations empty, TrustedSigners/TrustedKeyGroups disabled, AllowedMethods GET/HEAD default with nested CachedMethods) — validated by driving the exact payload to a live UpdateDistribution success. The probe also exposed two more members of the casing/shape family, fixed in both directions: the CFn template spelling CustomOriginConfig.OriginSSLProtocols (capital SSL) never matched the converter's OriginSslProtocols lookup (the protocol list was silently dropped on every create), and the CFn SIBLING CachedMethods array was never nested inside the SDK's AllowedMethods wrapper (revertCacheBehavior has 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 of GeoRestriction country codes (template [JP, US] read back [US, JP]), so the positional drift compare fired guaranteed phantom drift on any multi-country restriction — getDriftUnorderedPaths now declares DistributionConfig.Restrictions.GeoRestriction.Locations as an unordered set (the FSx WindowsConfiguration.Aliases precedent, issue #1096 mechanism). Live-verified end-to-end by the extended s3-cloudfront integ: create asserts IsIPV6Enabled=true + the geo allowlist reached AWS, drift reports clean on the fresh deploy, the new Phase 1.5 re-deploys with CDKD_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 rollback plan preview stops unwinding a record for a delete it will refuse; dead supportsFinalSnapshot removed (issue #1368) — src/cli/commands/rollback.ts, src/provisioning/final-snapshot.ts. Before: #1366 taught the plan LABEL to say the rollback will REFUSE it for a Snapshot shape cdkd cannot snapshot, but applyPlanToPreview / applyFailedPlanToPreview still 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 to skip — already reverted in the one preview the user reads before typing y. 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 take skipFinalSnapshot (as the label functions already did) and consult the SAME refusesFinalSnapshot predicate with the SAME route, so the label and the previewed state cannot disagree by construction; under --skip-final-snapshot nothing is refused and the unwind is unconditional, matching the executor. Also: supportsFinalSnapshot had zero src/ callers (never re-exported from src/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: finalSnapshotMechanism tests 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-failed CREATE each keep their record so the older segment is not mislabelled; --skip-final-snapshot and 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 adding AWS::EC2::Volume to 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 pure finalSnapshotMechanism(type, route) -> atomic-delete-parameter / pre-delete-snapshot / refuse-cc-routed / refuse-unsupported-type, plus the refusesFinalSnapshot predicate), src/deployment/rollback-executor.ts (prepareCreateRollbackFinalSnapshot switches on that matrix instead of re-deriving it; the four CREATE-rollback arms emit the EFFECTIVE route; plan items carry effectiveProvisionedBy), src/cli/commands/rollback.ts (shared snapshotNote helper for both label functions). Before: (1) both delete sites resolved the routing layer via effectiveProvisionedBy (state record first) and routed the provider lookup with it, but the emitted ROLLBACK_RESOURCE_* event still carried op.provisionedBy — so when a legacy journal entry disagreed with the state record, cdkd events named a layer the delete did not use; (2) actionLabel / failedActionLabel rendered [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 labelled cdkd cannot snapshot this resource; the rollback will REFUSE it (re-run with --skip-final-snapshot to delete without one). --skip-final-snapshot still wins over both (nothing is refused under the opt-out). Design note: the refusal verdict lives in the LABEL layer, not as a new refuse-* 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 in final-snapshot.test.ts (per-route atomic polarity, route-agnostic pre-delete types, unsupported arm, the predicate pinned in both directions), 7 in rollback-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: neutering refusesFinalSnapshot fails 4, reverting the event route fails 4, reverting the plan stamping fails 4. Scoped out: deploy-engine.ts's prepareFinalSnapshotForDelete still carries a third copy of the matrix — routing it through the new helper is the obvious consolidation but that file is in the integ-destroy + integ-broad gate 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 honors DeletionPolicy (issue #1362) — src/deployment/rollback-executor.ts (two new FailedOpActionKinds — orphan-failed-create-retain / delete-failed-create-with-final-snapshot; classifyFailedOp reads the CURRENT state record's deletionPolicy; the delete branch resolves the routing layer ONCE via effectiveProvisionedBy and shares it between the snapshot gate and the provider lookup; prepareCreateRollbackFinalSnapshot widened 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: the delete-failed-create branch read NO DeletionPolicy at all — a Snapshot-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 a Retain-policy one was deleted outright while the COMPLETED-CREATE path orphaned it. Now: one matrix for both paths — Retain leaves the resource in AWS and drops the record, Snapshot snapshots then deletes (atomic delete parameter on the SDK route, createPreDeleteFinalSnapshot for the pre-delete types, REFUSAL for a cc-api-routed atomic type or any other Snapshot shape), RetainExceptOnCreate / Delete / absent delete plainly. The Retain arm is a deliberate scope extension beyond the issue text (recorded on the issue before implementation): fixing only Snapshot would leave the classifier reading deletionPolicy for one value and ignoring it for another, and CloudFormation applies the policy to a failed create's rollback delete too — which is why RetainExceptOnCreate exists 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 in remainingFailedOps — a half-created resource that is not snapshot-capable yet (an RDS instance rejects a final-snapshot delete while creating) becomes snapshot-able once it settles, so a re-run completes the job, and --skip-final-snapshot remains the explicit opt-out. The tolerant option's failure mode is unrecoverable by comparison. Tests: 15 new units in tests/unit/deployment/rollback-executor.test.ts (classifier per policy incl. the mismatch guard running FIRST, Retain-orphan, atomic+SDK identifier threading with attemptedProperties still 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-snapshot covering the refused shapes but NOT overriding Retain, routing-source alignment) plus 4 CLI units in tests/unit/cli/commands/rollback.test.ts (identifier reaching the delete through the real command, both flag polarities, Retain issuing no delete at all, and the plan labels). Binding-proofed: reverting the two classifier lines fails 13 of them. Docs: cli-reference (DeletionPolicy: Snapshot section, cdkd rollback flag table + limitations), .claude/rules/code-layout.md.
  • A rolled-back CREATE under DeletionPolicy: Snapshot is snapshotted then deleted, not orphaned (issue #1358) — src/deployment/rollback-executor.ts (new delete-with-final-snapshot RollbackActionKind; classifyRollbackOp splits Retain from Snapshot; new prepareCreateRollbackFinalSnapshot mirroring the deploy engine's prepareFinalSnapshotForDelete mechanism matrix; RollbackExecutorContext gains finalSnapshotClients + skipFinalSnapshot), src/deployment/deploy-engine.ts (threads both from DeployEngineOptions into the executor context), src/cli/commands/rollback.ts (--skip-final-snapshot + stack-region-pinned AwsClients), src/cli/options.ts (doc comment). Before: classifyRollbackOp mapped BOTH Retain and Snapshot to orphan-retain, so a deploy that failed after creating a Snapshot-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 once src/provisioning/final-snapshot.ts shipped (#1352 / #1353). Now: CloudFormation semantics — rolling a CREATE back IS a delete, so Snapshot takes the final snapshot and then deletes: atomic delete parameter for the SDK-routed ATOMIC_FINAL_SNAPSHOT_TYPES, createPreDeleteFinalSnapshot + wait for PRE_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-snapshot is the data-loss opt-out; Retain / RetainExceptOnCreate / Delete are unchanged. The cc-api test uses the CURRENT state record's provisionedBy with the journaled op's as the legacy fallback (shared effectiveProvisionedBy helper, also used by #1354's rollbackFinalSnapshotId). 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 in tests/unit/deployment/rollback-executor.test.ts plus 4 CLI-wiring units in tests/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 the rollback subcommand 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-snapshot plain-deletes incl. the otherwise-refused shapes; Retain still orphans; RetainExceptOnCreate still deletes). Binding-proofed: reverting the classifier line fails 9 of them. The command installs a STACK-REGION-PINNED AwsClients as the process-global for the replay (mirroring destroy-runner.ts's cross-region destroy) and restores the original in its finally: pinning only the snapshot would be worse than not pinning it at all, since a --stack-region run would then take a real, billable snapshot and fail the delete that follows. Docs: cli-reference (DeletionPolicy: Snapshot section + cdkd rollback flag 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::Volume immutable-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: the create-only-properties.ts CFn-schema fallback found nothing because the AWS registry schema for this type declares no createOnlyProperties at all (live-verified 2026-08-03), and the hand-authored ReplacementRulesRegistry had no entry. Now: a hand-written rule classifies the six immutable properties as replacement and the ModifyVolume-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::Volume also joins STATEFUL_TYPES, so the deploy engine's property-driven replacement path demands --force-stateful-recreation before 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: new replacement-rules-ec2-volume.test.ts (per-property replacement / in-place polarity, isClassified pinning both directions, stateful guard). Integ: the deletion-policy-snapshot fixture regains the replacement phase #1354 had to drop — CDKD_TEST_REPLACE=true flips VolumeKeep's AZ, and the run asserts the OLD volume is gone, a completed final snapshot of it exists (UpdateReplacePolicy: Snapshot honored 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: Snapshot coverage completed + UpdateReplacePolicy: Snapshot honored (issues #1353 / #1354) — src/provisioning/final-snapshot.ts (Redshift CreateClusterSnapshot + ElastiCache replication-group CreateSnapshot pre-delete implementations behind the new createPreDeleteFinalSnapshot dispatcher, name-prefix idempotent reuse via finalSnapshotNamePrefix, per-service 50/255-char identifier caps), src/utils/aws-clients.ts (redshift / elastiCache getters), src/deployment/deploy-engine.ts (shared prepareFinalSnapshotForDelete gate reused by the destroy DELETE branch AND all four replacement / recreate delete sites — --replace delete-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::ReplicationGroup under DeletionPolicy: Snapshot were refused outright (#1352's fail-safe), and UpdateReplacePolicy: Snapshot old 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: new deletion-policy-snapshot-heavy fixture (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 planned deletion-policy-snapshot replacement phase (AZ flip) could not run because cdkd does not classify an AWS::EC2::Volume AZ change as a replacement — the AWS registry schema for that type declares no createOnlyProperties (root cause filed as #1356; the integ follow-on is #1357). Docs: cli-reference table + notes, README, troubleshooting, rules.
  • DeletionPolicy: Snapshot is 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 in rds-provider.ts / neptune-provider.ts / docdb-provider.ts / elasticache-provider.ts, --skip-final-snapshot flag on deploy / destroy / state destroy (src/cli/options.ts, threaded through nested-stack-context.ts for recursive child destroys). Before: shouldRetainResource only honored Retain / RetainExceptOnCreate, so Snapshot fell through to a plain DELETE — no final snapshot where CloudFormation creates one (live A/B on AWS::EC2::Volume, 2026-08-03: CFn delete-stack left a completed snapshot, cdkd destroy left nothing), and the RDS/Neptune/DocDB providers hardcoded SkipFinalSnapshot: true. Silent data loss on a daily pattern — the CDK RDS L2 defaults removalPolicy to SNAPSHOT. 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 with DBClusterIdentifier set gets no instance-level snapshot). CC-routed AWS::EC2::Volume gets a pre-delete CreateSnapshot (tagged cdkd:final-snapshot-of), waited to completed, 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 — CC DeleteResource has no final-snapshot parameter; CloudControlProvider.delete also fail-closes if the context field ever reaches it). Snapshot-wait failures are wrapped as typed FINAL_SNAPSHOT_* errors that the destroy call sites rethrow BEFORE their "not found = already deleted" heuristics (a raw InvalidSnapshot.NotFound poll error would otherwise drop a live, un-snapshotted volume from state), with transient post-CreateSnapshot InvalidSnapshot.NotFound propagation 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-snapshot is the explicit opt-out for all shapes. UpdateReplacePolicy: Snapshot on 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 via CDKD_TEST_UPDATE=true template-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 every AWS::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-recreation replacement consent, #1340 plumbing) or the aws-cdk:auto-delete-objects tag (honored when present, though Tags is not yet a handled property for this type, so it currently requires --allow-unsupported-properties; no CDK autoDeleteObjects sugar exists for directory buckets). Without an opt-in, DeleteBucket is attempted directly and a not-empty failure surfaces with an actionable message (manual aws 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. The s3-directory-bucket integ fixture gained a verify.sh exercising 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 in tests/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 normal finally releases 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-effort releaseLock is now gated on a lockHeld flag — releaseLock deletes 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 while cdkd destroy prints Acquiring 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 (new forwardSigtermToSigint() — registers ONE process.on('SIGTERM') listener that re-emits the signal as SIGINT, returns an unregister function), wired at the top of src/cli/commands/deploy.ts / destroy.ts / state.ts (stateDestroyCommand) / rollback.ts with the unregister in each command's outermost finally (so the forwarder never leaks into cdkd 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 every finally, 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 the cdkd force-unlock <stackName> recovery hint — parity of message, no best-effort release plumbed at deploy's top level). A per-stack pre-start gate in deploy.ts's runStackInner additionally 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-unlock recovery. 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 -TERM during 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-recreation consent into the four replacement/recreate delete sites). Before: cdkd destroy of ANY non-empty bucket silently deleted every object version + delete marker (deleteBucketWithEmptyRetry's unconditional emptyBucket), and ANY image-carrying ECR repo was deleted with force: true — where both CloudFormation (DELETE_FAILED, live-A/B-verified) and Terraform (force_destroy / force_delete required) 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's aws-cdk:auto-delete-objects tag (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 the aws-cdk:auto-delete-images tag; both: DeleteContext.forceDataDelete, set ONLY by the deploy engine's replacement/recreate deletes when --force-stateful-recreation was 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 migration removeProtection went 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-state deletes images AWS-natively pre-destroy; synthetics-canary keeps 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):

  • --verbose no longer prints NoEcho parameter values (issue #1329) — src/deployment/intrinsic-function-resolver.ts, plus unit tests in tests/unit/deployment/intrinsic-noecho-redaction.test.ts (new). NoEcho: true is 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 in resolveParameters and the Resolved Ref to parameter line in resolveRef. All four now route through the module-local stringifyParameterForLog(paramDef, value) (renders <redacted> when the definition carries NoEcho: true); resolution itself is unchanged — consuming resources still receive the real value, only the log is masked. Sibling of PR #1328's stringifyAttributeForLog (name-heuristic redaction for Fn::GetAtt attribute values; here the author told us explicitly, so no heuristic is needed). Live-verified: a --verbose deploy 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::AccessKey SDK provider (issue #1323) — src/provisioning/providers/iam-access-key-provider.ts (new), src/provisioning/register-providers.ts, schema fixture tests/fixtures/cfn-schemas/AWS-IAM-AccessKey.json (new), unit tests in tests/unit/provisioning/providers/iam-access-key-provider.test.ts (new), real-AWS fixture tests/integration/iam-access-key/ (new), rows in docs/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 pattern iam.AccessKey + secretsmanager.Secret({secretStringValue: key.secretAccessKey}) (found by the 2026-07-31 /hunt-bugs sweep). The provider maps CreateAccessKey (create; a Status: Inactive template gets a follow-up UpdateAccessKey, with delete-on-failure cleanup so a half-created key never strands outside state and burns the 2-keys-per-user quota), UpdateAccessKey (in-place Status flip — UserName/Serial are createOnly; a REMOVED Status is reset to the CFn default Active), and DeleteAccessKey (the owning user comes from state properties, recovered via GetAccessKeyLastUsed when absent; NoSuchEntityException = idempotent success after the shared region check). SecretAccessKey is a create-time-only attribute — CreateAccessKey is 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) and update() 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. Serial is declared HANDLED despite never being sent to IAM: its whole CFn semantic (change → new key) is the registry-schema createOnly replacement classification, and an unhandledByDesign listing would land it in the silent-drop set, where the #614 auto-route viability guard on a disableCcApiFallback NON_PROVISIONABLE type would wrongly HARD-REJECT any template using serial. Import is explicit-override-only (keys are not taggable; verified via GetAccessKeyLastUsed; the imported record has no cached secret, so Fn::GetAtt SecretAccessKey is documented as unresolvable for imported keys). Drift: readCurrentState resolves the owner via GetAccessKeyLastUsed + ListAccessKeys (paginated); Serial is declared drift-unknown (no read API). Three review-driven companions shipped in the same PR: (1) src/deployment/retryable-errors.ts gains the anchored IAM-propagation pattern The user with name — CreateAccessKey issued ~1s after the same deploy's CreateUser can race IAM's own eventual consistency with NoSuchEntity: 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's User with name X already exists. collision phrasing stays non-retryable); (2) a partial CreateAccessKey response carrying AccessKeyId but no SecretAccessKey now 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::GetAtt debug logging redacts credential-bearing attribute VALUES — stringifyAttributeForLog in src/utils/stringify.ts (name matches /secret|password|credential/i minus identifier suffixes like ...Arn/...Id) — because this PR is the first to cache a live long-lived credential in state attributes, and a --verbose deploy would have printed the usable IAM secret into terminals / CI logs at the three GetAtt resolution sites in src/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 in tests/unit/provisioning/cloudfront-distribution-provider.test.ts. delete() only entered the disable-then-wait path when the config read back Enabled: 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, and DeleteDistribution failed 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 same waitForDistributionStable(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 confirming GetDistribution read. More reachable post-#1282 (fire-and-forget default makes interrupted lifecycles on InProgress distributions more common).

  • BREAKING: CloudFront Distribution default completion is now fire-and-forget; --full-wait opts into the Deployed wait (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 in tests/unit/provisioning/cloudfront-distribution-provider.test.ts and the new mechanical backstop tests/unit/provisioning/full-wait-doc-coverage.test.ts (the --full-wait mirror of no-wait-doc-coverage.test.ts, added now that a second type joined ECS). create() no longer waits for Deployed by default (previously a 3-15 min wait, skippable only via --no-wait); it returns once CreateDistribution is accepted and prints an INFO line with the manual wait command (aws cloudfront wait distribution-deployed --id <id>) plus a --full-wait hint gated on CDKD_WAIT_FLAGS_AVAILABLE (issue #1291 pattern, so cdkd drift --revert never advertises a flag it does not declare). Under CDKD_FULL_WAIT the wait applies to BOTH create and update — update never waited before, so --full-wait is now full CloudFormation parity — with the ~20 min budget lifted by an explicit --resource-timeout per 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-wait timeout 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, default true) both wait here — so the wait-semantics rule in docs/cli-reference.md gained 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-wait by default?" (ACM / RDS / EC2 each fail condition (a)). The cloudfront benchmark scenario needs re-measuring into the two-row form (new default vs wait_for_deployment = false; --full-wait vs Terraform default) — tracked as a follow-up in the PR.

  • Destroy partial-failure guidance now names cdkd state orphan as 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 (the PartialFailureError messages), plus a unit test in tests/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 destroy drives 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::AnomalyDetector SDK provider (issue #1304) — src/provisioning/providers/cloudwatch-anomaly-detector-provider.ts (new), src/provisioning/register-providers.ts, schema fixture tests/fixtures/cfn-schemas/AWS-CloudWatch-AnomalyDetector.json (new), unit tests in tests/unit/provisioning/providers/cloudwatch-anomaly-detector-provider.test.ts (new), real-AWS fixture tests/integration/cloudwatch-anomaly-detector/ (new), rows in docs/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-bugs sweep). The provider maps PutAnomalyDetector (create; also the in-place update path — the registry schema marks every descriptor field createOnly, so only Configuration reaches update()) and DeleteAnomalyDetector (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-only Id attribute resolves to it. disableCcApiFallback = true per 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: CFn Configuration.ExcludedTimeRanges ISO-8601 strings become the SDK's Date, and CFn Configuration.MetricTimeZone (capital Z) is RE-KEYED to the SDK's MetricTimezone (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 in tests/unit/provisioning/ec2-provider-subnet-capture-readback.test.ts (new) and tests/unit/provisioning/ec2-provider-roundtrip.test.ts. Two coupled defects surfaced by a drift-revert-vpc integ failure (2026-07-30): (1) issue #1299 — createSubnet returned immediately after ModifySubnetAttribute(MapPublicIpOnLaunch=true), and the deploy engine's async observed-state capture (kickOffObservedCapture → DescribeSubnets) raced EC2's eventual consistency, occasionally persisting the stale pre-modify false as the drift baseline — every later cdkd drift then reported phantom false → true drift on a subnet nothing touched. create() (and the new update() path) now read back the attribute via a bounded best-effort waitForSubnetMapPublicIp poll (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 — updateSubnet rejected EVERY update as "Subnet properties are immutable", though MapPublicIpOnLaunch is mutable via ModifySubnetAttribute and Tags via CreateTags/DeleteTags — so cdkd drift --revert could not revert real (or phantom) MapPublicIpOnLaunch drift (PartialFailureError: 1 update-not-supported), and a template flip forced a full subnet replacement where CloudFormation updates in place. updateSubnet is now property-aware (diff-based modify + tag diff, zero mutating calls on a no-drift round-trip; changed VpcId / CidrBlock / AvailabilityZone still reject with ResourceUpdateNotSupportedError so replacement classification is preserved on the revert path), and replacement-rules.ts gains an explicit AWS::EC2::Subnet rule (replacementProperties: VpcId/CidrBlock/AvailabilityZone, updateableProperties: MapPublicIpOnLaunch/Tags) so the classification survives a cloudformation:DescribeType failure.

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 in tests/unit/provisioning/sns-subscription-provider.test.ts and the new real-AWS fixture tests/integration/sns-pending-subscription/. delete() called Unsubscribe unconditionally, but SNS rejects Unsubscribe for 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 destroy AND cdkd state destroy failed the resource on every retry (PartialFailureError, state preserved), and the only escape was cdkd state orphan. Found live by a /hunt-bugs sweep. 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 literal PendingConfirmation placeholder physical id (possible via cdkd import --resource) short-circuits before the API call the same way. Unlike the NotFound skip, no assertRegionMatch gate is needed — the error proves the subscription was positively found in the client's region.

  • --full-wait ECS 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 in tests/unit/provisioning/resource-timeout-registry.test.ts (new) and tests/unit/provisioning/ecs-service-full-wait.test.ts. ECSProvider.settleService hardcoded maxWaitTime: 600 for the --full-wait steady-state waiter, so --resource-timeout AWS::ECS::Service=20m lifted 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 shape slow-cc-operation-timeouts.ts closes for Cloud Control types. A new process-wide registry (setResolvedResourceTimeouts seeded by deploy / destroy / state destroy right after validateResourceTimeouts; resolvedResourceTimeoutMs(type) resolving per-type > explicit global > undefined) now feeds the waiter, whose cap is max(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::Metadata DescribeType 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 in tests/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 — updateForStack only ever touches the exports index (plus, on a first-ever call, a rebuild scan of state.json files, which the journal delete does not affect), and the journal delete reads nothing — so they now run under one Promise.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). The drainObservedCaptures step before saveState was 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-names migration pre-flight issued its own getState before 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, where findPendingPrefixRenames returns [] unconditionally. DeployEngineOptions gained an onCurrentStateLoaded(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 new createPrefixMigrationGate factory. 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 new DeployCancelledError, which the deploy CLI unwinds quietly (no error output, no FAILED run event), matching the plain early return the 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 → one AuthorizeSecurityGroupIngress PER RULE → RevokeSecurityGroupEgress → one AuthorizeSecurityGroupEgress PER 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 an IpPermissions ARRAY, so N rules became ONE call per direction. The revoke still strictly precedes the egress authorize, and an EMPTY SecurityGroupEgress: [] 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 existing isDefaultEgressRule predicate so the create path and the drift reverse-mapper cannot disagree about what "default" means. That skip deliberately does NOT fire for the CDK L2 allowAllOutbound: true shape, which stamps Description: 'Allow all outbound traffic by default' — the AWS default rule carries no description and AuthorizeSecurityGroupEgress rejects 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 (readCurrentState would report no Description while state records one). Since this file is in the integ-destroy markgate scope, the change needs a real-AWS integ before merge.

    (4) BUG: AWS::CDK::Metadata triggered a misleading warning on every deploy. The deploy-start create-only DescribeType prefetch iterated the RAW template type set, so it always included the AWS::CDK::Metadata sentinel CDK injects into every synthesized template. That type has no CloudFormation registry entry, so the lookup always failed — burning one API call and printing Failed 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 shared hasNoRegistrySchema(resourceType) predicate in describe-type.ts covering Custom::*, AWS::CloudFormation::CustomResource (previously a private helper inside create-only-properties.ts), and AWS::CDK::Metadata. Both DescribeType-backed resolvers short-circuit on it — write-only-properties.ts had 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: a cdkd deploy --verbose of a stack with one IAM Role + 3 InstanceProfiles + 3 t3.micro instances 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; RunInstances fired ~2.8s later and AWS rejected each instance three times with Value (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.ts now stores the message table as two composed halves — the exported IAM_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 single RETRYABLE_ERROR_MESSAGE_PATTERNS export, so retryability keeps ONE source of truth and the new isIamPropagationError(message) predicate only selects a CADENCE (a misfiled pattern can change the backoff shape, never whether an error is retryable at all). withRetry picks the schedule per attempt: propagation errors back off 0.25s -> 0.5s -> 1s -> 2s -> 2s ... (IAM_PROPAGATION_INITIAL_DELAY_MS 250, IAM_PROPAGATION_MAX_DELAY_MS 2_000, IAM_PROPAGATION_MAX_RETRIES 26 = 47.75s of sleep), everything else keeps the generic 1s -> 2s -> 4s -> 8s capped 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.75 vs the old 1, 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 rejected RunInstances itself 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 poll RunInstances at ~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 explicit maxRetries / initialDelayMs / maxDelayMs / isRetryable means 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, and describe-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 putting src/provisioning/providers/** into the diff (and its real-AWS integ gates) for; and CustomResourceProvider.invokeCustomResourceWithRetry has 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 in retry.test.ts + 17 classifier tests in retryable-errors.test.ts, taking the suite from 8108 to 8133 (dense schedule used for the exact measured wire message; generic schedule preserved for a Rate exceeded throttle and for a name-only ThrottlingException; 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 existing sleep test seam so nothing waits for real.

Recently Implemented (2026-07-28):

  • AWS::CloudFront::OriginAccessControl gets 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 a CreateOriginAccessControl call that returns synchronously. The Distribution references the OAC, so that latency delayed everything downstream. Same shape as the AWS::EC2::EIP provider (PR #1175), which cut ~23s to ~2.4s. create calls CreateOriginAccessControl and records the generated Id as the physical id; update fetches the current ETag with GetOriginAccessControl and passes it as IfMatch to UpdateOriginAccessControl (every config field is mutable in place, so an OAC is never replaced); delete does the same Get-then-Delete IfMatch dance and treats NoSuchOriginAccessControl from EITHER call as idempotent success, but only behind the shared assertRegionMatch() guard so a wrong-region destroy cannot silently strip the resource from state; getAttribute('Id') returns the physical id with no AWS call (Id is the type's only read-only attribute AND its primaryIdentifier); readCurrentState maps the AWS-current OriginAccessControlConfig back to CFn shape for cdkd drift (field names are identical on both sides, absent optionals dropped so they cannot fire phantom drift); import is explicit-override only, verified with a single GetOriginAccessControl (OACs carry no tags and the config's Name is 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 no unhandledByDesign entries: OriginAccessControlConfig is handled, Id is read-only. A missing required config field (Name / OriginAccessControlOriginType / SigningBehavior / SigningProtocol) raises a ProvisioningError naming 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: every cdkd deploy paid 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, then HeadBucket on cdkd-state-{acct}, then HeadBucket on the legacy cdkd-state-{acct}-{region}, then GetBucketLocation, then a THIRD HeadBucket on the bucket just probed, with a fresh GetCallerIdentity behind each ExpectedBucketOwner header. Three changes: (1) the two candidate-name HeadBucket probes are independent and now run under Promise.all; (2) verifyBucketExists({ existenceAlreadyProbed }) drops the duplicate HeadBucket when the bucket name came from the DEFAULT-name resolution AND that resolution's own probe came back clean — the GetBucketLocation + region-correct client rebuild still always runs, and the skip is gated by the exported stateBucketExistenceConfirmed(resolved), which requires BOTH a 'default' / 'default-legacy' source AND probe === 'ok'. ResolvedStateBucket gained an optional probe field ('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 second HeadBucket still 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) ExpectedBucketOwner resolution is now memoized by the resolved ACCESS KEY ID as well as per client, and the default-name resolution seeds it via recordResolvedAccountId(stsClient, accountId) with the account its own GetCallerIdentity just 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 own ASIA… key and still resolves the PRODUCER account, keeping the cross-account Fn::GetStackOutput RoleArn path 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 --profile identity, 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 skipped HeadBucket genuinely redundant rather than merely duplicated. Net: 3 fewer sequential AWS round trips per deploy (2 GetCallerIdentity + 1 HeadBucket), plus one probe-latency overlap. NOT applied to cdkd destroy / diff / import / state * — they call resolveStateBucketWithDefault and keep the unconditional verifyBucketExists, since their preflight is not on a latency-sensitive path.
  • AWS::EC2::Instance.AvailabilityZone is 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: AvailabilityZone was in neither handledProperties nor unhandledByDesign, and CDK's L2 ec2.Instance ALWAYS 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 for running, 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's aws-ec2/lib/instance.js AND by synthesizing a benchmark-shaped L2 instance (t3.micro + IAM role / instance profile + security group + encrypted EBS block device + userData + tags), which emits exactly AvailabilityZone / BlockDeviceMappings / IamInstanceProfile / ImageId / InstanceType / SecurityGroupIds / SubnetId / Tags / UserData -- AvailabilityZone is the ONLY one of those missing from the handled set (every other unhandled candidate is undefined unless 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: associatePublicIpAddress makes CDK drop SubnetId / SecurityGroupIds and emit NetworkInterfaces instead, which remains a silent drop (tracked separately). What shipped: a buildPlacement helper maps the CFn property onto RunInstances' Placement.AvailabilityZone (omitted entirely when absent, so no pointless empty Placement: {}), the property joins handledProperties, and readInstanceCurrentState reverse-maps it out of Placement so drift does not report a phantom diff. AvailabilityZone also joins the ReplacementRulesRegistry rule for this type (alongside the pre-existing EbsOptimized, plus ImageId / SubnetId / KeyName, which carried the same latent exposure). The create-only-properties.ts DescribeType fallback already classified all four from the registry schema's createOnlyProperties, but it degrades to an EMPTY list when cloudformation:DescribeType is unavailable -- and updateInstance silently 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 with provisionedBy: 'cc-api' stays on the CC path, so the improvement applies to newly created instances. Tests: the Placement mapping, the absent-property omission, a handledProperties assertion, a findActionableSilentDrops assertion against the full L2-emitted property set (the layer the routing decision actually reads), the readCurrentState reverse map, and the four-property replacement classification. The ec2-instance integ fixture now emits availabilityZone too, so the new Placement create path runs against real AWS and the provisionedBy assertion 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 as uniform_random(minDelay, min(minDelay * 2^(attempt-1), maxDelay)), so once the schedule saturates, the expected lag between a resource becoming ready and cdkd noticing is maxDelay / 2. At the SDK default maxDelay of 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 Instance running / terminated waits move from minDelay 5 / maxDelay 15 to 2 / 5, and NAT Gateway available / deleted from 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 reach running in 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.ts gained a rule that scans every {minDelay, maxDelay} waiter config in the provider tree and fails if any maxDelay exceeds 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) ELBv2Provider returned as soon as CreateLoadBalancer returned, on the header comment's premise that "ELBv2 Create* APIs are synchronous" — factually wrong for a LoadBalancer, which comes back State.Code: provisioning and 503s on the DNSName cdkd had just handed downstream, while CloudFormation AND Terraform both wait for active; (b) the EC2 Instance running wait was not gated on CDKD_NO_WAIT, so --no-wait silently did nothing on any stack containing an instance and the two benchmark columns measured the same thing; (c) docs/cli-reference.md claimed default waiting was "the same behavior as CloudFormation", which no longer held. What shipped: ELBv2 createLoadBalancer waits for active via waitUntilLoadBalancerAvailable (maxWaitTime 600 matching Terraform's aws_lb create timeout, minDelay 5 / maxDelay 10 per the #1177 poll-cap sweep), gated on CDKD_NO_WAIT and placed INSIDE the existing partial-create cleanup try/catch so a waiter timeout deletes the LB rather than stranding it for the next deploy to hit DuplicateLoadBalancerName; update is untouched (SetSubnets etc. act on an already-active LB). ECS Service KEEPS its non-wait default — CloudFormation waits for steady state and Terraform's wait_for_steady_state defaults to false, nothing downstream needs a steady service (Fn::GetAtt yields Name / ServiceArn immediately), 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 shared settleService helper prints the exact aws ecs wait services-stable --cluster X --services Y command on create AND update, and the new --full-wait flag opts into waitUntilServicesStable (with a best-effort DeleteService --force before failing a create, same partial-create reasoning as ELBv2). --full-wait and --no-wait are opposite ends of one axis and are rejected as a pair by validateWaitFlags, before applyRoleArnIfSet so the rejection really does precede every AWS call. A one-shot post-CreateService health 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.md replaces the flat --no-wait list 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-wait opts 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 separate aws_acm_certificate_validation resource cdkd has no equivalent of). Tests: three new unit suites pin the default wait, the --no-wait / --full-wait gate, 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). The ecs-fargate integ gained a Phase 0 rejecting --no-wait --full-wait and passes --full-wait on both the create and the CDKD_TEST_UPDATE redeploy, asserting a COMPLETED rollout with runningCount == desiredCount immediately after deploy returns — the only place the real waitUntilServicesStable call is exercised against AWS. A regression the gating itself introduced, fixed here (issue #1279): with the running wait skipped, createInstance's post-launch IAM-instance-profile association check ran against a pending instance, and AWS rejects AssociateIamInstanceProfile for anything not running / 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-wait asked to skip -- so the create FAILED and the stack rolled back. Every CDK L2 ec2.Instance gets an instance profile, so --no-wait was broken for all of them. The check is now skipped under --no-wait with a warning that names the instance and the exact describe-iam-instance-profile-associations / associate-iam-instance-profile commands 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 the ec2-instance integ. 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() passed InvokeMode / Cors into UpdateFunctionUrlConfig only 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 (shared clearOnUpdateRemoval helper, #1223): InvokeMode->'BUFFERED' (the documented default), Cors removal -> Cors: {} (live-probed: an empty Cors object clears CORS entirely; GetFunctionUrlConfig omits the field afterwards). The pre-existing Class-2 empty-placeholder sanitize is preserved by normalizing the all-empty readCurrentState Cors 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. The cloudfront-function-url integ gained a baseline URL-config assertion + a CDKD_TEST_REMOVAL=true phase 1b (drop both fields, assert InvokeMode back to BUFFERED + Cors gone). No deferrals: AuthType is CFn-required (always sent), TargetFunctionArn / Qualifier are 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 into UpdateLifecyclePolicy only 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 (shared clearOnUpdateRemoval helper, #1223): CreateInterval->1, RetainInterval->7, CopyTags->false, ExtendDeletion->false, CrossRegionCopyTargets->[], and Exclusions->the per-DefaultPolicy explicit-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-partial Exclusions needs no #1225-style handling — the API replaces the whole object (live-probed), so pass-through is already sub-field parity. The dlm-lifecycle-policy integ gained a second always-DISABLED VOLUME default policy + baseline assertions + a CDKD_TEST_REMOVAL=true phase 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) elasticache batch of the #1160 umbrella audit (absent-field removal silent-drop bug class, reference fix LambdaFunctionProvider #1157). updateCacheCluster / updateSubnetGroup passed optional fields into ModifyCacheCluster / 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 (shared clearOnUpdateRemoval helper, #1223): cluster — SnapshotRetentionLimit->0 (CFn default; SDK doc: 0 turns backups off), AutoMinorVersionUpgrade->true (service create-default, live-verified 2026-07-27), NotificationTopicArn removal -> the documented disable sentinel NotificationTopicStatus: '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), and LogDeliveryConfigurations per-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 removed Description/CacheSubnetGroupDescription resets to the same Subnet group for ${logicalId} default createSubnetGroup synthesizes (create/update parity). readCurrentState now gates its NotificationTopicArn emit on TopicStatus !== '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 on dual_stack clusters 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, redis cache.t3.micro single node): phase 1 deploy with SnapshotRetentionLimit: 3 + a same-stack SNS NotificationTopicArn (AutoMinorVersionUpgrade omitted — DescribeCacheClusters reads the create default true), phase 2 adds AutoMinorVersionUpgrade: false, phase 3 DROPS all three plus the subnet-group Description and DescribeCacheClusters/DescribeCacheSubnetGroups confirm retention 0, topic status inactive, AMVU back to true, 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 / updateDBSubnetGroup passed optional fields into merge-semantics Modify APIs (absent = "no change"), so a property DROPPED from the template silently kept its old live value — worst case DeletionProtection: the user removes it, cdkd reports success, but the live cluster (or Neptune instance) still refuses deletion. What shipped: the shared clearOnUpdateRemoval helper resets the safely-resettable fields to their documented CFn/API defaults — Neptune cluster: DeletionProtection->false, BackupRetentionPeriod->1, IamAuthEnabled (-> Modify's EnableIAMDatabaseAuthentication)->false; Neptune instance: DeletionProtection->false, AutoMinorVersionUpgrade->true (CreateDBInstance doc: "Default: true"); DocDB cluster: DeletionProtection->false, BackupRetentionPeriod->1; both families' DBSubnetGroup: a removed DBSubnetGroupDescription resets to cdkd's create-time Subnet 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-dependent default.* 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), DocDB MasterUserPassword (secret), DocDB instance AutoMinorVersionUpgrade (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 shared docdb-neptune integ gained a CDKD_TEST_REMOVAL=true phase: the baseline deploy sets DeletionProtection: true + BackupRetentionPeriod: 7 on both clusters (+ IamAuthEnabled: true on Neptune), the removal redeploy DROPS them, verify.sh polls DescribeDBClusters until both clusters read the CFn defaults, and the destroy runs WITHOUT --remove-protection as 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: NotFound as 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's No 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 — a CloudControlOperationFailedError from an async FAILED DELETE carrying ccErrorCode === 'NotFound' — under the same assertRegionMatch guard as the string forms; (2) cleanupFailedCreateRemnant's catch downgrades service-worded not-found messages (new exported isNotFoundMessage, which adds the no ... found shape) 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's integ-destroy scope gains src/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, isNotFoundMessage positive/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 / FifoThroughputLimit reset 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 a SQS_ATTRIBUTE_REMOVAL_RESET entry, so dropping them from the template silently kept the live high-throughput config (SetQueueAttributes merges absent attributes) while CloudFormation resets them. What shipped: DeduplicationScope -> 'queue' and FifoThroughputLimit -> '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 as ContentBasedDeduplication (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 kept DeduplicationScope: 'messageGroup' accepted; only-DeduplicationScope-removed while the template KEEPS FifoThroughputLimit: '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). The fifo-sqs-event-source integ baseline now sets deduplicationScope: MESSAGE_GROUP + fifoThroughputLimit: PER_MESSAGE_GROUP_ID and the CDKD_TEST_REMOVAL=true phase 1b drops all three FIFO attributes, polling until CBD/scope/limit read false/queue/perQueue with an unchanged CreatedTimestamp (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 #1238 NAMED_REPLACEMENT_IDEMPOTENT_CREATE guard (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 --replace delete-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, if createResult.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 at cdkd 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). An update() 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 ContentBasedDeduplication classified 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. The AWS::SQS::Queue rule listed ContentBasedDeduplication in replacementProperties, but CloudFormation documents it as "Update requires: No interruption" and the registry schema's createOnlyProperties is only [FifoQueue, QueueName] — SetQueueAttributes flips it in place on a live FIFO queue (live-verified 2026-07-27 via raw SDK and again via cdkd 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's ContentBasedDeduplication -> 'false') unreachable through a plain deploy. What shipped: ContentBasedDeduplication moved to updateableProperties (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 / FifoThroughputLimit were 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.ts pins CBD as in-place UPDATE + isClassified true + QueueName/FifoQueue still replacement (fails-without-fix proven via temporary revert: 1 failed / restored 11/11). The fifo-sqs-event-source integ gained a CDKD_TEST_REMOVAL=true phase 1b that DROPS contentBasedDeduplication on redeploy and asserts the live queue reads false (bounded 60s retry per SetQueueAttributes propagation docs) with an unchanged CreatedTimestamp (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 explicit QueueName underwent a property-driven replacement; SQS CreateQueue is 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-existing NAMED_REPLACEMENT_COLLISION handling (#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 --replace fails the resource with the actionable NAMED_REPLACEMENT_IDEMPOTENT_CREATE error (rename / remove the explicit name, or re-run with cdkd deploy --replace) — no delete is issued, the old resource stays live, and its state record is preserved verbatim; (b) under --replace the engine falls back to the same delete-first + re-create sequence as the collision path (now shared via replaceDeleteFirstAndRecreate, covering the SQS ~60s QueueDeletedRecently cooldown); (c) UpdateReplacePolicy: Retain hard-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 (--replace fallback, --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 → --replace re-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) secretsmanager batch of the #1160 umbrella audit (absent-field removal silent-drop bug class, reference fix LambdaFunctionProvider #1157). update() gated Description / KmsKeyId on !== undefined (KmsKeyId additionally skipping '') into UpdateSecret, 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 shared clearOnUpdateRemoval helper (#1223) with clear value '' — Description: '' clears the description, and KmsKeyId: '' 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/secretsmanager also 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 latent drift --revert gap 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). The secrets-dynamic-ref integ gained baseline Description/KmsKeyId assertions and a CDKD_TEST_REMOVAL=true phase 2 that drops both and asserts DescribeSecret reports the pristine shape (both absent), then destroys clean. Deferred (in-code rationale): Type keeps 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) sqs batch of the #1160 umbrella audit. SQS_ATTRIBUTE_REMOVAL_RESET (the provider's local removal-reset map) covered 9 attributes but missed ContentBasedDeduplication and SqsManagedSseEnabled, 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) and SqsManagedSseEnabled->'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-empty KmsMasterKeyId — live probe confirmed AWS rejects KmsMasterKeyId + 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). The sns-sqs-event integ gained an SSE-removal L1 queue (SqsManagedSseEnabled: false) and a CDKD_TEST_REMOVAL=true phase 2 that drops the property and asserts the live queue reads true again with an unchanged CreatedTimestamp (in-place, replacement-guarded re issue #1238), then destroys clean. Known limitation (filed): the ContentBasedDeduplication reset 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-role batch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fix LambdaFunctionProvider #1157). IAMRoleProvider.update() gated Description / MaxSessionDuration on !== undefined into UpdateRole, 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 shared clearOnUpdateRemoval helper (#1223): Description->'' (the documented clear sentinel), MaxSessionDuration->3600 (the IAM/CFn default). Never-present fields stay absent from the input, and the drift-revert Description: '' 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. The iam-role-prefixed-name-update integ gained a CDKD_TEST_REMOVAL=true phase 3 (kept separate from CDKD_TEST_UPDATE so the migration-prompt regression assertions stay non-vacuous): phases 1-2 set description + 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-errors annotation flags on synth / deploy (issue #1230, CDK CLI parity follow-up to #1228) — src/synthesis/stack-messages.ts (StackMessageOptions + failAt resolution), src/cli/options.ts (shared annotationMessageOptions), src/cli/commands/synth.ts + deploy.ts wiring, 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 (validateMetadataFailAt in aws-cdk/lib/cli/cdk-toolkit.ts) + empirically (the originally-planned --no-validation turned out to control the validateOnSynth policy-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] … via logger.error, matching the CDK CLI's log-lines-then-bare-Found errors output shape) and resolves the failure threshold exactly like the CLI: default fails on errors (Found errors); --strict also fails on warnings (Found warnings (--strict mode); errors win when both exist; infos never fail); --ignore-errors displays 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 on synth AND deploy via 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, bare Found errors thrown). Live-verified against aws-cdk-lib 2.248.0 side-by-side with the real CDK CLI: --ignore-errors exits 0 on an addError app (error line still displayed), --strict exits 1 on a warning-only app with the exact upstream message, on both synth and deploy --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, display addWarning / addInfo (issue #1228) — new src/synthesis/stack-messages.ts, src/synthesis/assembly-reader.ts (StackInfo.messages), src/types/assembly.ts (ArtifactManifest.additionalMetadataFile), src/cli/commands/synth.ts + deploy.ts wiring, tests/unit/synthesis/stack-messages.test.ts. Why: cdkd previously ignored CDK Annotations entirely — a stack carrying an error annotation (the mechanism L2 constructs/aspects use to mark must-not-deploy configurations) sailed through cdkd synth / cdkd deploy with exit 0 and got deployed, where the CDK CLI refuses with Found errors exit 1; warnings/infos were never shown. What shipped: collectStackMessages reads annotation entries from BOTH cloud-assembly layouts — the artifact's inline metadata (older aws-cdk-lib) and the <artifactId>.metadata.json side file referenced by additionalMetadataFile (current aws-cdk-lib; a referenced-but-unreadable side file throws, fail-closed) — into StackInfo.messages. processStackMessages prints [Warning at /path] … / [Info at /path] … and throws SynthesisError with the CDK CLI format ([Error at /path] … + Found errors, exit 1) when any stack in the given set carries an error. synth checks all synthesized stacks; deploy checks 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 (upstream cdk diff does 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): ASG InstanceMaintenancePolicy kept-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); ASG CapacityReservationSpecification kept-partial is UNPROBED (a live probe needs a billed Capacity Reservation) and passes through verbatim (pinned); ASG AvailabilityZoneDistribution has a single sub-field (no partial shape). ECS DeploymentConfiguration'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 Lambda ImageConfig kept-partial probe. No CLI flag, dependency, or state-schema change.
  • Shared clearOnUpdateRemoval helper extracted (issue #1223) — new src/provisioning/update-removal.ts, edits to src/provisioning/providers/{lambda-function,ecs,rds,asg}-provider.ts, new tests/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 of clearOnUpdateRemoval<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 in src/provisioning/update-removal.ts with 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) asg batch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fix LambdaFunctionProvider #1157, pattern twin of the ECS batch #1164). ASGProvider.update() spread every optional field into UpdateAutoScalingGroup only 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: the clearOnUpdateRemoval(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), and AvailabilityZoneImpairmentPolicy (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). The launchtemplate-asg-inplace integ gained a CDKD_TEST_REMOVAL=true phase 3 (kept separate from CDKD_TEST_UPDATE so the #985 only-instanceType-changed assertion stays non-vacuous): phases 1-2 set non-default HealthCheckGracePeriod/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) rds batch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fix LambdaFunctionProvider #1157, pattern twin of the ECS #1164 / ASG batches). updateDBCluster / updateDBInstance passed optional fields into ModifyDBCluster / ModifyDBInstance, which MERGE (absent = "no change"), so a property DROPPED from the template silently kept its old live value — worst case DeletionProtection: the user removes it, cdkd reports success, but the live cluster/instance still refuses deletion. What shipped: the clearOnUpdateRemoval(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. The rds-aurora integ (ModifyDBCluster) and rds-dbinstance-backfill integ (ModifyDBInstance) each gained a CDKD_TEST_UPDATE=true phase: phase 1 sets DeletionProtection: true + EnableIAMDatabaseAuthentication: true, phase 2 DROPS both and polls DescribeDB* until both read false, and the phase-3 destroy runs WITHOUT --remove-protection as 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: setResponseBucket drops 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 }) in setResponseBucket) was built on the DEFAULT credential chain, dropping --profile — and with reuseClientCredentials: true the 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 lazy ensureResponseClient() 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 shared AwsClients.s3 client, so --profile / static credentials carry into both the GetBucketLocation probe 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 the bench-cdk-sample integ run cross-region (AWS_REGION=us-west-2 against the us-east-1 state bucket — its Custom::VpcRestrictDefaultSG custom resource exercises the presign path without the hint) with a clean destroy. Internal API change only (setResponseBucket / setCustomResourceResponseBucket are not user-facing); no CLI flag, dependency, or state-schema change.
  • Custom-resource ResponseURL presign 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-signed ResponseURL PUT 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 placeholder PutObject. The state backend (#60), LockManager (#803), and ExportIndexStore (#819) already region-correct via the shared rebuildClientForBucketRegion helper (#827); the CR response path was the one remaining un-migrated state-bucket consumer. What shipped: a lazy, memoized ensureResponseClient() (mirroring the siblings' clientResolved / single-flight resolveInFlight pattern) runs before the first response-bucket S3 operation in generateResponseURL, resolving the bucket's actual region via the cached GetBucketLocation probe and swapping in a region-corrected client (reuseClientCredentials; tolerateNonStandardClient so test doubles degrade to no-rebuild; the shared AwsClients.s3 original is never destroyed). setResponseBucket's bucketRegion argument 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 the custom-resource-provider integ run cross-region (AWS_REGION=us-west-2 against 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 rollback reverse-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 in tests/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 replaying provider.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): classifyRollbackOp now routes a replacement op (previousState.physicalId !== op.physicalId) to a reverse-replacement: re-CREATE the old resource from its journaled previousState (routed via the recorded old provisionedBy layer) 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: Retain is 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 as failedOperations (ADDITIVE journal field, no journalVersion bump — old binaries ignore it): logicalId, changeType, previousState, physicalId, provisionedBy, and the intrinsic-RESOLVED attemptedProperties (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-apply previousState; 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.setRollbackJournalFailedOperations persists 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 with previousState + attemptedProperties, failed-only segment), and command plumbing (--revert-failed on/off/failure). No CLI-breaking change, no state-schema bump, no journal-version bump.
  • Standalone cdkd rollback command to revert a failed --no-rollback / interrupted deploy (issue #1183) — new src/cli/commands/rollback.ts, new src/deployment/rollback-executor.ts (extracted from deploy-engine.ts), new src/types/rollback-journal.ts, plus edits to src/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; new tests/integration/rollback-command/; unit tests under tests/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 of cdk rollback / CloudFormation RollbackStack). What shipped: the deploy engine now persists a rollback journal (s3://bucket/cdkd/{stack}/{region}/rollback-journal.json, a sibling of state.json, its own journalVersion — 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 one segment (a verbatim CompletedOperation[]) per failed attempt; consecutive failed deploys append. The new cdkd rollback [STACK] command (synth-free) loads the journal and replays it newest-first via the shared rollback-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.json is deleted too. Flags: --force, --orphan <logicalId> (repeatable, like cdk 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 with DeletionPolicy: Retain/Snapshot is 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 by cdkd destroy / cdkd state destroy (via deleteState, which now sweeps the journal key). cdkd export warns + confirms if a journal exists. DeploymentRunCommand gained the 'rollback' literal (additive, no event-schema bump) so cdkd events renders 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 + deleteState sweep, the engine's per-reason segment writes + initialDeploy, and the command's resolution / error / terminal paths; the rollback-command integ fixture exercises a real --no-rollback failure → cdkd rollback → assert reverted + journal gone (incl. an initialDeploy scenario that deletes state.json). No state-schema bump.
  • AWS::Lambda::EventSourceMapping caches EventSourceMappingArn for Fn::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 }, so Fn::GetAtt [Esm, EventSourceMappingArn] missed the cached-attribute path; the physical id is the ESM UUID (not ARN-shaped) and the resolver's constructAttribute has no ESM branch, so the resolver's shape guard HARD-FAILED the deploy (the #1179 GetAtt-key class, surfaced by the #1187 sdk-attr-coverage critic). What shipped: both create() and update() now record EventSourceMappingArn under its exact CFn read-only name from the SDK response (create reads response.EventSourceMappingArn; update already read updateResp.EventSourceMappingArn for the tag diff), so the ARN resolves from cached state. The #1187 lint's SDK_ATTR_ALLOW_LIST entry that tracked this as a KNOWN GAP was removed, so the audit:sdk-attr-coverage:check critic now verifies the ARN stays cached (a regression re-flags it). Tests: 2 new unit tests assert both create() and update() return { Id, EventSourceMappingArn }; the eventsourcemapping-race integ gained a CfnOutput on attrEventSourceMappingArn so the real-AWS deploy exercises Fn::GetAtt EventSourceMappingArn (pre-fix this failed to resolve). No CLI flag, dependency, or state-schema change; the import() path (override-only) is unchanged.
  • AWS::BedrockAgentCore::Runtime import() 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, so Fn::GetAtt [Runtime, AgentRuntimeArn] resolves from state. An imported runtime (cdkd import --resource <logicalId>=<agentRuntimeId>) recorded only { AgentRuntimeId }, so the same Fn::GetAtt missed the cache and fell through to the live getAttribute() path on every reference — correct (the #1179 fix widened getAttribute to resolve the full set), just an extra GetAgentRuntime per distinct attribute reference instead of a cached read. What shipped: import() now issues one GetAgentRuntime for the supplied physical id and populates attributes via the same buildAttributes() helper create() / update() use, so imported runtimes match deploy-created runtimes' cached-attribute behavior. Enrichment is best-effort: any failure (throttle, transient error, a --resource id that does not resolve) falls back to the prior minimal { AgentRuntimeId } record with a debug log — import never fails on the enrichment, and the live getAttribute() path still resolves the ARN. WorkloadIdentityDetails is not populated on the import path (GetAgentRuntime does not return it — create/update-response field only). Tests: 2 new / rewritten import() unit tests assert the enriched attribute map on a successful GetAgentRuntime and 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::Runtime enriches its read-only Fn::GetAtt attributes 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 key Arn, but CloudFormation's read-only attribute name for this type is AgentRuntimeArn. Output / cross-resource Fn::GetAtt resolution looks the cached attribute up BY CFn name (IntrinsicFunctionResolver.constructAttribute never calls a provider's getAttribute), so Fn::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 — a CfnOutput referencing the ARN broke the deploy. What shipped: a shared buildAttributes(response, agentRuntimeName) helper (used by both create() and update()) now records the type's registry-schema readOnlyProperties under their exact CFn names — AgentRuntimeArn / AgentRuntimeId / AgentRuntimeVersion / Status / CreatedAt / WorkloadIdentityDetails (the last two only when the SDK response carries them; WorkloadIdentityDetails is re-shaped to PascalCase via camelToPascalCaseKeys), plus AgentRuntimeName. getAttribute() (the live-fetch path used by e.g. cdkd orphan) was widened to the same attribute set via one GetAgentRuntime call, keeping the legacy Arn name 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 leftover Arn key) and the widened getAttribute resolution. No CLI flag, dependency, or state-schema change; existing state files carrying the old Arn key are unaffected (the next deploy re-writes the attributes).

Recently Implemented (2026-07-23):

  • ECS readCurrentState reverse-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 by cdkd 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 template properties, or observedProperties). EphemeralStorage was 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 template properties (PascalCase) — the observedProperties-absent window (state from an older binary, a capture failure, or before observed-capture existed) — cdkd drift compared {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 existing camelToPascalCaseKeys (the inverse of the converter #1165 used) for AWS::ECS::Service DeploymentConfiguration / CapacityProviderStrategy / PlacementConstraints / PlacementStrategy + PlacementStrategies / ServiceRegistries / NetworkConfiguration / LoadBalancers, AWS::ECS::TaskDefinition RuntimePlatform / ProxyConfiguration / PlacementConstraints, and AWS::ECS::Cluster Configuration / DefaultCapacityProviderStrategy. DeploymentConfiguration.LifecycleHooks[].HookDetails (a free-form document) is preserved verbatim; ProxyConfiguration uses an explicit reverse-map (proxyConfigurationToCfn) because the SDK field properties maps back to CFn ProxyConfigurationProperties. Scope note: the whole ContainerDefinitions blob is still surfaced as raw SDK camelCase (a much larger structure needing the full inverse of convertContainerDefinitions, incl. the nested LinuxParameters / 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 whose observedProperties were captured in camelCase will phantom-drift ONCE against the new PascalCase read until its next deploy re-captures observedProperties (self-healing) — negligible in practice since these nested fields only became functional in #1165 (any real use requires a re-deploy anyway). Tests: 3 new readCurrentState unit tests feed camelCase SDK responses and assert PascalCase output for Cluster / Service / TaskDefinition (incl. HookDetails-verbatim and ProxyConfigurationProperties); two pre-existing round-trip tests that asserted the old camelCase read output were flipped to PascalCase. The ecs-service-update-props integ gained a phase 1b that asserts the deploy-time observedProperties baseline (captured FROM readCurrentState) carries RuntimePlatform.CpuArchitecture in 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-stack cdkd drift-clean assertion was deliberately not used: the same fixture's ContainerDefinitions — 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-ecs uses camelCase input shapes while CloudFormation templates carry PascalCase keys. ECSProvider passed 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 diff said "No changes", so the divergence was permanent and invisible — the SET-path twin of #1160). Confirmed live against real AWS 2026-07-22 for AWS::ECS::Service.DeploymentConfiguration (a custom minHealthyPercent / 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-impact TaskDefinition.RuntimePlatform (a CpuArchitecture: ARM64 Graviton task registered as the default X86_64) and EphemeralStorage. What shipped: PascalCase->camelCase converters wired on both create() and update() for AWS::ECS::Service DeploymentConfiguration / CapacityProviderStrategy / PlacementConstraints / PlacementStrategies / ServiceRegistries, AWS::ECS::TaskDefinition RuntimePlatform / EphemeralStorage / ProxyConfiguration / PlacementConstraints / (per-container) LinuxParameters + LogConfiguration.SecretOptions, and AWS::ECS::Cluster Configuration / DefaultCapacityProviderStrategy. The pure first-letter-flip fields reuse the shared recursive pascalToCamelCaseKeys converter (so fields CDK/CFn add later — e.g. the blue/green Strategy / LifecycleHooks — convert automatically instead of re-introducing the drop), with DeploymentConfiguration.LifecycleHooks[].HookDetails (a free-form __DocumentType) preserved verbatim so its user-defined keys are NOT case-flipped. ProxyConfiguration needs an explicit converter because the CFn key ProxyConfigurationProperties maps to the SDK's properties field (not a first-letter flip). NetworkConfiguration / LoadBalancers were already converted. DeploymentConfiguration removal-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 passes undefined (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. The ecs-service-update-props integ fixture (SDK-routed by design) was extended to set a custom DeploymentConfiguration (create + a phase-2 change) and a Graviton RuntimePlatform + EphemeralStorage, asserting AWS describe-services / describe-task-definition return 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 Service batch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fix LambdaFunctionProvider #1157). ECSProvider.updateService() passed properties['X'] as T | undefined straight into UpdateService, 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, next cdkd diff said "No changes", divergence baked in invisibly. What shipped: a clearOnUpdateRemoval(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), and CapacityProviderStrategy / 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 / ServiceRegistries already clear via #975. Fields left untouched (immutable / required / always-carried): ServiceName (immutable, already guarded), Cluster / TaskDefinition / DesiredCount / NetworkConfiguration, SchedulingStrategy (create-only). DeploymentConfiguration removal is DEFERRED — its correct reset requires the full default shape (live-probed: it merges at the SUB-FIELD level, so a partial reset leaves deploymentCircuitBreaker / alarms stuck), and it is entangled with a separate pre-existing CFn-PascalCase (MaximumPercent) -> SDK-camelCase (maximumPercent) nested-object conversion gap (the field is passed raw, unlike NetworkConfiguration which 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 existing ecs-service-update-props integ fixture extended with a #1160 removal phase (phase 1 sets PlatformVersion: 1.4.0 + HealthCheckGracePeriodSeconds: 30 via the L1 escape hatch; phase 2 drops them and asserts AWS reset to LATEST / 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 fix LambdaFunctionProvider #1157). Every AWS::ApiGatewayV2::* update() passed properties['X'] as T | undefined straight into UpdateApi / 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, next cdkd diff said "No changes", divergence baked in invisibly. What shipped: a shared clearableUpdate(next, previous, resetValue) helper (the apigatewayv2 twin of Lambda's clearOnUpdateRemoval) 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): Api Description/Version->'', DisableExecuteApiEndpoint->false, IpAddressType->ipv4, ApiKeySelectionExpression->WebSocket default; Stage AutoDeploy->false; Integration Description->''; Route AuthorizationType->NONE, AuthorizerId->'', AuthorizationScopes->[], OperationName->''; Authorizer AuthorizerCredentialsArn/AuthorizerPayloadFormatVersion/IdentityValidationExpression->'', AuthorizerResultTtlInSeconds->0. CorsConfiguration removal is the one field an empty Cors in UpdateApi does NOT clear (live-probed), so it is cleared out-of-band via DeleteCorsConfiguration. StageVariables and Integration RequestParameters also merge per-key ({} does not clear), so a new mapWithRemovals(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 Stage Description (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 CorsConfiguration DeleteCorsConfiguration path, 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::MicrovmImage drift detection + --no-wait docs 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: no readCurrentState (so cdkd drift reported the type as drift-unknown), the type was missing from the user-facing --no-wait resource lists (the user caught this — and the audit found DocDB / Neptune / CertificateManager::Certificate were ALSO pre-existing gaps in those lists), and the test-only poll-config env vars produced NaN (spurious immediate timeout) on a non-numeric value. What shipped: (1) readCurrentState maps GetMicrovmImage + ListTags back to the Name + Tags cdkd stores; the entire writeOnly build config (BaseImageArn / CodeArtifact / Logging / ...) is declared via getDriftUnknownPaths so it never fires false-positive drift (GetMicrovmImage can'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-wait table + intro in cli-reference.md, the README feature bullet, and the noWaitOption help + JSDoc. A new mechanical test no-wait-doc-coverage.test.ts scans providers for process.env['CDKD_NO_WAIT'] and fails CI if any handled type is absent from the cli-reference --no-wait table (coverage floor >= 8); step 7 of the "Adding a New SDK Provider" checklist in .claude/rules/providers.md names the four doc surfaces. (3) The poll-config reads go through positiveIntFromEnv (guards NaN / non-positive). Tests: 5 new provider unit tests (readCurrentState Name+Tags / aws:-tag strip / gone -> undefined / getDriftUnknownPaths / poll-config NaN guard) + the mechanical --no-wait doc 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, no Variables key — a hand-written L1 / imported-template shape CDK never emits) passed through update() verbatim; live-probed 2026-07-22: UpdateFunctionConfiguration KEEPS the old env vars for a Variables-less Environment, while the template's declarative meaning is "no env vars" — the same absent-means-keep hazard #1155 fixed, one level down. (2) The LoggingConfig removal reset {LogFormat:'Text'} was suspected of not restoring a custom LogGroup to the /aws/lambda/<fn> default (subfield merge). What shipped: (1) normalizeEnvironmentForUpdate rewrites a Variables-less Environment to the explicit-clear {Variables: {}} before the clearOnUpdateRemoval pass; populated / absent shapes pass through unchanged. (2) The LoggingConfig suspicion was REFUTED by a live probe (custom LogGroup set → {LogFormat:'Text'}-only update → LogGroup reverted to /aws/lambda/<fn>): the API replaces the LoggingConfig object wholesale, so the existing reset value is correct and no code change was needed. Also: docs/provider-development.md gains §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-less Environment → {Variables:{}} on the wire, verified to fail without the fix; populated Environment passes through un-normalized). No new dependency, CLI flag, or state-schema change.
  • AWS::Lambda::MicrovmImage import support + --no-wait integ 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 (so cdkd import reported the type unsupported), and the headline --no-wait behavior (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-only import() — adopt an existing image by ARN via --resource <logicalId>=<arn> (GetMicrovmImage confirms existence; a bare name is rejected since GetMicrovmImage requires the ARN; returns null when 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-wait phase: after the waited-create + tags-update + destroy, it re-deploys with --no-wait, asserts the image is still CREATING immediately after cdkd returns (proving cdkd did NOT wait for CREATED, unlike the always-polling CC fallback), waits for CREATED, 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-wait integ 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-set Timeout / MemorySize / Environment (whole block) / EphemeralStorage from the template left the old value live on AWS: update() passed those fields straight through as undefined, and UpdateFunctionConfiguration treats an ABSENT field as "no change" — while CloudFormation resets it to the property default. The deploy reported updated + success, state dropped the field, and the next cdkd diff said "No changes", so the divergence was baked in invisibly. The provider already had the right mechanism (clearOnUpdateRemoval, used by DeadLetterConfig / KMSKeyArn / FileSystemConfigs / ImageConfig / SnapStart / LoggingConfig), but seven fields were missed; the existing lambda-env-removal integ only covers removing one KEY from a still-present Environment.Variables map, not the whole block. What shipped: Timeout (reset 3), MemorySize (128), Description (''), Environment ({Variables: {}}), Layers ([]), TracingConfig ({Mode: 'PassThrough'}), and EphemeralStorage ({Size: 512}) now route through clearOnUpdateRemoval in update()'s UpdateFunctionConfigurationCommandInput. Never-present fields still pass undefined (no spurious reset); Role / Handler / Runtime stay direct pass-throughs (CFn requires them for their package type, so removal is not a valid template transition); Architectures removal was already handled on the UpdateFunctionCode path. Tests: three new unit tests (all-seven removal → exact reset values; never-present → all seven stay undefined; mixed kept/removed → kept fields pass through unchanged while removed ones reset), the first verified to fail without the fix. Real-AWS integ tests/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::MicrovmImage SDK 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, so cdkd deploy --no-wait had no effect on it (the build is a multi-minute async operation, exactly where --no-wait is useful). What shipped: a dedicated SDK provider on the lambda-microvms service (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: {}}, ...), calls CreateMicrovmImage, then polls GetMicrovmImage until the image reaches CREATED (throws on CREATE_FAILED). The poll is gated on CDKD_NO_WAIT — so --no-wait returns right after CreateMicrovmImage (state CREATING) with the image ARN already resolved. update() routes a build-affecting change through UpdateMicrovmImage (the async rebuild UPDATING -> UPDATED) and defensively refuses an in-place Name change (create-only; cdkd's getCreateOnlyPropertyPaths already routes it to replacement). Tags are reconciled out-of-band via TagResource / UntagResource (UpdateMicrovmImage has no tags field), so a tags-only change updates the tags WITHOUT an image rebuild. delete() calls DeleteMicrovmImage and polls until the image 404s (async DELETING -> DELETED) so destroy leaves no orphan. Physical id is the image ARN (primaryIdentifier); getAttribute exposes ImageArn / 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. the Logging.Disabled tagged-union + Tags/EnvironmentVariables list->record mappings, async CREATING->CREATED poll, CREATE_FAILED/DELETE_FAILED, --no-wait skips the poll, create-only Name guard, idempotent NotFound delete). Real-AWS integ tests/integration/lambda-microvm-image/ uploads a Dockerfile+app.js code artifact to S3, deploys (real Firecracker snapshot build to CREATED), asserts the ARN physicalId + MicrovmImageArn output parity + GetMicrovmImage state, then destroys clean (image 404s, state removed). aws-cdk-lib has no CfnMicrovmImage yet, so the fixture (and real users) declare the resource via the CfnResource escape 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, so cdkd deploy/diff of a macro-FREE stack failed (or paid CFn round-trips) whenever a sibling carried a Transform, and cdkd list of 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's AWS::EarlyValidation::ResourceExistenceCheck hook 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.deferMacroExpansion skips the pre-pass inside synthesize(); expandMacrosForStacks is now PUBLIC and deploy / diff call it after stack selection with exactly the final target set (deploy: incl. auto-included dependency stacks), while list and destroy defer and never expand (destroy works off cdkd state and its cross-stack ordering scan reads the raw templates' literal Fn::ImportValue / Fn::GetStackOutput markers, 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 inside expandMacrosForStacks and runs only when a selected stack actually carries a macro (a plain -a cdk.out read no longer pays STS). (2) The macro region chain falls back to resolveSdkDefaultRegion(profile) — a throwaway STS client's config.region() provider, i.e. the exact chain every provisioning client uses — before hard-erroring; the synth-branch CDK_DEFAULT_REGION resolution gains the same fallback (matches the CDK CLI, which passes the profile-resolved region to the app subprocess). (3) expandMacros retries a changeset FAILED whose StatusReason names AWS::EarlyValidation::* up to 3 attempts (fresh transient stack name each attempt, 2s/4s backoff via the retryDelays.sleep test seam); non-EarlyValidation failures still surface immediately. One deliberate edge: with expansion now selection-scoped, a macro-generated Outputs[*].Export.Name on an UNSELECTED sibling is no longer visible to deploy's cross-stack auto-include inference (it reads the raw template); a raw Fn.importValue of a transform-generated export without addDependency would 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, listStacks needs 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: Retain in the stateful guard — src/deployment/deploy-engine.ts, tests/unit/deployment/resource-replacement.test.ts. Why: the update-policy-mutations integ FAILed at DEPLOY on 2026-07-21 (surfaced by the coverage sweep). A template immutable-property change (e.g. an S3 BucketName suffix 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 called isStatefulRecreateTargetForReplace — which conservatively treats ANY S3 bucket as stateful — BEFORE reading the resource's UpdateReplacePolicy. When the policy is Retain, 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, throwing STATEFUL_REPLACE_BLOCKED and blocking the documented orphan-on-replace path (Retaining old ... - UpdateReplacePolicy: Retain). What shipped: the updateReplacePolicy read 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 --replace opt-in guard is unchanged: its fallback unconditionally deletes the old resource first regardless of UpdateReplacePolicy, 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 the update-policy-mutations integ (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: the opensearch-domain-getatt integ FAILed at DESTROY on 2026-07-20 — an AWS::OpenSearchService::Domain deletion routinely runs 15-30 min, but the CloudControlProvider's internal poll cap (MAX_WAIT_TIME_MS) is a flat 15 min, so waitForOperation threw DELETE timeout ... after 900s while AWS was still IN_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 shared slowCcOperationTimeoutMs(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) waitForOperation now takes resourceType and caps at Math.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 its Math.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 default cdkd destroy now 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-timer CloudControlProvider DELETE 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.sleep test seam (issue #1091) — src/provisioning/import-tag-walk.ts, 12 src/provisioning/providers/*.ts, ~26 unit-test files. Why: after batch 2 (PR #1119), ~25 providers still hand-rolled the N+1 List* + per-candidate tag-read aws:cdk:path walk in import(), so a single throttled read aborted the whole cdkd import run; separately, every batch-1/2 wiring test that exercised the throttle path paid a REAL 0.5s backoff sleep because provider import() signatures expose no retry options to inject retry.sleep through. What shipped: 12 more providers migrated onto the shared importTagWalk helper, chosen by throttle exposure starting from the picks named on the issue — stepfunctions-provider.ts (lowercase {key, value} tags re-shaped to {Key, Value} inside tagsOf; the now-dead private tagsMatchCdkPath deleted), cognito-provider.ts (map-shaped tags via Object.entries; the Properties.UserPoolName name-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 retried describe callback), 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 (template EventBusName scopes every ListRules page; tag-read errors still surface un-skipped, as before), firehose-provider.ts (ExclusiveStartDeliveryStreamName / HasMoreDeliveryStreams pagination fold, mirroring Kinesis), wafv2-provider.ts (template Scope forwarded on every page), s3-directory-bucket-provider.ts (NoSuchTagSet / AccessDenied candidates still skip; deliberately NO 301 skip — directory buckets are zonal), and budgets-budget-provider.ts (AccountId on every page, constructed-ARN tag reads). Behavior is preserved mechanically: explicit-override / name-fallback branches untouched, not-found skips became describe returning undefined, 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 in import-tag-walk.ts (a per-call retry.sleep still 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, not importTagWalk). 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, GlobalTable ExclusiveStartTableName, 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, 55 tests/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 spelled GONE) and silenced probe wrappers (fn() { aws ... >/dev/null 2>&1; } / value wrappers with || true tails — the shape #1110 fixed by hand six times but nothing prevented reintroducing). What shipped: two new classifier categories. blindCaptureProbes flags 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 -e hard-fails them, only the diagnostic is lost) and the strict stderr-capture idiom ($(cmd 2>&1 >/dev/null || true)) is recognized as strict. silencedFunctionProbes flags 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 unrestored set +e in 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-protection gained 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), and set +eu best-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 (no inherit_errexit), so a multi-statement value wrapper whose probe is not the LAST command silently yielded empty on a probe error. Every intermediate out="$(aws ...)" capture in the tree's wrappers now carries || return 1 (16 sites across 13 fixtures, incl. find_evaluator_id, the 5 deletion-ordering-complex find_* helpers with their inner tags= captures, and list_esms_for_function), enforced by a third new category unpropagatedWrapperCaptures (skips last-statement / condition-context / $?-consuming captures; flags local V=$(...) outright since local masks 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 +eu cleanup helpers converted to subshell bodies (fn() { ( set +eu; ... ) }) so calling one from a set +eu cleanup trap can never re-arm strict mode mid-sweep; every gone_probe-then-requery site gained a TOCTOU guard (canonical not-found on the requery counts as gone, anything else hard-fails); the eventbridge-pipes post-deploy poll and propagation-races-2 post-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 mandatory it('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 two drift --revert round-trip guards but deferred this one, because satisfying it changes WHICH top-level keys readCurrentState emits — 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-minimum DescribeFileSystems response a file system deployed without e.g. NetworkType never carried the key in observedProperties, and the comparator's state-keys-only walk skipped a console-side ADD forever. What shipped: the four top-level properties update() can actually mutate are now emitted unconditionally with placeholders — StorageCapacity ?? 0, StorageType ?? 'SSD', FileSystemTypeVersion ?? '', NetworkType ?? 'IPV4' (the two enums use the AWS-documented defaults) — joining Tags, which already always-emitted. FileSystemType / SubnetIds / KmsKeyId deliberately KEEP their putIfDefined guard per §3b's "immutable on create" carve-out (all registry-createOnly; update() rejects any change, so a placeholder buys no drift coverage and an undefined -> [] / '' transition could trip the TOP_LEVEL_IMMUTABLE_PROPS guard on a later deploy); SecurityGroupIds / BackupId are createOnly AND never returned, so they stay in getDriftUnknownPaths(). The four <Variant>Configuration blocks take §3b's Class 1 type-discriminator carve-out: at most one is legal for a given FileSystemType, so emitting all four as {} would make drift --revert push a shape AWS rejects. readCurrentState now emits EXACTLY the one block FileSystemType selects — 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: the StorageCapacity ?? 0 placeholder is reachable on a real AWS shape — FSx Lustre Intelligent-Tiering (DataReadCacheConfiguration) provisions no capacity, so DescribeFileSystems returns none, which is why StorageCapacity is neither createOnly nor required in the registry schema. update() now suppresses a non-positive StorageCapacity instead of shipping it (folded into the changed() flag so the no-op early-return sees it too, rather than issuing an empty UpdateFileSystem); suppressing rather than throwing keeps an otherwise-valid update in the same call working. (2) Unrecognized-discriminator warn: pre-carve-out, an unknown FileSystemType still 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 a logger.warn naming the type and stating that its configuration is absent from the drift snapshot. The inline Lustre mapping was extracted to a readLustreConfiguration helper so all four blocks route through symmetric reverse-mappers. No getDriftUnknownPaths() contradiction was found — every declared entry is a key readCurrentState still 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 per FileSystemType (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 through update() ships no foreign variant block (the regression would otherwise pick LustreConfiguration for a WINDOWS file system via detectVariantConfigKey's declaration-order walk); an unsupported-FileSystemType case 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.
  • importTagWalk batch-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-tolerant aws:cdk:path walk shipped in PR #1093 with two reference callers (EMR Cluster, DocDB); the other ~40 providers still hand-rolled the N+1 List* + 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 whole cdkd import run. What shipped: behavior-preserving migration of the 12 highest-exposure walkers onto importTagWalk. Per-provider semantics preserved inside the callbacks: map-shaped tag responses (Lambda ListTags, Logs, SQS) re-shaped to {Key, Value} entries in tagsOf; 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's IsTruncated-gated Marker and ManagedPolicy's Scope: 'Local' AWS-managed exclusion kept; Kinesis's ExclusiveStartStreamName + HasMoreStreams pagination (including the stop-on-empty-page guard) folded into nextMarker; DynamoDB's two-reads-per-candidate (DescribeTable for the ARN, then ListTagsOfResource) both live inside the retried describe callback; S3's single unpaginated ListBuckets becomes a one-page walk. SNS is the one non-mechanical case: its walk matches Properties.TopicName against 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 synthetic aws:cdk:path tag carrying the walk's own lookup key (input.cdkPath, or a cdkd: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 to null with 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 unconsumed mockResolvedValueOnce queue entries leaked by earlier tests in the same file, so every added block mockReset()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.ts is owned by the in-flight #1114 lane and untouched. No new resource type, dependency, CLI flag, or state-schema change.
  • Fn::GetAtt guard 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 only constructAttribute's FINAL unknown-type fallback; the 36 per-type handlers ending in default: return physicalId still shipped a NAME for an unrecognized *Arn attribute, 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 shared guardedPhysicalIdFallback(logicalId, attributeName, resourceType, physicalId) helper and EVERY per-type unknown-attribute default: branch routes through it (same rules: *Arn/*Url shape 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-existing SubnetId / FileSystemId / ApiId / Id / Name / QueueUrl / GroupId / VpcId cases). (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/outputReads with 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 via DeployEngineOptions.strictGetAtt → the engine's IntrinsicFunctionResolver constructor options; nested-stack child engines inherit it through the options spread. (3) The resolver counts warn-path fallbacks per instance (getPhysicalIdFallbackCount / resetPhysicalIdFallbackCount, reset at each deploy() 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 as DeployResult.attributeFallbackCount, and cdkd deploy prints N attribute resolution(s) fell back to the physical ID (potentially wrong values); re-run with --strict-getatt to fail on these after 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 in getatt-fallback-guard (strict-mode failure on a BogusName attribute that default mode warn-passes; default-mode success asserting the summary line). Docs: docs/cli-reference.md --strict-getatt section.
  • 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 CREATE ProgressEvent.ResourceModel → empty state attributes → Fn::GetAtt silently 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 the gen-enrichment-coverage critic. What shipped: a new mergeSparseModelReadback pass in both create and update, running BEFORE the per-type enrichResourceAttributes switch so existing if (!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 (carries ApiEndpoint) never triggers a read-back, while the Pipes / AccessPoint / ResourceGroups / Backup shapes do. On sparse, ONE best-effort GetResource read-back (reuses readCcResourceModel, 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::GetAtt physicalId 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 in Arn whose physicalId does not start with arn:, or ending in Url whose 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 / Endpoint and every other suffix keep warn-and-return (shape-indistinguishable from a plain name, so false positives are unacceptable). cdkd diff is 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, and AWS::ResourceGroups::Group have no SDK provider (pure Cloud Control) and their async CREATE ProgressEvent.ResourceModel is sparse, so state attributes ended up {} and Fn::GetAtt fell through the resolver's constructAttribute default to the bare physicalId — the resource NAME. GetAtt(Pipe, 'Arn') returned my-pipe instead of arn:aws:pipes:...:pipe/my-pipe, GetAtt(Ap, 'Arn'/'Alias') returned the access point name instead of the ARN / ...-s3alias alias, 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::Api resolved ApiEndpoint correctly in the same probe stack — ResourceModel sparseness is per-type, which is why the class keeps resurfacing type by type. What shipped: the Backup-scoped readBackupResourceModel helper (PR #992) is generalized to readCcResourceModel (same CC GetResource + parse + best-effort-never-throws contract, JSDoc updated) and three new enrichResourceAttributes cases 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, plus CurrentState / StateReason / CreationTime / LastModifiedTime — the documented GetAtt set), AccessPoint (Arn / Alias / NetworkOrigin), Group (Arn). Per-key literal enriched['...'] assignments keep the gen-enrichment-coverage TS-AST parser seeing each case's keys; all three types join enrichedWithoutCachedSchema (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: new tests/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 an arn: 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 import persists 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: buildStackState hardcoded attributes: {} when constructing each ResourceState row, so every provider's import() return value — e.g. EMRClusterProvider's buildAttributes(cluster) — was computed and then dropped on the floor. attributes is what backs Fn::GetAtt resolution against state, so a resource adopted via cdkd import started with an empty attribute map while the same resource deployed by cdkd deploy had it populated. Pre-existing and provider-agnostic; surfaced while building the EMR import round-trip integ for #1090, which had to assert observedProperties instead. 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: ImportRow carries attributes, populated from ResourceImportResult at the provider.import() call site on the imported outcome; buildStackState uses it. One fix covers every mode — the recursive --migrate-from-cloudformation child walk calls the same importOne / buildStackState, so auto / selective / hybrid / nested cannot diverge. The synthesized AWS::CloudFormation::Stack row (cdkd-local ARN, no provider call) correctly keeps {}. Second-order effect, measured rather than assumed: resolveImportedProperties passes stackState.resources as resolver context, and the resolver reads attributes for both Fn::GetAtt and Ref, so populating attributes changes the PERSISTED properties of imported resources. Pre-fix this did NOT fail — constructAttribute's terminal branch is default: return physicalId, so it silently substituted the physical id. Probed against an imported AWS::CodeCommit::Repository (physicalId = repo NAME, no constructAttribute branch): 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 what cdkd deploy writes. (The effect only appears for types with no constructAttribute branch — 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 whole ResourceState, 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> --force re-import would resurrect stale facts about the OLD resource and hand them to Fn::GetAtt. Tests: three genuinely fail-to-pass unit tests (attributes persisted; same-physical-id carry-over; downstream Fn::GetAtt resolving into persisted properties) plus guard tests for the no-attributes, different-physical-id, and unlisted-resource paths. Integ: new tests/integration/import-attributes/ — AWS::IAM::ManagedPolicy (free, instant, and its import() returns a non-empty {PolicyArn}) deploy → state orphan → re-import → assert the persisted attributes map is non-empty AND PolicyArn matches → destroy + GONE assertion. import-nested-stack cannot substitute: its only leaf type is AWS::SSM::Parameter, whose import() returns attributes: {} 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.ts canonicalized 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. FSx WindowsConfiguration.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's readCurrentState reverse-mapper looks like a one-liner but breaks the properties-fallback baseline — runDriftForStack uses observedProperties when present and falls back to the template properties otherwise, 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 optional ResourceProvider.getDriftUnorderedPaths(resourceType): string[] (mirroring the existing getDriftUnknownPaths seam) threaded by drift.ts into calculateResourceDrift's options.unorderedPaths and applied by canonicalizeUnorderedArraysAtPaths to 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 exported matchesPathPrefix(path, entries) that drift-calculator.ts's isIgnoredPath is 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: isIgnoredPath never sees a path crossing an array (the comparator compares arrays wholesale via deepEqual) whereas the unordered walk descends into array elements and gives them the parent's path, so 'Items.Aliases' is meaningful for getDriftUnorderedPaths but inert as an ignore-path — strictly more permissive. FSxFileSystemProvider declares only WindowsConfiguration.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 ElastiCache PreferredAvailabilityZones (which AWS does document as positionally node-indexed). The provider docstring carries the citation and the FSx test pins DnsIps in AWS order so a future re-add must be deliberate. Tests: command-path tests in tests/unit/cli/drift.test.ts driving the REAL observedProperties and properties-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 the drift.ts wiring is removed; drift-normalize.test.ts cases for declared leaf, undeclared path, subtree-prefix entry, non-string / mixed-type / empty / array-of-arrays arrays, and array-element path inheritance; a matchesPathPrefix block pinning the shared rule; and an FSx test that routes real readCurrentState output 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 is AWS::Cognito::UserPool, where cognito-provider.ts already carries a comment self-flagging this exact false-positive class on EnabledMfas and naming "a future order-insensitive array compare in drift-calculator" as the fix — that seam now exists. Also ACM SubjectAlternativeNames, CloudFront Aliases / TrustedKeyGroups, Route53 ResourceRecords, IAM ManagedPolicy Groups/Roles/Users, WAFv2 TokenDomains, ASG AvailabilityZones. 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 fleet SUSPENDED fail-fast (issue #1092 item 2) — new src/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-based import() auto-lookup does an inherent N+1 read — List* to enumerate candidates, then one Describe* / ListTagsForResource per 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 whole cdkd import run. Separately, EMRInstanceFleetConfigProvider's FAILED_STATES contained only TERMINATED, so a fleet that entered SUSPENDED (a resize that could not complete — existing instances keep running but AWS can add/remove none) polled to the full maxWaitMs timeout 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:path match and wraps BOTH callbacks in the deploy engine's withRetry (0.5s → 1s → 2s → 4s → 5s, 5 retries), returning {summary, detail} for the first match or null (short-circuiting with zero API calls on an empty cdkPath, replacing the if (!input.cdkPath) return null guard each provider carried inline). Its classifier isThrottlingLikeError (also exported for providers whose tag API does not fit the callback shape) DELEGATES the error + .cause traversal to retryable-errors.ts's newly-exported isThrottlingError and only adds the Rate exceeded message 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 $metadata at every cause depth, the write path only at depths 0-1), so the status check was folded into the one shared walk; isRetryableTransientError consequently 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. Because withRetry's backoff is PER CALL, the walk carries its own limits, surfaced as ImportTagWalkLimitError with 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) and retry.maxPages (default 1,000 — a service returning a non-advancing pagination token would spin forever). retry.isInterrupted / onInterrupted forward to withRetry so Ctrl-C during a throttled sleep is honored as on the deploy path, and retry.logger defaults to the process logger so throttled retries AND every skipped candidate are visible under --verbose (a provider whose describe maps a broad error class to undefined would 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) and DocDBProvider's three sub-types (DescribeDBInstances / DescribeDBClusters / DescribeDBSubnetGroups + a new shared listTagsForResource helper). Batched tag reads are deliberately NOT modelled — neither migrated service offers one; a service that does (e.g. CodeCommit BatchGetRepositories) can satisfy several candidates inside its own describe callback. Separately, BOTH halves of the symmetric EMR SUSPENDED defect are fixed: SUSPENDED means a resize could not complete (existing instances keep running, AWS can add or remove none), so the wait polled to the full maxWaitMs timeout instead of failing fast with the service's own state-change reason. emr-instance-fleet-config-provider.ts's FAILED_STATES goes {TERMINATED} -> {SUSPENDED, TERMINATED} and emr-instance-group-config-provider.ts's goes {ARRESTED, TERMINATED, ENDED} -> {ARRESTED, ENDED, SUSPENDED, TERMINATED}. Issue #1092 item 2 names only the fleet, but SUSPENDED is a valid InstanceGroupState too 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 / ENDED are InstanceGroupState members that do NOT exist for fleets, so TERMINATED is 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: SUSPENDED is precisely the state a user re-runs cdkd deploy to RECOVER from, and the pre-resize read lag both providers already document (right after Modify* the group/fleet still reports its PRE-modify state) means poll #1 of the recovery resize still reads SUSPENDED. 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. So waitForGroupReady / waitForFleetReady take a toleratesStaleFailedState flag (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 transitions RUNNING -> RESIZING -> SUSPENDED still fails fast. A resource that never leaves its initial state falls through to the maxWaitMs timeout — the pre-fix behavior. Note the asymmetry that makes this specific to failed states: a stale pre-resize RUNNING is harmless (the instance-count / capacity check keeps the loop polling), but a stale SUSPENDED is terminal. Tests: new import-tag-walk.test.ts (classifier: throttling names, nested cause chain, HTTP 429/503, Rate exceeded backstop, write-path messages NOT matched, cyclic cause chain; walk: first-match, no-match, empty-cdkPath zero-call short-circuit, pagination, describe-undefined skip, throttled-describe retry, throttled-list retry, budget exhaustion, non-throttling immediate rethrow) plus provider-level wiring tests on EMRClusterProvider (retries a throttled DescribeCluster mid-walk and still finds the match / does not retry a non-throttling DescribeCluster error during the walk) and DocDBProvider (a new import tag walk block: 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), and fails 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 an aws:cdk:path walk — tracked as a follow-up on #1091 so this PR stays reviewable; docs/provider-development.md now documents importTagWalk as the recommended shape for new providers. No new resource type, dependency, CLI flag, or state-schema change.

Recently Implemented (2026-07-19):

  • AWS::FSx::FileSystem variant drift read-back + apply*UpdateField default 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 but readCurrentState still mapped only the Lustre block, so getDriftUnknownPaths had to mark the whole WindowsConfiguration / OntapConfiguration / OpenZFSConfiguration subtrees drift-unknown — cdkd drift reported "unknown" for every non-Lustre variant's config rather than comparing it. Separately, the four apply*UpdateField switches had no default: arm, so a sub-property added to a *_MUTABLE_SUBPROPS set without a matching case would pass the mutability check, map to nothing, and issue a no-op UpdateFileSystem with hasMutableDiff true — silently dropping the user's change. What shipped: three read*Configuration reverse-mappers turn each variant's DescribeFileSystems block back into the flat CFn *Configuration input shape (mirroring the Lustre DataRepositoryConfiguration → LustreConfiguration mapping), including the Windows Aliases shape change (API returns {Name, Lifecycle}[], CFn takes a plain name list) and the shared DiskIopsConfiguration / AuditLogConfiguration / FsrmConfiguration / ReadCacheConfiguration sub-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. getDriftUnknownPaths drops the three whole-block entries and keeps only the leaves AWS genuinely never returns: the two write-only credentials (WindowsConfiguration.SelfManagedActiveDirectoryConfiguration.Password — SelfManagedActiveDirectoryAttributes has no Password field at all; OntapConfiguration.FsxAdminPassword) and OpenZFSConfiguration.RootVolumeConfiguration (configured on the root volume — DescribeFileSystems returns only RootVolumeId). EndpointIpv6AddressRange on 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. The apply*UpdateField switches gain a default: arm routing to a new unmappedMutableSubprop() that throws a reportable ProvisioningError naming the exact <ConfigKey>.<Subprop> with no mapping; the variant identity is threaded in via a new VariantFieldContext. VARIANT_MUTABLE_SUBPROPS is now exported so the invariant is testable. A shared putIfDefined helper replaces the repeated if (x !== undefined) assignments across the whole of readCurrentState (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-empty UpdateFileSystem field, 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 the ProvisioningError + zero AWS calls; per-variant readCurrentState reverse-map tests (incl. Aliases flattening, read-only-field exclusion, write-only Password absence, empty-block omission); the two mandatory drift --revert round-trip guards from docs/provider-development.md §3b (unchanged snapshot replays as a no-op; a mutable-only drift replays as a real scoped UpdateFileSystem); and dedicated nested-sub-block update-arm coverage (Windows AuditLogConfiguration reconciliation, stringly-typed Iops coercion, ONTAP DiskIopsConfiguration independent of RouteTableIds, sub-block removal → undefined). Known gaps (neither introduced here): (1) the FSx test file still lacks the emits placeholders for every user-controllable top-level key block that docs/provider-development.md §3b marks mandatory — retrofitting it would change which top-level keys readCurrentState emits on an AWS-minimum response and is deliberately left to a focused follow-up. (2) WindowsConfiguration.Aliases and SelfManagedActiveDirectoryConfiguration.DnsIps are plain-string arrays, which src/analyzer/drift-normalize.ts does not canonicalize — RESOLVED for Aliases by issue #1096 item 1 (see the entry below), which extended the shared normalizer exactly as this note predicted was the correct fix. DnsIps is 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::FileSystem Windows / 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 / OpenZFSConfiguration were unhandledByDesign and pre-flight REJECTED, so a Windows / ONTAP / OpenZFS (or the CDK L2 aws-fsx.OpenZfsFileSystem) template failed fast rather than deploying. What shipped: all three config trees move to handledProperties (FSx's unhandledByDesign map is now empty). create maps each variant's full CFn block to the SDK CreateFileSystem<Variant>Configuration shape (nested SelfManagedActiveDirectoryConfiguration / AuditLogConfiguration / FsrmConfiguration / DiskIopsConfiguration / RootVolumeConfiguration incl. 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. update generalizes the Lustre sub-property differ into a per-variant computeVariantConfigDiff: the mutable subset matching each UpdateFileSystem<Variant>Configuration is applied, RouteTableIds changes on ONTAP / OpenZFS are translated into AddRouteTableIds / RemoveRouteTableIds set 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 --replace pointer. The shared create-poll-to-AVAILABLE / delete-poll-to-gone / ClientRequestToken / getMinResourceTimeoutMs / async-FILE_SYSTEM_UPDATE-action machinery is reused unchanged. getAttribute / buildAttributes add the OpenZFS-only RootVolumeId; getDriftUnknownPaths marks the three variant blocks drift-unknown (their read-back into CFn shape is a follow-up — only the Lustre block is mapped by readCurrentState today, so listing them avoids a guaranteed phantom drift). disableCcApiFallback stays true (FSx is NON_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 --replace rejection, RootVolumeId attribute); the CC-routing guard test was re-pointed (FSx no longer has silent-drop entries, so its variant templates SDK-route cleanly — the disableCcApiFallback + silent-drop guard is still covered generically via pickSilentDropFixture). Integ: new tests/integration/fsx-openzfs/ — the cheapest variant (OpenZFS SINGLE_AZ_1, no Active Directory) deploy → in-place update → destroy with a GONE-from-AWS assertion (describe-file-systems empty 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 are ProvisioningType: NON_PROVISIONABLE (re-verified live via DescribeType), 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 inline AWS::EMR::Cluster.Instances groups/fleets — a template attaches them to an EXISTING cluster referenced by JobFlowId (group) / ClusterId (fleet). Lifecycle: create → AddInstanceGroups / AddInstanceFleet + poll ListInstanceGroups / ListInstanceFleets until the new group/fleet is RUNNING (a failed terminal — ARRESTED/TERMINATED/ENDED for groups, TERMINATED for fleets — is a hard error); update → the AWS-mutable surface only — for a group InstanceCount (ModifyInstanceGroups resize, polled until the group's RunningInstanceCount reaches the new target — NOT just group State: RUNNING, which right after ModifyInstanceGroups is still the STALE pre-resize RUNNING and would let update() return before the resize even starts; the fleet resize waits on provisioned capacity meeting the target the same way, direction-aware — scale-UP waits for provisioned >= target, scale-DOWN for provisioned <= 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 fleet TargetOnDemandCapacity / TargetSpotCapacity / ResizeSpecifications / InstanceTypeConfigs (ModifyInstanceFleet, polled); every other property is registry-createOnly → replacement, and a createOnly change reaching update() is refused with a --replace pointer; 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 normal cdkd destroy the parent AWS::EMR::Cluster is terminated in the same run (the group/fleet depends on it via JobFlowId/ClusterId, so it is deleted first, then TerminateJobFlows releases every group/fleet + its EC2 instances → zero orphans). As a best effort for the "delete only the group/fleet, keep the cluster" case, a TASK group/fleet is scaled to 0 (ModifyInstanceGroups InstanceCount: 0 / ModifyInstanceFleet target capacities 0) to release its instances; MASTER/CORE cannot be scaled to 0 and are a pure no-op. Delete never blocks the destroy (warn-and-continue); an InvalidRequestException (cluster/group gone) is region-guarded via assertRegionMatch. Note scaling to 0 does NOT remove the group/fleet record (it stays with 0 instances) — documented as the honest limitation. Both set disableCcApiFallback = true (NON_PROVISIONABLE — no CC handler) and getMinResourceTimeoutMs() (1h poll ceiling, mirrors EMRClusterProvider). Every settable schema property is in handledProperties (zero silent-drop). import() is not wired (both are cluster sub-resources without a per-resource aws:cdk:path tag — noted in docs/import.md alongside the ASG entry; a future override-only import() 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 in provider-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: new tests/integration/emr-instance-configs/ — a group-based cluster (1x m5.xlarge master + 1x m5.xlarge core; the core node is required because EMR rejects AddInstanceGroups on a master-only job flow) + a standalone TASK InstanceGroupConfig (1x m5.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 only InstanceGroupConfig; the structurally identical InstanceFleetConfig is 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's RunJobFlow, and EMR rejected the create with Failed to authorize instance profile <arn> — the SAME just-created-instance-profile propagation race #1064 already handles as Invalid InstanceProfile, but with a different EMR sentence the retryable set did not cover. Added Failed to authorize instance profile to src/deployment/retryable-errors.ts (+ unit test) so the deploy engine's withRetry absorbs the propagation window; CloudFormation tolerates this via deployment latency, cdkd retries.
  • Code seed-content + Triggers support for AWS::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/ + new adm-zip dependency. Why: the two properties were unhandledByDesign and 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 — GetObject the S3 ZIP (Code.S3.{Bucket,Key,ObjectVersion}), unpack every file entry with adm-zip (directory entries skipped — CodeCommit has no empty-dir concept), and issue a single CreateCommit carrying all files as putFiles on Code.BranchName (default main); an empty ZIP is a warn-and-skip (CodeCommit rejects an empty putFiles). Triggers (mutable) maps the CFn [{Name, DestinationArn, CustomData, Branches, Events}] list to the SDK's camelCase RepositoryTrigger[] and reconciles via PutRepositoryTriggers on create AND update — PutRepositoryTriggers REPLACES the full set, so a dropped entry or a fully-removed Triggers property is handled by putting the new set (empty array = clear all), and an unchanged block (compared as canonical JSON) skips the call. Both moved from unhandledByDesign to handledProperties (the provider now declares NO by-design-unhandled property → property-coverage regen). Create rollback safety: a Code / Triggers failure AFTER CreateRepository self-cleans (best-effort DeleteRepository) 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. Code is create-only (CFn ignores it on update, so does update()). getDriftUnknownPaths still returns ['Code', 'Triggers']: Code is git history (never a comparable attribute), and Triggers is now wired on the write side but not yet read back by readCurrentState (a follow-up can add GetRepositoryTriggers read-back and drop Triggers from the list). Tests: new create 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), and update trigger cases (full-replace on change, empty-array clear on removal, no-op on unchanged) — S3 / adm-zip mocked. Integ: tests/integration/codecommit/ extended with a repo carrying a Code seed + an SNS Triggers entry, asserting GetFile (seed committed) + GetRepositoryTriggers (trigger applied) read-back, then a clean destroy.
  • import() for AWS::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 deferred import(), so cdkd import reported every EMR cluster as unsupported. What shipped: import(input) resolves the cluster either by an explicit id override (--resource <id>=j-XXXX → verified via DescribeCluster; there is no template name property equal to the physical id, so no name fallback) or by the aws:cdk:path tag walk — ListClusters filtered to the non-terminated states (a TERMINATED* cluster is never adopted) then a DescribeCluster per candidate to read Tags (list summaries carry none), matched via matchesCdkPath. Returns physicalId + { Id, MasterPublicDNS } attributes (or null for not-found / already-terminated, so the import command marks it skipped). Also adds readCurrentState + getDriftUnknownPaths: the reverse of create()'s Instances flatten — DescribeCluster (+ ListInstanceGroups / ListInstanceFleets chosen by InstanceCollectionType) is re-bucketed back into the role-keyed CFn MasterInstanceGroup / CoreInstanceGroup / TaskInstanceGroups (+ *InstanceFleet(s)) shape with the flat Ec2InstanceAttributes (subnet / key name / security groups) folded in, plus Tags (normalizeAwsTagsToCfn, aws:* stripped) and the top-level scalar fields — so a freshly-imported cluster gets a real observedProperties baseline (via captureObservedForImportedResources) and drift works. Lossy create-only sub-config AWS does not read back faithfully (ManagedScalingPolicy / AutoTerminationPolicy / BootstrapActions / Steps / KerberosAttributes / Configurations / AdditionalInfo / PlacementGroupConfigs / Applications Version) is declared in getDriftUnknownPaths so the properties-fallback path never phantom-drifts. docs/import.md moves AWS::EMR::Cluster from 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 implement readCurrentState, so cdkd drift reported "unknown" for every repository. What shipped: readCurrentState(physicalId, logicalId, resourceType) issues GetRepository + ListTagsForResource and 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 by normalizeAwsTagsToCfn so a CDK-deployed repo's aws:cdk:path tag is not phantom drift), repositoryName → RepositoryName. Every user-controllable top-level key update() 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 carries RepositoryDescription in observedProperties and 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. Returns undefined when the repository is gone (RepositoryDoesNotExistException) so the caller reports drift-unknown rather than throwing (mirrors the optional import opt-in shape) — including a repo deleted BETWEEN the GetRepository and ListTagsForResource reads (a race with a concurrent destroy), which is caught rather than aborting the whole cdkd drift run (matches the ECR provider's guard). getDriftUnknownPaths returns ['Code', 'Triggers'] — both are unhandledByDesign (create-only seed content / repository triggers), never read back by readCurrentState, and normally rejected pre-flight, but a state written under --allow-unsupported-properties could carry them, so they are excluded defensively. No new resource type, no coverage regen, no register-providers change. Tests: new readCurrentState (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, Code ignored via getDriftUnknownPaths, 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 real calculateResourceDrift; the integ fixture gained a Phase 1b drift round-trip (clean after deploy → out-of-band UpdateRepositoryDescription detected 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 is ProvisioningType: NON_PROVISIONABLE (re-verified live at implementation time via DescribeType), so the Cloud Control fallback cannot handle it and pre-flight rejected any stack carrying an EMR cluster. Lifecycle: create → RunJobFlow + poll DescribeCluster until WAITING/RUNNING; a TERMINATED* terminal during create is a hard error and the partially-created cluster is best-effort TerminateJobFlows'd so it does not bill; update → the LIMITED mutable surface only — SetTerminationProtection (Instances.TerminationProtected), SetVisibleToAllUsers, ModifyCluster (StepConcurrencyLevel), PutManagedScalingPolicy / RemoveManagedScalingPolicy, PutAutoTerminationPolicy / RemoveAutoTerminationPolicy, and AddTags / RemoveTags (full-tag-removal untags explicitly — the #981 ECR regression class); delete → TerminateJobFlows + poll until TERMINATED with a hard error on timeout (a live cluster bills per instance-hour), idempotent on an already-TERMINATED / aged-out cluster behind assertRegionMatch, and under --remove-protection it flips SetTerminationProtection(false) first (mirroring the EC2/ASG pattern in src/provisioning/ec2-termination-protection.ts); getAttribute → Id (physical id) / MasterPublicDNS (via DescribeCluster). Property classification (from the registry schema, not the issue's guesses): everything except Tags / VisibleToAllUsers / StepConcurrencyLevel / ManagedScalingPolicy / AutoTerminationPolicy and Instances.TerminationProtected is createOnly → replacement (Instances topology, Applications, ReleaseLabel, roles, EBS root volume, etc.); an immutable change that reaches update() is refused with a --replace pointer. The bulk of the mapping work is the CFn Instances block: role-keyed MasterInstanceGroup / CoreInstanceGroup / TaskInstanceGroups (and the *InstanceFleet(s) siblings) are flattened into the SDK's InstanceGroups / InstanceFleets arrays with an explicit InstanceRole / InstanceFleetType discriminator per entry. getMinResourceTimeoutMs() self-reports a 1-hour ceiling (mirrors CustomResourceProvider / FSxFileSystemProvider) so slow creates/terminates fit the per-resource deadline without --resource-timeout. disableCcApiFallback = true so 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 over ListClusters + 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-protection flip ordering + timeout hard-error, getAttribute). Integ: tests/integration/emr-cluster/ (smallest single-node cluster — 1 master m5.xlarge, emr-7.x, AutoTerminationPolicy idle-timeout to bound cost; deploy → in-place update → destroy with a TERMINATED/no-orphan assertion). covers: AWS::EMR::Cluster. Engine fix surfaced by the first live integ run: RunJobFlow validates the cluster's JobFlowRole instance profile at create time, but cdkd's fast SDK path issues RunJobFlow only ~1s after the AWS::IAM::InstanceProfile CREATE — before IAM propagates the profile to EMR's validation layer — so EMR rejected the create with Invalid InstanceProfile: <name>. (the one-word "InstanceProfile", distinct from EC2's Invalid IAM Instance Profile phrasing already handled). Added Invalid InstanceProfile to src/deployment/retryable-errors.ts so the deploy engine's withRetry absorbs 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 is ProvisioningType: 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 L2 aws-fsx.LustreFileSystem. Scope: the Lustre variant only; WindowsConfiguration / OntapConfiguration / OpenZFSConfiguration are unhandledByDesign (each non-Lustre FileSystemType REQUIRES its variant block, so every non-Lustre template is caught by the property-coverage pre-flight — no silent drop). Lifecycle: create → CreateFileSystem (or CreateFileSystemFromBackup when BackupId is set — a different API where FileSystemType is not a valid parameter) with a stable immutable-input-hashed ClientRequestToken + poll DescribeFileSystems to AVAILABLE, best-effort rollback delete on a failed/timed-out create (an orphaned FSx file system bills per hour); update → UpdateFileSystem for the mutable surface (StorageCapacity, StorageType, FileSystemTypeVersion, NetworkType, and the mutable Lustre sub-props) + TagResource/UntagResource for Tags, 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 behind assertRegionMatch; getAttribute → DNSName / LustreMountName / ResourceARN / RootVolumeId; readCurrentState maps the read-side DataRepositoryConfiguration back to the flat CFn LustreConfiguration inputs; import verifies an explicit id or walks DescribeFileSystems for the aws:cdk:path tag. getMinResourceTimeoutMs() self-reports a 1-hour ceiling (mirrors CustomResourceProvider) 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) UpdateFileSystem applies changes via an ASYNC FILE_SYSTEM_UPDATE administrative action — returning on Lifecycle: AVAILABLE alone 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-long STORAGE_OPTIMIZATION actions, matching CloudFormation) and hard-fails on a FAILED action. (2) The deploy engine's in-place UPDATE state write dropped the resource's stored attributes whenever the provider's update result carried none — degrading every later Fn::GetAtt to the physical-id fallback (live: the stack's LustreMountName output 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_PROVISIONABLE or provider disableCcApiFallback (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 from agentcore-runtime-provider.ts, now with a preserveKeys escape hatch for free-form JSON documents), src/provisioning/register-providers.ts, @aws-sdk/client-bedrock-agentcore-control bumped ^3.1017.0 → ^3.1089.0 (the pinned 3.1018.0 predated the EvaluatorConfig.CodeBased union member). Why: Browser / CodeInterpreter are ProvisioningType: 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' proposed create → CreateBrowser / CreateCodeInterpreter would have created CUSTOM tools, which belong to the DIFFERENT AWS::BedrockAgentCore::BrowserCustom / CodeInterpreterCustom types (FULLY_MUTABLE, Cloud Control-served). The providers are therefore ADOPT-ONLY: create verifies the default exists via GetBrowser / GetCodeInterpreter and records its ARN (the primaryIdentifier, CFn Ref parity) as physicalId; update / delete are no-ops (destroy must never touch the AWS-owned defaults); getAttribute serves the read-only attributes live; import auto-adopts with no --resource override. (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's evaluator/<id> segment via evaluatorIdFromArn), update → UpdateEvaluator (Description / EvaluatorConfig / Level / KmsKeyArn) + TagResource / UntagResource tag 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 via GetEvaluator, readCurrentState → CFn-shaped drift read incl. tags (best-effort), import → explicit ARN verbatim or bare id resolved via GetEvaluator. EvaluatorName is the only createOnly property (the issue guessed "possibly Level" — Level is updatable per both schema and SDK) → replacement via the registry-schema createOnly fallback, no hand rule needed. The full EvaluatorConfig union (LlmAsAJudge incl. RatingScale/ModelConfig/InferenceConfig, CodeBased → LambdaConfig) plus KmsKeyArn / Tags map 1:1 through the shared case converter — with AdditionalModelRequestFields (free-form model fields like top_k) passed VERBATIM both directions via preserveKeys. 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 across tests/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: new tests/integration/agentcore-tools/ fixture — all three types as raw cdk.CfnResources (aws-cdk-lib ships no L1 for the singletons and CfnEvaluator predates CodeBased), a code-based evaluator backed by a fixture Lambda (no Bedrock model-access dependency): deploy (defaults adopted into state + GetAtt outputs, 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 are ProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot handle them and pre-flight rejected them — while their PrivateDnsNamespace twin deployed fine via the existing SDK provider, an inconsistency CDK aws-servicediscovery L2 users hit directly. What shipped: the existing ServiceDiscoveryProvider gained both kinds, sharing its operation-polling plumbing (pollOperation: OperationId → GetOperation until SUCCESS, namespace id from Targets.NAMESPACE) — create → CreateHttpNamespace / CreatePublicDnsNamespace (PublicDns passes Properties.DnsProperties.SOA.TTL through and surfaces the HostedZoneId attribute read back via GetNamespace; the shared SOA-TTL extraction is now the extractSoaTtlProperties helper reused by the PrivateDns paths); update → UpdateHttpNamespace (Description is the sole mutable field) / UpdatePublicDnsNamespace (Description + SOA TTL) plus explicit TagResource / UntagResource diffs via the shared syncNamespaceTags helper (partial AND full Tags removal untag explicitly — the ECR #981 regression class; tag failures throw, never warn-swallow; also wired into the pre-existing PrivateDnsNamespace update path, whose Tags-only changes previously silently no-op'd); Name is createOnly per the registry schema → replacement handled upstream; delete → the now kind-agnostic deleteNamespace (DeleteNamespace + operation poll, NamespaceNotFound-idempotent behind assertRegionMatch); getAttribute → GetNamespace (Id / Arn / HostedZoneId) with a GetService-backed Service branch; readCurrentState (drift) reuses the namespace reader with the Properties bag omitted for HttpNamespace (no CFn Properties there); import adopts by explicit id / Properties.Name / aws:cdk:path tag via the shared ListNamespaces walk, now filtered by the Cloud Map Namespace.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-time Fn::GetAtt fallback in src/deployment/intrinsic-function-resolver.ts covers all three namespace kinds (constructed Arn; HostedZoneId fetched live via GetNamespace and resolving to undefined — never the namespace id — on a miss; previously the two new kinds AND PrivateDns's HostedZoneId fell through to the bare physicalId). All writable schema properties for both types are wired (handledProperties, zero silent-drop; no new dependency — @aws-sdk/client-servicediscovery was 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: new tests/integration/servicediscovery-namespaces/ fixture — one CfnHttpNamespace + one CfnPublicDnsNamespace (SOA TTL 90 + tags); verify.sh asserts both namespaces exist, the public one's Route 53 hosted zone exists and the TTL reached AWS, the HostedZoneId Fn::GetAtt resolved 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 is ProvisioningType: 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, Tags with CFn Tag[] → CodeCommit Record<string, string> map conversion); update → UpdateRepositoryName (see below) / UpdateRepositoryDescription (empty string clears on property removal) / UpdateRepositoryEncryptionKey (property removal reverts to the AWS-managed alias/aws/codecommit key) / TagResource + UntagResource (partial AND full tag removal untag explicitly — the ECR #981 regression class); delete → DeleteRepository (idempotent — AWS signals "already deleted" via a null repositoryId, NOT an exception, so both the null-id shape and the defensive RepositoryDoesNotExistException run assertRegionMatch before counting as success); rename is retry-safe (a withRetry re-entry whose UpdateRepositoryName hits NotFound probes the new name and treats an already-applied rename as success); getAttribute → GetRepository (Arn / CloneUrlHttp / CloneUrlSsh / Name / KmsKeyId); import → explicit RepositoryName verify via GetRepository, else paginated ListRepositories + ListTagsForResource aws:cdk:path tag-map lookup. Rename is IN-PLACE, not replacement: issue #1045 proposed classifying a RepositoryName change as replacement "for CFn parity", but the CFn docs mark the property "Update requires: No interruption" and the registry schema's createOnlyProperties is empty — real CloudFormation renames via UpdateRepositoryName, preserving the repository's entire git history (replacement would destroy it), so the provider does the same and returns the new name as physicalId with wasReplaced: false (a hand-written ReplacementRulesRegistry rule pins all four handled properties as updateable). Ref parity: CFn's Ref returns the repository ID (a GUID) while every CodeCommit API is name-based (physical id = repository name), so create()/import() store a RepositoryId attribute and cfnRefValueFromPhysicalId recovers it via stateLookup (falls back to the name on torn state). Code (CFn-only seed-content orchestration) and Triggers are unhandledByDesign — 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) + 2 Ref parity cases in tests/unit/deployment/intrinsic-functions.test.ts. Integ: new tests/integration/codecommit/ fixture — deploy (desc + 2 tags + Ref-returns-GUID output pin), UPDATE with in-place rename (repository ID must survive) + description change + env tag change + team tag removal, destroy clean.

  • AWS::Budgets::Budget SDK provider (issue #1041) — src/provisioning/providers/budgets-budget-provider.ts (new), src/provisioning/register-providers.ts, src/analyzer/replacement-rules.ts, @aws-sdk/client-budgets dependency. Why: the type is ProvisioningType: 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, and ResourceTags; physicalId is the budget NAME, CFn Ref parity — a user-supplied Budget.BudgetName passes through VERBATIM, only the logical-id fallback goes through the name generator), update → UpdateBudget plus 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) and TagResource / UntagResource tag diffs, delete → DeleteBudget (NotFound-idempotent, assertRegionMatch-guarded), getAttribute → Arn (computed arn:aws:budgets::{account}:budget/{name}, existence-verified via DescribeBudget). Global-endpoint semantics: the Budgets API is a global per-account service served from us-east-1; the SDK endpoint ruleset routes EVERY aws-partition region to budgets.amazonaws.com (SigV4 scope us-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 STS GetCallerIdentity as a cached single-flight promise (failures not cached). Budget.BudgetName is createOnly — a rename classifies as REPLACEMENT via a conditional rule on the nested name (budgetNameChanged in replacement-rules.ts); NotificationsWithSubscribers is 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). import adopts by explicit --resource id / Properties.Budget.BudgetName, or by aws:cdk:path tag via DescribeBudgets + 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-AWS budgets integ 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 info no longer dies with PermanentRedirect when the ambient region differs from the state bucket's region (issue #1054) — src/cli/commands/state.ts. Bug: the state info flow called its four raw S3 helpers — detectBucketRegion, listStateFileKeys, readSchemaVersion, listAssetStorageMarkers — with the raw ambient-region awsClients.s3 client, so ListObjectsV2 / GetObject against a bucket in another region 301'd with PermanentRedirect (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 shared rebuildClientForBucketRegion helper (S3StateBackend / LockManager / ExportIndexStore, issue #827) — these direct reads were the one un-corrected path. Fix: stateInfoCommand builds one region-corrected S3 client after verifyBucketExists() (mirroring ExportIndexStore's option choices: reuseClientCredentials + tolerateNonStandardClient, never destroying the shared original; only the replacement is destroyed in the command's finally), and the four helpers now take the S3 client directly (s3: S3Client) instead of AwsClients. Side benefit: detectBucketRegion previously try/caught the cross-region failure into Region: unknown; with the corrected client it now reports the bucket's actual region. Tests: new cross-region state bucket (issue #1054) describe block in tests/unit/cli/state-info.test.ts — rebuildClientForBucketRegion mocked to return a sentinel client, asserting ALL GetBucketLocation / ListObjectsV2 / GetObject traffic 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 returns null) asserting the original client carries the traffic.

  • cdkd local run-task --from-state recognizes 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 literal cdkd-container-assets- prefix (CDK_ASSET_IMAGE_REPO_RE). Since cdkd 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 local cdk.out-build fast path (falling back to an ECR pull: correct, but slower). What shipped: isCdkAssetImageUri takes an optional cdkAssetContainerRepo name and additionally accepts any ECR-hosted URI (host contains .dkr.ecr., tolerating both concrete and ${AWS::...} placeholder hosts) whose repository path component (new helper extractEcrRepoComponent, digest/tag-stripped) equals it; the name is threaded through a new optional EcsImageResolutionContext.cdkAssetContainerRepo field into all three classification sites (flat/Fn::Sub, Fn::GetAtt, Fn::Join). detectEcsImageResolutionNeeds gains needsAssetRepoMarker (any container Image is a flat-extractable ECR-hosted URI NOT matching the conventional shapes), which gates a new best-effort helper loadBootstrapContainerRepo in local-state-loader.ts (reads cdkd-bootstrap/{region}.json from the state bucket via S3StateBackend.getRawObject + parseBootstrapMarker; every miss — no bucket, no marker, malformed marker, AWS error — logs at debug and returns undefined, never failing the run). The marker read fires only under --from-state (--from-cfn-stack stacks 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: new ecs-task-resolver describe block (custom-repo Fn::Sub placeholder-host + concrete-literal URIs → cdk-asset with hash; no-context / different-name → ecr; docker.io host guard → public; digest-suffixed match; conventional shapes still classify; needsAssetRepoMarker true/false matrix) + new tests/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::LifecyclePolicy SDK provider (issue #1040) — src/provisioning/providers/dlm-lifecycle-policy-provider.ts (new), src/provisioning/register-providers.ts, @aws-sdk/client-dlm dependency. Why: the type is ProvisioningType: 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-up GetLifecyclePolicy for the Arn attribute; physicalId is the service-generated policy id, CFn Ref parity), update → UpdateLifecyclePolicy for policy fields plus explicit TagResource/UntagResource for tag diffs including FULL Tags removal (the #981 ECR regression class — the update API has no Tags parameter), delete → DeleteLifecyclePolicy (NotFound-idempotent, assertRegionMatch-guarded), getAttribute → Arn via GetLifecyclePolicy. A DefaultPolicy change throws the typed ResourceUpdateNotSupportedError (create-only at the API level; --replace recreates). The CFn property shape maps 1:1 onto the SDK's PascalCase inputs; the only conversion is CFn Tags list ↔ DLM tag map — all 12 writable schema properties are wired (handledProperties, zero silent-drop). readCurrentState (drift) surfaces Description/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). import adopts by explicit --resource id or by aws:cdk:path tag via the single-call GetLifecyclePolicies summary 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-AWS dlm-lifecycle-policy integ 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.extractDependencies captures every Ref — including Refs to CFn Parameters — and both state-write sites persisted the set verbatim, so every parameter-referencing resource carried the parameter NAME in its dependencies. On cdkd destroy the graph is rebuilt from a state-derived pseudo-template with no Parameters section, so DagBuilder.buildGraph warned Resource <X> depends on <Param>, but <Param> not found in template for 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's Parameters section 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) + a dependencies persisted to state (#1032) case in tests/unit/cli/import.test.ts — both fail without the fix.

  • Fix: stacks carrying AWS::CloudFormation::WaitConditionHandle are 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 by cdk-multi-region-stack as 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 opaque cdkd-wait-condition-handle-<logicalId>-<uuid> placeholder (deliberately NOT URL-shaped so a stray cfn-signal fails loudly), never calls AWS on create/update/delete, resolves Ref to the placeholder, throws on Fn::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-AWS wait-condition-handle integ fixture pins the #1020 shape: bare handle + SSM sibling deploys (the old pre-flight failure), Ref resolves into the stack output, sibling UPDATE keeps the handle's physical id stable, destroy leaves nothing. covers: AWS::CloudFormation::WaitConditionHandle.

  • cdkd diff binds 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's CfnInclude (the common CFn → CDK migration shape) carry Parameters with defaults, Mappings, and Conditions — the deploy engine resolved all of them (steps 2.5–2.7) but the standalone cdkd diff did not, so a no-op diff on a freshly deployed stack reported a phantom [requires replacement] on an unchanged create-only property (the unresolved Fn::Join/Fn::FindInMap new side vs the resolved old side), a phantom to create for a condition-false resource, spurious value changes for Ref/Fn::Sub over parameters, and a Fn::Sub variable ... not found warn; separately, resolveOutputs iterated every Outputs entry unconditionally, so an output whose Condition is false warned Failed 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: CfnInclude fixture on real AWS). What shipped: computeStackDiff now mirrors the deploy engine's preprocessing best-effort — binds template parameter defaults via resolveParameters (nested-stack input parameters act as user values), evaluates Conditions, prunes condition-false resources via filterResourcesByCondition, and threads parameters + conditions into the resolver context so Fn::If resolves too; binding/evaluation failures degrade to the previous raw-template diff. resolveOutputs skips an output whose Condition evaluated false — no resolution attempt, no warn, not persisted to state, not exported (unknown condition names are kept, matching filterResourcesByCondition semantics); TemplateOutput gains the Condition?: string field. Tests: 6 new computeStackDiff cases (param-default NO_CHANGE, condition-false prune, condition-false-in-state DELETE, Fn::If, nested-input-satisfies-required-param, graceful fallback) in tests/unit/cli/diff-recursive.test.ts + tests/unit/deployment/deploy-engine-condition-false-outputs.test.ts (skip/keep/unknown-condition matrix). Integ: new tests/integration/raw-cfn-conditions-params/ fixture (scenario tag raw-cfn-template-diff-parity) — deploy (defaults + Fn::Sub over param+GetAtt + condition-false resource/output absent, no warn) → no-op diff --fail exits 0 → inlined-parameter UPDATE diffs as exactly 0 to create, 1 to update, 0 to delete with 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 from bootstrap-destroy.ts), src/cli/index.ts. Why: cdkd-owned asset storage (issue #1002) is content-addressed and deliberately never deleted on cdkd destroy (another stack or a future rollback may reference the same hash), so cdkd-assets-* / cdkd-container-assets-* grow without bound — and cdk gc cannot reach them by design. What shipped: top-level cdkd gc [--region <r>] [--older-than <dur>] [--dry-run] [-y] (upstream cdk gc parity 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 shared state-file-keys.ts so gc and bootstrap --destroy cannot drift), deep-walking each document for {S3Bucket, S3Key} pairs, s3:// URIs, virtual-hosted / path-style https URLs (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: any lock.json in 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 pins ExpectedBucketOwner with a 403 = foreign-bucket refusal. Reporting prints the per-item reclaim plan + byte totals; --dry-run exits without prompting or deleting; otherwise Continue? (y/N) default-No confirm (--yes skips; non-TTY without --yes hard-errors); zero candidates → info + exit 0 with no prompt. Deletes via chunked DeleteObjects (1,000/batch) and BatchDeleteImage (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-than parse 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-assets as 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 on cdkd 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 / validateContainerRepoName in asset-storage.ts) and threaded into ensureAssetStorage as optional assetBucketName / containerRepoName fields — 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 via AssetModeResolver, publish, verifyAssetStorageExists, state info, and the #1010 teardown) already reads the marker, so none needed changes. ensureAssetStorage now 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-errors ASSET_STORAGE_NAME_CONFLICT pointing at cdkd 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 newer assetSupportVersion still hard-errors. Flag combos rejected: with --no-assets (contradictory — the flags name storage that --no-assets skips) 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 (ExpectedBucketOwner probe, 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 bootstrap creates 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 the CDKToolkit stack, while cdkd users had to hand-delete each piece (aws s3 rb --force, aws ecr delete-repository --force, marker delete). What shipped: --destroy on the existing cdkd bootstrap command (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-prefix are covered); any deployed stack still referencing them refuses the teardown with a per-stack listing (--force overrides — under --destroy the flag means "skip the reference scan", not the create side's "reconfigure"). The state bucket is kept by default; --include-state-bucket opts it in and is refused (no --force override) 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. Interactive y/N (default No) confirmation prints the full deletion plan; --yes skips; non-TTY stdin without --yes is a hard error (recreate-confirm-prompt convention). Idempotent: missing pieces are skipped with info lines (mirror of ensureAssetStorage), and every S3 call passes ExpectedBucketOwner (a foreign bucket squatting the predictable name is refused, never deleted). --no-assets + --destroy and --include-state-bucket without --destroy are rejected as contradictory. Tests: tests/unit/cli/bootstrap-destroy.test.ts (21 tests — teardown order incl. marker-deleted-last, marker-driven custom names, ExpectedBucketOwner on every call, versioned emptying, reference-scan refusal + --force override, no-marker no-op, idempotent skips, foreign-bucket 403 refusal, declined / empty-answer / non-TTY confirmation, --include-state-bucket refusals + happy path + marker-less state-bucket-only path, flag validation). Docs: docs/cli-reference.md bootstrap Teardown section, docs/state-management.md marker paragraph.

Recently Implemented (2026-07-15):

  • cdkd local run-task recognizes 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 literal cdk-hnb659fds-container-assets-. After PR 2, cdkd deploy publishes container assets into the cdkd-owned repo cdkd-container-assets-{acct}-{region} and rewrites templates to point there, so a migrated stack's template (and its --from-state state) 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-disk cdk.out build. The same sites also missed any cdk bootstrap --qualifier <custom> account (a pre-existing gap — only the hnb659fds default matched). What shipped (PR 3 of 3): both classification sites (the flat / Fn::Sub path and the shared classifyResolvedImage used by the Fn::GetAtt / Fn::Join fromEcrRepository paths) now go through one helper isCdkAssetImageUri, 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 as kind: 'cdk-asset' (resolve via cdk.out) instead of kind: 'ecr' (pull from ECR). No cdk-local upstream change is needed: cdkd keeps its OWN still-local ecs-task-resolver.ts / lambda-resolver.ts (they are not shimmed from cdk-local), and cdk-local's intrinsic-image helpers 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 asset sourceHash + ECR-pull fallback, not by repo-prefix classification). Tests: 3 new ecs-task-resolver unit tests — cdkd-container-assets- Fn::Sub → cdk-asset with the hash extracted; custom-qualifier cdk-myqual123-container-assets- Fn::Sub → cdk-asset; a Fn::Join fromEcrRepository whose state-resolved repo name is cdkd-container-assets-... → cdk-asset (exercising the shared classifyResolvedImage site).
  • 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 that cdk gc garbage-collects. What shipped (PR 2 of 3): in cdkd-assets mode (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; custom fileAssetsBucketName / staging buckets / cross-region destinations stay verbatim (§8), and objectKey / imageTag flow through unchanged; (2) the publishers redirect through that table (AssetPublisher.addAssetsToGraph({redirect}) — deploy AND publish-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::Sub template strings, folded pseudo-parameter-only Fn::Join runs; lookalike names like <bucket>-backup are never corrupted) — applied by deploy (top-level + every nested child template via NestedStackProviderContext.assetRedirect), diff (incl. --recursive children, so the plan previews the one-time migration diff), and import (top-level + the recursive CFn-migration child walk, so imported state matches the next deploy); synth / export stay 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.useCdkBootstrapAssets pin 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 repointing Code / 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, resolveUseCdkBootstrapAssets config tests; integ tests/integration/asset-migration/ (legacy deploy → bootstrap → migration diff → in-place repoint incl. nested child + env-var asset URL read back from GetFunctionConfiguration → opt-out round-trip → Docker/ECR leg → clean destroy).
  • cdkd bootstrap now 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 gc decides "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, runtime s3.Asset readers break). Storage not referenced by the CDK bootstrap stack is structurally out of gc's reach — hence cdkd-owned asset storage (design at docs/design/1002-cdkd-asset-storage.md, moved onto main by this PR). What shipped (PR 1 of 3): (1) cdkd bootstrap additionally creates the asset bucket cdkd-assets-{accountId}-{region} (AES-256 + BucketKey, deny-external-account policy, deliberately NO versioning — immutable content-addressed blobs) and the container-asset ECR repo cdkd-container-assets-{accountId}-{region} (IMMUTABLE tags), then writes the marker s3://{stateBucket}/cdkd-bootstrap/{region}.json LAST (crash mid-bootstrap leaves no marker → deploys stay legacy). --no-assets opts out. Bucket-squatting defense: refuses owned-elsewhere buckets (HeadBucket + ExpectedBucketOwner, BucketAlreadyExists hard error); the marker-verification path also passes ExpectedBucketOwner. Re-running bootstrap on an existing account no longer early-returns — the state bucket is left as-is (no --force needed) and the asset storage + marker are added, which is the documented upgrade path. (2) Deploy-side AssetModeResolver reads 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-assets mode 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 local matching lands in PR 3. (3) cdkd state info lists 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 via ExpectedBucketOwner, a marker with assetSupportVersion above this binary's is hard-rejected ("upgrade cdkd"), and --profile is 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 (assetStorage in text + JSON). New real-AWS integ fixture tests/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.md new cdkd bootstrap section, docs/state-management.md marker key layout.

Recently Implemented (2026-07-03):

  • Fix: an in-place UPDATE that changes a Fn::GetAtt-consumed derived attribute now propagates to a NO_CHANGE dependent 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 via Fn::GetAtt, the dependent stayed one deploy behind. Concrete case: an ec2.LaunchTemplate + autoscaling.AutoScalingGroup where the ASG's LaunchTemplate.Version is Fn::GetAtt [Lt, LatestVersionNumber]. Changing the LaunchTemplate's instanceType bumps LatestVersionNumber 1 → 2, but the ASG was classified NO_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 the Fn::GetAtt attribute NAME equals a template property that CHANGED on the upstream. LatestVersionNumber is a computed AWS-derived attribute (resolved live via the DescribeLaunchTemplates special case in intrinsic-function-resolver.ts), never a template property, so that arm never matched. Fix: a second, allow-list-driven arm promotes a NO_CHANGE dependent when it reads — via Fn::GetAtt or Fn::Sub ${Up.Attr} — a DERIVED read-only attribute (per-type IN_PLACE_UPDATE_DERIVED_ATTRS; seeded with AWS::EC2::LaunchTemplate → LatestVersionNumber / DefaultVersionNumber) of an upstream that had an in-place (non-replacement) UPDATE — regardless of WHICH property changed, since any LaunchTemplateData edit bumps the version. Replaced upstreams are excluded from the new arm (they are already handled transitively by promoteReplacementDependents, 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 Lambda Arn) from ever triggering a spurious dependent update. 5 new unit tests (ASG promoted via GetAtt + via Fn::Sub; the synthetic change is an in-place UPDATE not a replacement; unchanged-LT stays NO_CHANGE; a non-allow-listed computed attr of an updated LT stays NO_CHANGE). New real-AWS launchtemplate-asg-inplace integ fixture (VPC + LaunchTemplate + ASG, desiredCapacity: 0): Phase 1 asserts ASG LaunchTemplate.Version == "1", the UPDATE phase changes only instanceType and asserts the ASG re-points to "2" in the same deploy, then a clean destroy. covers: AWS::EC2::LaunchTemplate, AWS::AutoScaling::AutoScalingGroup.

  • Fix: Fn::GetAtt on CC-routed AWS::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 CREATE ResourceModel is sparse for Backup, so Fn::GetAtt(<Vault>, 'BackupVaultArn') (the canonical CDK shape, emitted by vault.backupVaultArn) fell through cdkd's intrinsic-resolver constructAttribute default to the physicalId — which for a BackupVault is the vault NAME, not the ARN — with only a warn. Deploy stayed green (a silent GetAtt divergence); any downstream consumer that needs the real ARN (an IAM policy, a cross-stack reference, a CfnOutput) got the bare name. Same systemic enrichment-gap bug class as #844 / #864 / #865 / #866 / the Events::Connection Arn fix. Fix: add AWS::Backup::BackupVault (BackupVaultArn / EncryptionKeyArn / BackupVaultName), AWS::Backup::BackupPlan (BackupPlanArn / VersionId / BackupPlanId), and AWS::Backup::BackupSelection (SelectionId / BackupPlanId, extracted from the compound <SelectionId>|<BackupPlanId> CC primaryIdentifier) cases to enrichResourceAttributes. Each overlays the readOnly attributes from a generic Cloud Control GetResource read-back on the physicalId (the cleanest source for a type with no SDK provider) via a new best-effort readBackupResourceModel helper — a failed read leaves the CC attribute shape unchanged and never fails the deploy, with physicalId-derived fallbacks (BackupVaultName / BackupPlanId from the physicalId, SelectionId / BackupPlanId from 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 — the no stale fixture files for unregistered types test would hard-fail otherwise); they join docs/_generated/enrichment-coverage.json's auto-generated enrichedWithoutCachedSchema list. 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-AWS backup integ fixture deploys a BackupVault (removalPolicy DESTROY) + BackupPlan referencing the vault + a tag-based BackupSelection, exposes Fn::GetAtt(Vault, 'BackupVaultArn') as a CfnOutput('VaultArn'), and asserts the resolved output STARTS WITH arn: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::Topic Subscription list 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 declared Subscription in handledProperties (keeping it on the SDK fast path) but create() deliberately skipped it, update() had no branch, and getDriftUnknownPaths() excluded it — so any Topic carrying an inline Subscription list got NO subscriptions on AWS while cdkd reported success, and drift was blind to it. The old comment assumed CDK always manages subscriptions as separate AWS::SNS::Subscription resources — true for L2 topic.addSubscription(), but NOT for L1 CfnTopic with subscription: [...] or migrated CloudFormation templates, which declare them inline on the Topic (exactly as CloudFormation creates and updates them). Fix: create() issues one Subscribe per entry (required Protocol + Endpoint, plus documented optional attributes — RawMessageDelivery / FilterPolicy / FilterPolicyScope / RedrivePolicy / DeliveryPolicy / ReplayPolicy / SubscriptionRoleArn — passed through the Subscribe Attributes map, object-valued policies JSON-stringified via the exported buildSubscriptionAttributes helper); update() diffs old vs new lists on (Protocol, Endpoint) identity — added entries Subscribe, removed entries resolve their live SubscriptionArn via ListSubscriptionsByTopic (paginated, PendingConfirmation skipped) then Unsubscribe. getDriftUnknownPaths() now returns []; readCurrentState() reverse-maps the live (Protocol, Endpoint) pairs via ListSubscriptionsByTopic — but ONLY when state actually recorded an inline Subscription list, 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 []. New sns-inline-subscription integ fixture (L1 CfnTopic inline-subscribed to CfnQueue A, UPDATE phase switches the endpoint to queue B and asserts B subscribed + A unsubscribed, clean destroy). covers: AWS::SNS::Topic.

  • Fix: removing LoggingConfiguration / TracingConfiguration / EncryptionConfiguration from a StepFunctions StateMachine is now actually applied on UPDATE — src/provisioning/providers/stepfunctions-provider.ts (issue #978). Bug: UpdateStateMachine is 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 to undefined, was omitted from UpdateStateMachineCommand, 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's clearOnUpdateRemoval / SQS's SQS_ATTRIBUTE_REMOVAL_RESET — when a config is present+configured in previousProperties but absent (or an empty readCurrentState placeholder) in the new properties, update() sends the explicit disable payload instead of undefined: tracing → { enabled: false }, logging → { level: OFF, includeExecutionData: false, destinations: [] } (SDK LogLevel.OFF; destinations only required when level is not OFF, so the empty list is accepted), encryption → { type: AWS_OWNED_KEY } (SDK EncryptionType.AWS_OWNED_KEY, the AWS default — resets a customer-managed CMK config). A wasMeaningfullyConfigured guard (discriminator Level for logging, Type for encryption, Enabled === true for tracing) keeps the readCurrentState empty-placeholder round-trip ({} / { Enabled: false }) a no-op so cdkd drift --revert does 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). The stepfunctions-logging integ'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 StreamSpecification changes now apply on UPDATE — enabling / disabling / changing a stream is no longer silently dropped — src/provisioning/providers/dynamodb-table-provider.ts (issue #977). Bug: StreamSpecification is classified updateable and stays on the SDK path (it is in handledProperties), but update() had NO StreamSpecification branch — enabling a stream, changing the StreamViewType, and removing a stream were ALL silently dropped: cdkd deploy reported success, state.json recorded the new spec, but AWS never got an UpdateTable with 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 own UpdateTable, fired only when JSON.stringify(new) !== JSON.stringify(old)), with three transitions keyed off whether the spec is present on each side: enable (StreamEnabled: true + the new StreamViewType, wait ACTIVE, capture LatestStreamArn); disable (new absent, previous present → StreamEnabled: false, clear the stream ARN); and view-type change (both present, different StreamViewType) — 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-materialized LatestStreamArn is enriched back into the returned StreamArn attribute (from the enable UpdateTable's TableDescription, falling back to a DescribeTable) so Fn::GetAtt [Table, StreamArn] resolves after an update-time enable rather than returning the pre-update value. 5 new unit tests (enable pins StreamEnabled: true + the fresh StreamArn; DescribeTable fallback when UpdateTable omits the ARN; removal pins StreamEnabled: false + cleared StreamArn; view-type change pins the disable → wait → re-enable sequence; unchanged → no UpdateTable). The dynamodb-streams integ gained an enable-on-UPDATE phase (CDKD_TEST_UPDATE=true): Phase 1 deploys a stream-LESS table, the UPDATE phase adds stream: NEW_AND_OLD_IMAGES (+ a Lambda / EventSourceMapping consumer, which can only exist once a stream does) and asserts StreamSpecification.StreamEnabled == true + LatestStreamArn non-null + the cdkd StreamArn output equals AWS's LatestStreamArn. covers: AWS::DynamoDB::Table.

  • Feature: ApiGateway Stage MethodSettings are 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 everyday deployOptions.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) breaking Ref. Implementation: CreateStage does not accept method settings, so create() issues one post-create UpdateStage with replace ops 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; CFn ResourcePath is 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} + replace of 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-stage methodSettings map, 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 ~1 key; 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). The apigateway integ gained deployOptions.throttling* + an assertion that the throttling reached AWS AND provisionedBy == sdk (the stage no longer CC-routes). The apigw-stage-throttling fixture (the #963 CC-route regression) swapped its trigger to the still-unwired AccessLogSetting so 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: Ref on CC-routed ECS::Service / S3Tables Namespace + Table returns the CFn Ref component; WAFv2::WebACL Ref returns the CFn name|id|scope compound 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-Ref class). Audit: enumerated every SDK-registered type (116) and cross-checked each against its CC registry primaryIdentifier; 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] — Ref is the service ARN (FIRST segment), so a #614-routed service would have leaked arn:...|<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 — Ref is 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 that Refs them). On the CC path it is load-bearing for Namespace only (compound [TableBucketARN, Namespace] primaryIdentifier); Table's CC primaryIdentifier is the bare single-segment TableARN (describe-type-verified), so a #614-routed Table stores a pipe-free ARN whose Ref still 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 stored TableName attribute). (3) AWS::WAFv2::WebACL is the INVERSE divergence: CFn's Ref IS the pipe-joined name|id|scope compound (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-exported parseWebACLArn (global-scope ARNs map to the CLOUDFRONT scope word). Excluded with documented rationale (synthetic / undocumented Ref, 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). Broad multi-resource integ re-ran clean for the resolver change.

  • Fix: Ref on a Cloud-Control-routed ApiGatewayV2 Stage / Route / Integration / Model / Deployment / RouteResponse / IntegrationResponse / Authorizer / ApiMapping returns the CFn Ref component — 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 Cognito UserPoolUser entry, 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 with AccessLogSettings, an Integration with ResponseParameters / TlsConfig) routes through Cloud Control via #614 and stores the compound primaryIdentifier — and every V2 compound type was missing from the extraction Sets, so Ref leaked the compound id to consumers like a Route's Target: integrations/{Ref <Integration>} / AuthorizerId: {Ref: <Authorizer>} and an ApiMapping's Stage: {Ref: <Stage>}. Fix (all 9 types docs-verified + describe-type-verified): Stage / Route / Integration / Model / Deployment (2-segment [ApiId, <ref>]) and RouteResponse / IntegrationResponse (3-segment, Ref = trailing) join the after-pipe Set; Authorizer ([AuthorizerId, ApiId]) and ApiMapping ([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 / DomainName have simple pipe-free primaryIdentifiers (no change). 13 new unit tests (each type CC-compound, per-branch SDK pass-through, and the daily Route-wiring Fn::Join integrations/{Ref} + AuthorizerId shapes — 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 broad multi-resource integ re-ran clean for the resolver change.

  • Fix: Ref on a Cloud-Control-routed ApiGateway Stage / Resource / Authorizer / Deployment / DocumentationPart returns the CFn Ref component, not the compound physical id — src/deployment/intrinsic-function-resolver.ts (issue #963, found by /hunt-bugs sweep 11). Bug: a REST API whose Stage carries MethodSettings (synthesized by CDK's everyday deployOptions.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::Stage was missing from REF_RETURNS_SEGMENT_AFTER_PIPE, so the CDK-generated Lambda Permission SourceArn (an Fn::Join over {Ref: <Stage>}) resolved to arn:...: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 / Authorizer join the after-pipe Set (docs-verified: Ref returns the trailing stage name / resource id / authorizer id); Deployment / DocumentationPart have REVERSED primaryIdentifier order ([<refId>, <restApiId>] — the Ref component comes FIRST), so a new sibling Set REF_RETURNS_SEGMENT_BEFORE_FIRST_PIPE extracts the segment before the first pipe (Deployment matters in every CDK template: the Stage's DeploymentId is {Ref: <Deployment>}). Method / DocumentationVersion are excluded — their AWS-docs pages document no Ref return 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-Permission Fn::Join shape — 6 fail without the fix). New real-AWS apigw-stage-throttling integ fixture pins the full chain: Stage really takes the CC route (provisionedBy == cc-api guard), 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 --replace delete-first — instead of a raw AlreadyExists — src/deployment/deploy-engine.ts (issue #960 follow-up, surfaced by /hunt-bugs sweep 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 confusing AlreadyExists and 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, throws NAMED_REPLACEMENT_COLLISION explaining BOTH remediations — rename (CFn parity) or re-run with cdkd deploy --replace for 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) under UpdateReplacePolicy: 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). The eventbridge-pipes integ gained two phases: a Source switch (length-1 createOnly) on the named pipe FAILS without --replace with the actionable message AND leaves AWS unchanged, then succeeds WITH --replace under 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 createOnlyProperties no 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-bugs sweep 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: SourceParameters itself is mutable and only stream-source sub-paths under it (.../KinesisStreamParameters/StartingPosition etc.) are createOnly — an SQS pipe changing SourceParameters.SqsQueueParameters.BatchSize (CFn: "Update requires: No interruption") was classified as a REPLACEMENT, and because the pipe carried a user-supplied Name the create-first replacement immediately died with AlreadyExists, hard-failing the deploy. Fix: getCreateOnlyPropertyPaths now returns the FULL segment paths (RFC 6901 pointers after /properties/), and the new pure createOnlyChangeRequiresReplacement(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 own valuesEqual so 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). The eventbridge-pipes integ gained an UPDATE phase (BatchSize 1 -> 2) that asserts the deploy log contains NO Replacing Pipe line 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::Schedule SDK provider — src/provisioning/providers/scheduler-schedule-provider.ts (new), src/provisioning/register-providers.ts (issue #961, found by /hunt-bugs sweep 10). Bug: the type's registry primaryIdentifier is /properties/Name only, but the AWS read/update/delete handlers resolve a bare Name against the DEFAULT schedule group — a schedule created with a custom GroupName was unaddressable via Cloud Control by ANY identifier form (bare name → NotFound; grp|name → ValidationException; ARN → name-pattern rejection; no additionalIdentifiers). Empirically: UPDATE failed NotFound, 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) threads GroupName from the resource properties through Create/Update/Delete/GetSchedule. physicalId stays the schedule NAME (CFn Ref parity + zero-migration from CC-provisioned state). UpdateSchedule sends the full desired configuration (it is a full-replace API). A GroupName change throws the typed ResourceUpdateNotSupportedError (GroupName ADDRESSES the schedule; the --replace fallback recreates it in the new group). readCurrentState (drift) + import (explicit id / template Name, no tag lookup — schedules are not taggable) included. CFn StartDate/EndDate ISO strings are converted to SDK Dates. 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-AWS scheduler-custom-group integ 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-group eventbridge-scheduler integ re-run as the routing regression. covers: AWS::Scheduler::Schedule.

  • Fix: changing a log group's KmsKeyId on redeploy is no longer silently dropped — src/provisioning/providers/logs-loggroup-provider.ts. Bug (issue #958 item 1, found by the #954 review): ReplacementRulesRegistry classifies KmsKeyId as updateable for AWS::Logs::LogGroup, but update() 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") via AssociateKmsKey / 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-AWS loggroup-kms-associate integ 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 it AssociateKmsKey fails 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 passed EventBusName to ListTargetsByRule / RemoveTargets / DeleteRule — the Name-only form targets the DEFAULT bus, so a diff-driven deletion of just the rule (bus kept in the template) reported ResourceNotFound, 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" while list-rules --event-bus-name still showed the rule attached. A second defect in extractBusNameFromArn assumed exactly rule/<bus>/<rule> (3 slash-parts) and returned default for 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 as cfnRefValueFromPhysicalId), and delete() + getAttribute() now thread EventBusName through every call (omitted for default-bus rules, preserving the previous wire shape there). 4 new unit tests (custom-bus delete passes EventBusName on all three calls / partner-bus name extraction / default-bus omission pin / getAttribute custom-bus addressing — 3 of 4 fail without the fix). The eventbridge integ fixture gained a Phase 1.5 UPDATE that drops the rule while KEEPING the bus and asserts list-rules --event-bus-name returns 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-bugs sweep 10, 2026-07-02): some CC create handlers materialize the resource FIRST and stabilize it afterwards — AWS::Synthetics::Canary creates 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 outer withRetry re-issued CreateResource against the occupied name, so every retry died with AlreadyExists instead 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 via cloudcontrol list-resource-requests: attempt 1 FAILED GeneralServiceException with Identifier: cdkd-bh-syn, attempt 2 FAILED AlreadyExists. Fix: waitForOperation's FAILED branch now throws the new CloudControlOperationFailedError (a ProvisioningError subclass carrying the progress event's ErrorCode + the operation kind), and create() runs cleanupFailedCreateRemnant before rethrowing: when a CREATE failed with an Identifier and ErrorCode !== 'AlreadyExists', the materialized remnant is best-effort deleted (reusing the provider's own NotFound-idempotent delete()), so the outer retry starts with a free name and a final failure leaves nothing behind. Safety: the AlreadyExists guard is load-bearing — an AlreadyExists FAILED 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 on AWS::CodeDeploy::DeploymentGroup) — the NotFound-idempotent delete absorbs that as a no-op. UPDATE/DELETE failures never trigger cleanup (ccOperation gate). 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-AWS synthetics-canary integ 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: Ref on AWS::Events::Rule / AWS::CloudTrail::Trail returns 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), and resolveRefValue returned the physicalId verbatim — but CloudFormation's Ref for these types returns the resource name (AWS::Events::Rule → the rule name, or <busName>|<ruleName> for a custom-bus rule — the bus|name form verified against real CloudFormation; AWS::CloudTrail::Trail → the trail name, AWS docs explicit for both). Live repro: a CfnOutput of rule.ruleName (synthesizes to {Ref: <Rule>}) printed the full rule ARN. Any consumer composing the name into another string or calling events:* / cloudtrail:* APIs by name got the ARN instead. Fix (three parts): (1) new REF_RETURNS_NAME_FROM_ARN map in the resolver — the ARN-stored sibling of REF_RETURNS_SEGMENT_AFTER_PIPE — extracts the CFn Ref value from the stored ARN (custom-bus rule ARNs rule/<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 for Fn::GetAtt Arn — it now returns an ARN-shaped physicalId verbatim; (3) the whole Ref-value derivation is extracted into the exported pure cfnRefValueFromPhysicalId(resourceType, physicalId) and the cdkd orphan rewriter'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's Ref for those types IS the ARN; ECS::Cluster stores the name (also correct). 7 new resolver unit tests (default-bus name, custom-bus bus|name, trail name, double-ARN guards ×2 — all failing pre-fix) + 2 orphan-rewriter tests (Events::Rule + Cognito compound). The eventbridge integ fixture now stores rule.ruleName (→ {Ref}) in an SSM parameter and verify.sh asserts the value equals <busName>|<actual rule name> from list-rules. Verified live: pre-fix output printed the ARN, fixed binary prints the rule name; the bus|name custom-bus form pinned by deploying a real CloudFormation stack and reading its Ref outputs. 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 with LoggingConfiguration (StateMachine + fresh Role + DefaultPolicy + LogGroup) failed to deploy 100% of the time: cdkd's fast SDK path issues CreateStateMachine ~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-AWS stepfunctions-logging integ fixture — first integ coverage for SFN LoggingConfiguration (CREATE under the race, in-place log-level ALL -> ERROR UPDATE, functional start-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 TableClass on redeploy is no longer silently dropped — src/provisioning/providers/dynamodb-table-provider.ts. Bug (found by /hunt-bugs): changing a table's tableClass (STANDARD <-> STANDARD_INFREQUENT_ACCESS, a common cost-optimization redeploy) reported Updated: 1 success 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 next cdkd diff saw no difference and the switch could never self-heal. CloudFormation applies it in place via UpdateTable ("Update requires: No interruption"). Fix: TableClass now rides the existing BillingMode/ProvisionedThroughput UpdateTable branch, with the throughput/billing fields gated on their own change detection so a class-only change does not re-assert unchanged throughput (AWS rejects an UpdateTable whose 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-AWS dynamodb-tableclass-switch integ 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's architecture with unchanged code reported Updated: 1 success while AWS kept the OLD architecture. Architectures rides on UpdateFunctionCode (the Lambda API ties the instruction set to a code deployment), but update() only fired UpdateFunctionCode when the Code property changed — and never passed Architectures at all. Because the silently-dropped change was still written to cdkd state, the next cdkd diff saw no difference and the switch could never self-heal. CloudFormation applies it in place ("Update requires: No interruption"). Fix: update() now fires UpdateFunctionCode when the architecture changed even if the code is byte-identical, passing Architectures on an architecture change; a removed property reverts to the x86_64 default (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-AWS lambda-arch-switch integ fixture asserts BOTH the function config AND the actual runtime (process.arch via 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 returns arm64; 0 orphans). covers: AWS::Lambda::Function.

Recently Implemented (2026-06-29):

  • Fix: changing a log group's LogGroupClass on redeploy now fails actionably instead of being silently dropped — src/provisioning/providers/logs-loggroup-provider.ts. Bug (found by /hunt-bugs): switching a logs.LogGroup's logGroupClass (STANDARD <-> INFREQUENT_ACCESS, a common cost-optimization redeploy) reported Updated: 1 success 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's update() ignored the property entirely. Because the silently-dropped change was still written to cdkd state, the next cdkd diff saw no difference and it could never self-heal. Fix: update() now throws the typed ResourceUpdateNotSupportedError BEFORE any other mutation when the class changed, with the remediation spelled out (--replace recreates the group under the new class; the existing stateful guard additionally requires --force-stateful-recreation since 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-AWS loggroup-class-guard integ 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's createOnlyProperties. So an immutable-property change on any OTHER type (e.g. AWS::EFS::FileSystem.PerformanceMode) was mis-classified as an in-place UPDATE (cdkd diff showed "1 to update"); the provider's update() 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 --replace and --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's createOnlyProperties from the CFn registry schema via cloudformation:DescribeType (new getTopLevelCreateOnlyProperties, cached + graceful-degradation, mirroring write-only-properties.ts) and uses it as a replacement fallback for any property the registry does not explicitly classify (ReplacementRulesRegistry.isClassified gates the fallback so a deliberate updateableProperties / conditional classification is never overridden) — so a createOnly change on ANY type correctly drives a replacement and cdkd diff reports it honestly; (2) the property-driven replacement path in the deploy engine now applies the same stateful guard as --replace (isStatefulRecreateTargetForReplace + --force-stateful-recreation), throwing STATEFUL_REPLACE_BLOCKED rather than silently destroying a stateful resource's data; (3) EFSProvider.create derives its CreationToken from a content hash of the create properties (cdkd-<logicalId>-<hash>) instead of a bare cdkd-<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; the replacement-immutable-name integ'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-AWS efs-immutable-replacement integ: deploy EFS maxIO → cdkd diff reports a replacement → deploy WITHOUT the flag is blocked → deploy WITH --force-stateful-recreation performs the DELETE+CREATE (new FileSystemId) → destroy clean. Verified end-to-end against real AWS (0 orphans), plus the updated replacement-immutable-name and the broad bench-cdk-sample regression. covers: AWS::EFS::FileSystem.

  • Fix: S3 replication combined And filter (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 the Filter.And operator (Filter: { And: { Prefix, TagFilters[] } } — CDK's L2 emits this whenever a rule sets both prefix and tags). applyReplicationConfiguration read only the top-level Filter.Prefix / Filter.TagFilter and never Filter.And, so a combined filter fell through to the empty-filter branch and produced Filter: {} — 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 AWS And { 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's Filter.And.TagFilters. Fix: both sides now handle the canonical And shape — the write path translates Filter.And { Prefix?, TagFilters[] } → SDK And { Prefix?, Tags[] }, and the readback round-trips AWS And { Prefix, Tags[] } → CFn And { Prefix, TagFilters[] } preserving all tags (the top-level prefix/tagFilter branches were also tightened from truthy to !== undefined so an empty-string prefix round-trips instead of dropping to replicate-all). 4 new unit tests (write: combined And prefix+tags → And.Tags with every tag; And tags-only; single TagFilter → Tag; readback: AWS And → CFn And.TagFilters). New real-AWS s3-replication-and-filter integ fixture: deploys versioned source+dest buckets + a replication role with a rule using Filter.And { Prefix: logs/, TagFilters: [replicate=yes] }, asserts GetBucketReplication returns the And filter verbatim (NOT replicate-all), re-deploys changing the And prefix logs/ → 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 UserPoolUser no longer leaks its Cloud Control compound id through Ref — src/deployment/intrinsic-function-resolver.ts. Bug (found by the compound-id-Ref family audit — the missed Cognito sibling of the resource-server / group / IdP / domain fix below): AWS::Cognito::UserPoolUser has no SDK provider, so it routes through Cloud Control, whose primaryIdentifier is the compound <userPoolId>|<username>; cdkd stored that compound as the physicalId. CloudFormation's Ref for the type returns ONLY the trailing <username> (AWS docs are explicit), but the type was missing from REF_RETURNS_SEGMENT_AFTER_PIPE. Because there is no L2 for UserPoolUser and CfnUserPoolUser exposes no Attr* getter, the natural CDK pattern new CfnUserPoolUserToGroupAttachment(this, 'x', { username: user.ref, ... }) fed the resolver {Ref: User}, which leaked <userPoolId>|admin; the attachment's AdminAddUserToGroup call then failed with User does not exist (real-AWS repro confirmed: 3 of 4 resources created, Attach CREATE failed, full rollback). Fix: add AWS::Cognito::UserPoolUser (→ username) to REF_RETURNS_SEGMENT_AFTER_PIPE. 1 new parameterized unit test (Ref returns the bare admin, not <poolId>|admin). New real-AWS cognito-userpool-user-ref integ fixture deploys a UserPool + Group + UserPoolUser + a CfnUserPoolUserToGroupAttachment whose username consumes user.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-Ref bug class as the ::UserPoolResourceServer family + 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 TimeToLiveSpecification AttributeName 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-limits UpdateTimeToLive to 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 clear ProvisioningError BEFORE the doomed UpdateTimeToLive call when both the old and new specs are enabled with a different AttributeName, 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-attribute Enabled toggles all pass through unchanged. The update() catch now also passes a ProvisioningError straight through (mirroring create()) 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 single readTtlSpec normalizer so the Enabled-absent-means-true default cannot drift between them. 5 new unit tests (attribute-name change throws + no UpdateTimeToLive attempted; error not double-wrapped; enable-from-absent / enable-from-disabled / disable all pass through). New real-AWS dynamodb-ttl-attr-change integ fixture: deploys a PAY_PER_REQUEST table with TTL on ttlA, re-deploys (CDKD_TEST_UPDATE=true) requesting TTL on ttlB, asserts the deploy FAILS with the actionable message AND that AWS TTL is still on ttlA (the guard fired before any UpdateTimeToLive call), 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 standard cognito.UserPool + ResourceServer + a UserPoolClient requesting the resource-server scope (OAuthScope.resourceServer(rs, scope)) failed at client CREATE with Invalid scope requested: us-east-1_xxx|api/read. AWS::Cognito::UserPoolResourceServer has no SDK provider, so it routes through Cloud Control, whose primaryIdentifier is the compound <userPoolId>|<identifier>; cdkd stores that compound as the physicalId. CloudFormation's Ref for the type returns ONLY the trailing <identifier> segment (per the AWS docs), but the type was missing from REF_RETURNS_SEGMENT_AFTER_PIPE, so cdkd's resolver returned the whole compound. The CDK synth shape AllowedOAuthScopes: [{Fn::Join: ["", [{Ref: ResourceServer}, "/read"]]}] then produced <userPoolId>|api/read instead of api/read, which Cognito rejects. Fix: add the whole Cognito UserPool-child family whose CC primaryIdentifier is <userPoolId>|<child> and whose CFn Ref returns the trailing <child> — AWS::Cognito::UserPoolResourceServer (→ identifier), ::UserPoolGroup (→ group name), ::UserPoolIdentityProvider (→ provider name), ::UserPoolDomain (→ domain) — to REF_RETURNS_SEGMENT_AFTER_PIPE (joining the already-present ::UserPoolClient). The two attachment types (::UserPoolRiskConfigurationAttachment / ::UserPoolUICustomizationAttachment) are intentionally NOT added — their Ref returns 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 asserting Ref returns the bare trailing segment, plus the end-to-end Fn::Join scope case {Ref: ResourceServer}/read → api/read). New real-AWS cognito-resource-server integ fixture deploys a UserPool + ResourceServer + a client whose AllowedOAuthScopes references the resource-server scope, plus a UserPoolGroup and a UserPoolDomain, asserts the client's AllowedOAuthScopes reaches 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-Ref bug class as the AWS::ApiGateway::Model / ::RequestValidator fixes.

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: the ResourceUpdateNotSupportedError thrown when an existing Schema attribute is removed / modified previously suggested cdkd deploy --replace alone — but AWS::Cognito::UserPool IS in cdkd's STATEFUL_TYPES guard, so a bare --replace is refused with STATEFUL_REPLACE_BLOCKED. The message now correctly says cdkd deploy --replace --force-stateful-recreation. (2) Hardening (reviewer nit): a Schema entry with no Name was silently skipped by the diff (neither added nor rejected); it now throws a clear ProvisioningError (CDK synth always emits Name; this only fires on a malformed hand-written L1 template). (3) Hardening (reviewer nit): a comment on STANDARD_USER_POOL_ATTRIBUTES documents that the OIDC-claim snapshot may lag AWS and that a future standard attribute would fail LOUDLY at AWS (never a silent drop). The update() catch now also passes a ProvisioningError straight through (mirroring create()) 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 existing cognito.UserPool and re-deploying reported Updated: 1 success while AWS kept the OLD schema — the new attribute never appeared. UpdateUserPool does not accept Schema, and cognito-provider.update() ignored the property entirely (its previousProperties arg was unused), so the change reached neither AWS nor any error. AWS does support adding a custom attribute in place via the separate AddCustomAttributes API (CloudFormation uses it; adding a Schema attribute is "Update requires: No interruption"). Because the silent-dropped change was still written to cdkd state, the next cdkd diff saw no difference and the add could never self-heal. Fix: after UpdateUserPool, update() now diffs the new vs previous Schema by attribute name and calls AddCustomAttributes for 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 with ResourceUpdateNotSupportedError (pointing at cdkd deploy --replace --force-stateful-recreation, which recreates the pool and deletes all users — UserPool is a stateful-recreate-guarded type) rather than silently dropped; the typed error is re-thrown past the provider's generic ProvisioningError wrapper so the deploy engine's --replace fallback can catch it. A byte-identical Schema is a no-op (no AddCustomAttributes call, so a no-drift redeploy never errors with "attribute already exists"). Custom vs standard attributes are told apart by a STANDARD_USER_POOL_ATTRIBUTES set (the OIDC claim names); readCurrentState already emits Schema, so cdkd drift was 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-AWS cognito-custom-attribute-add integ fixture deploys a pool with tenantId + level (asserts AWS reports exactly 2 custom attributes), re-deploys adding region (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 a kinesis.Stream's streamMode (e.g. PROVISIONED -> ON_DEMAND, a common cost-optimization redeploy) and re-deploying reported Updated: 1 success while AWS kept the OLD mode. kinesis-provider.update() had no UpdateStreamMode call — it only reconciled ShardCount (and only when the NEW mode was PROVISIONED), so a StreamModeDetails.StreamMode change reached neither AWS nor any error. Worse, the change WAS written to cdkd state (properties.StreamModeDetails), so the next cdkd diff saw no difference (state == template) and the switch could never self-heal; cdkd drift was also blind to it (its observedProperties baseline captured the real AWS mode, which matched the AWS-current read). In CloudFormation StreamModeDetails is "Update requires: No interruption", applied via UpdateStreamMode. Fix: update() now switches the mode FIRST via UpdateStreamMode (resolving the required StreamARN via DescribeStreamSummary) when oldMode !== newMode, then waits for ACTIVE. Ordering matters: PROVISIONED -> ON_DEMAND switches mode and skips the (invalid-on-on-demand) UpdateShardCount path; 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-AWS kinesis-stream-mode-switch integ 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 async DeleteStream DELETING window). Verified end-to-end against real AWS (the previously-dropped mode switch now reaches AWS; 0 orphans).

  • Fix: cdkd drift no longer reports phantom drift on an IAM Role right after deploy (sibling Default Policy race) — src/deployment/deploy-engine.ts. Bug (deferred from /hunt-bugs): running cdkd drift immediately after cdkd deploy could report a false positive on an AWS::IAM::Role — - Policies:[{...DefaultPolicy...}] / + Policies:[] — for essentially every Lambda / L2 construct whose grant emits a Default Policy, one of the most common CDK patterns. CDK emits a construct's grants as a SEPARATE AWS::IAM::Policy resource attached to the role via Roles: [role], which AWS implements via iam:PutRolePolicy, so the inline policy appears in ListRolePolicies. The cdkd drift AWS-current read already filters these sibling-managed inline policies (issue #323's collectInlinePolicyNamesManagedBySiblings, fed by buildReadCurrentStateContext from full state). But the deploy-time observedProperties capture passed no sibling context, so the same filter no-op'd — and the role's ListRolePolicies capture RACED the sibling policy's PutRolePolicy. When the read landed after the write, the sibling-managed DefaultPolicy* leaked into observedProperties.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 a cdkd drift assertion to the lambda-event-invoke-config-update fixture). 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 through kickOffObservedCapture at both the post-CREATE and post-UPDATE sites. Each AWS::IAM::Policy whose Roles/Users/Groups references the captured principal (via {Ref: <logicalId>} or the literal physical name) is synthesized into the resolved-property shape collectInlinePolicyNamesManagedBySiblings consumes, so the SAME sibling-policy filter that runs at drift time now runs at capture time. A role's declared inline Policies (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 + sibling Default Policy → capture context carries the resolved sibling; Role with no sibling → no context; Users attachment field for an AWS::IAM::User; non-principal S3 bucket → no context). New real-AWS iam-role-policies-drift-clean integ fixture deploys a Lambda-with-grant (service-role Default Policy sibling) plus a standalone role carrying BOTH a declared inline policy AND an addToPolicy() Default Policy sibling, runs cdkd drift twice and asserts NO drift on any AWS::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 broad lambda integ for the cross-cutting deploy-engine.ts change.

  • Fix: EventBridge ApiDestination (the daily webhook pattern) is no longer undeployable — AWS::Events::Connection Arn GetAtt enrichment gap — src/provisioning/cloud-control-provider.ts. Bug (found by /hunt-bugs): a standard events.Connection + events.ApiDestination pair failed at ApiDestination CREATE with Model validation failed (#/ConnectionArn: failed validation constraint for keyword [pattern]). AWS::Events::Connection is CC-API-provisioned (no SDK provider) and its primaryIdentifier is Name, so the cdkd physicalId is the connection NAME, not the ARN. Its readOnly Arn attribute was NOT in enrichResourceAttributes, so AWS::Events::ApiDestination's ConnectionArn — Fn::GetAtt(Connection, 'Arn'), the canonical CDK shape — fell through the resolver's constructAttribute to 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: add AWS::Events::Connection (Arn / SecretArn via DescribeConnection; ArnForPolicy derived by stripping the ARN's trailing unique segment) and AWS::Events::ApiDestination (Arn via DescribeApiDestination; ArnForPolicy derived likewise) cases to enrichResourceAttributes, 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 DescribeConnection Name input + already-present-attrs short-circuit + per-field-independence partial overlay + best-effort failure; ApiDestination Arn/ArnForPolicy overlay + best-effort failure). New real-AWS eventbridge-api-destination integ fixture deploys Connection + ApiDestination + a Rule whose target is the ApiDestination, asserts the resolved ConnectionArn AND 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 join docs/_generated/enrichment-coverage.json's enrichedWithoutCachedSchema list 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 in src/provisioning/register-providers.ts + src/analyzer/replacement-rules.ts. Bug (found by /hunt-bugs): an async Lambda configured with an onFailure destination plus maxEventAge / retryAttempts synthesizes an AWS::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 empty DestinationConfig.OnSuccess: {} even when only OnFailure was configured — so every UPDATE that changed maxEventAge or retryAttempts hard-failed model validation with Model validation failed (#/DestinationConfig/OnSuccess: required key [Destination] not found) and rolled back. CREATE worked (the template carried no OnSuccess), 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 whose create() and update() both call PutFunctionEventInvokeConfig — a synchronous full-replace write, exactly what CloudFormation uses for this type — sending only the configured OnFailure and never an empty OnSuccess, 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 to DeleteFunctionEventInvokeConfig (ResourceNotFound = idempotent success with region check); readCurrentState() surfaces the mutable props for cdkd drift, dropping the AWS-injected empty OnSuccess and always emitting Qualifier (CDK synthesizes $LATEST into state, so omitting it would phantom-drift a base async Lambda); update() is a logical no-op when nothing changed (drift --revert round-trip). replacement-rules.ts adds a rule marking the two CREATE-ONLY props (FunctionName / Qualifier) as replacement-triggers while keeping MaximumEventAgeInSeconds / MaximumRetryAttempts / DestinationConfig in-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-AWS lambda-event-invoke-config-update integ 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, the cc-api → sdk routing is sticky per the existing provisionedBy rule, 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 KMS KmsKey) silently dropped — CFn PascalCase not mapped to SDK camelCase — src/provisioning/providers/ecr-provider.ts. Bug (found by /hunt-bugs): an ECR repository created with imageScanOnPush: true deployed "successfully" but AWS reported scanOnPush: 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 to CreateRepository verbatim (cast as ImageScanningConfiguration — compile-only, a runtime lie), so the SDK ignored the unknown ScanOnPush key and silently reset scanOnPush to false. The same casing trap silently dropped a KMS repo's KmsKey (masked in most tests because AES256 is the SDK default). The update() path's PutImageScanningConfiguration had 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 via toSdkScanningConfig ({ScanOnPush} → {scanOnPush}) and toSdkEncryptionConfig ({EncryptionType, KmsKey} → {encryptionType, kmsKey}, kmsKey only on EncryptionType: KMS) in both create() and update(). 4 new unit tests assert the create + update inputs carry the camelCase keys (and never forward the PascalCase key). New real-AWS ecr-scanning integ fixture deploys with imageScanOnPush: true (asserts AWS reports scanOnPush=true), re-deploys with it false (asserts the update reaches AWS), then destroys clean. Verified end-to-end against real AWS (0 orphans). The existing ecr fixture didn't catch it — it never set or asserted scanOnPush.

  • 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 that cdkd deploy --replace was 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, and docs/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: --replace is now a real deploy flag. The deploy engine's normal-update catch block already falls back to DELETE + CREATE for the Cloud Control UnsupportedActionException / "does not support UPDATE" signals (unconditional, pre-existing); --replace extends that fallback to the typed ResourceUpdateNotSupportedError an 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 same isStatefulRecreateTargetSync guard list --recreate-via-cc-api uses) requires --force-stateful-recreation to 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 --replace alone. Threaded as DeployEngineOptions.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 --replace real-AWS probe — an immutable AWS::Glue::SecurityConfiguration EncryptionConfiguration change) deployed an EMPTY EncryptionConfiguration on Phase 1 because glue-provider.ts's buildEncryptionConfiguration read the CFn property S3Encryption (singular) while CDK / CloudFormation emit S3Encryptions (plural — CloudWatchEncryption / JobBookmarksEncryption ARE singular in both CFn and the SDK, but the CFn S3Encryptions list maps to the SDK S3Encryption field). 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: read S3Encryptions (plural) on create, emit S3Encryptions on the readCurrentState readback, 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). The glue-securityconfig-replace verify.sh runs 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 broad lambda integ for the cross-cutting deploy-engine.ts / deploy.ts change. The ~20 pre-existing cdkd deploy --replace references are now all accurate; docs/cli-reference.md gains 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 that cdkd deploy --replace was 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, and docs/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: --replace is now a real deploy flag. The deploy engine's normal-update catch block already falls back to DELETE + CREATE for the Cloud Control UnsupportedActionException / "does not support UPDATE" signals (unconditional, pre-existing); --replace extends that fallback to the typed ResourceUpdateNotSupportedError an 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 same isStatefulRecreateTargetSync guard list --recreate-via-cc-api uses) requires --force-stateful-recreation to 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 --replace alone. Threaded as DeployEngineOptions.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 --replace real-AWS probe — an immutable AWS::Glue::SecurityConfiguration EncryptionConfiguration change) deployed an EMPTY EncryptionConfiguration on Phase 1 because glue-provider.ts's buildEncryptionConfiguration read the CFn property S3Encryption (singular) while CDK / CloudFormation emit S3Encryptions (plural — CloudWatchEncryption / JobBookmarksEncryption ARE singular in both CFn and the SDK, but the CFn S3Encryptions list maps to the SDK S3Encryption field). 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: read S3Encryptions (plural) on create, emit S3Encryptions on the readCurrentState readback, 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). The glue-securityconfig-replace verify.sh runs 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 broad lambda integ for the cross-cutting deploy-engine.ts / deploy.ts change. The ~20 pre-existing cdkd deploy --replace references are now all accurate; docs/cli-reference.md gains 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 a lambda.LayerVersion) and redeploying the SAME logical id was misclassified as an in-place UPDATE. AWS::Lambda::LayerVersion is fully immutable on AWS — there is no UpdateLayerVersion API; every change requires a fresh PublishLayerVersion — so the provider's update() hard-failed with an immutable-resource error that pointed users at a cdkd deploy --replace flag that does not exist, leaving a layer-content change completely undeployable. In CloudFormation every AWS::Lambda::LayerVersion property is "Update requires: Replacement", so cdk deploy transparently 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 from ReplacementRulesRegistry having no rule for AWS::Lambda::LayerVersion, so requiresReplacement defaulted to "updateable". Fix: replacement-rules.ts adds a replacement rule marking every AWS::Lambda::LayerVersion property (Content / LayerName / Description / CompatibleRuntimes / CompatibleArchitectures / LicenseInfo) as a replacement-trigger, plus a sibling rule for the equally-immutable AWS::Lambda::Version. A content change now drives a DELETE + CREATE, and the existing promoteReplacementDependents (issue #807) cascade re-points the consuming function at the new layer version ARN — matching cdk deploy exactly, with no manual flag. The provider's update() becomes a defensive fallback whose error message no longer references the non-existent --replace flag. New unit tests cover the LayerVersion / Version replacement-rule classification and a regression guard that the provider message never reintroduces --replace. New real-AWS lambda-layer-version-update integ fixture deploys a layer (content v1) + a consuming function, redeploys with v2 content, and asserts a new layer version :2 is 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-existent cdkd deploy --replace flag this entry called out (referenced by ~20 provider messages + docs/cli-reference.md as the escape hatch for other immutable-update / cdkd drift --revert cases) 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 --all sites — the auto-include-dependency CLOSURE walk (which EXPANDS the deploy set) and the inter-stack DAG ORDERING edges. But it treated Fn::ImportValue (a strong reference — the export must exist, so the producer is a genuine deploy dependency) and Fn::GetStackOutput (cdkd's weak reference — it reads the producer's output opportunistically from cdkd state) identically. So cdkd deploy <consumer> auto-included and silently RE-DEPLOYED any producer the consumer merely read via Fn::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's state.json (e.g. transparently upgrading its schema version), and the producer's resources were needlessly re-driven. Caught by the schema-v7-to-v8-migration integ (FAIL: producer state.version drifted ... during consumer-only Phase 3 deploy) during the 2026-06-21 staleness sweep. Fix: inferCrossStackStackDeps(stacks, opts?) gains an opts.kinds filter (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 manifest addDependency deps remain in the closure (always strong). destroy.ts is unaffected — its use of inferCrossStackStackDeps only REORDERS the already-selected destroy set (it never expands it), so keeping both kinds there is correct. New unit tests cover the kinds filter (default = both; ['ImportValue'] = strong only excludes the GetStackOutput producer; ['GetStackOutput'] = weak only). Verified end-to-end against real AWS: schema-v7-to-v8-migration now PASSES (Phase 3 deploys ONLY the consumer; producer state stays at its prior version) and the broad multi-stack-deps integ 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-events adds it. By default cdkd destroy keeps the deployments/ event store as post-mortem context (so the bucket never returns fully empty after teardown); --purge-events opts 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 helper purgeEventsAfterDestroy(reader, stack, region, {purgeEvents, runResult, interrupted}, logger) — it purges (via DeploymentEventsReader.pruneRuns(..., {all:true})) ONLY when purgeEvents===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's eventRecorder.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 destroy does NOT take the flag — cdkd events prune <stack> --all is the CDK-app-free / already-destroyed equivalent. The flag is registered directly on createDestroyCommand (not the shared destroyOptions array) so only cdkd destroy advertises 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 existing AWS::DynamoDB::Table grows AttributeDefinitions (the new index's key attribute), and the replacement rule listed AttributeDefinitions as a blanket replacement-trigger — so cdkd deploy tried to REPLACE the table (DELETE + CREATE on the same name), which failed at CREATE with Table already exists and rolled back. This diverged from CloudFormation, where adding a GSI is an in-place UpdateTable (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.ts removes AttributeDefinitions from the DynamoDB replacementProperties set and adds a conditionalReplacements predicate (attributeTypeChangedForSharedAttribute) that requires replacement ONLY when an attribute present on BOTH sides changes its AttributeType (the one case DynamoDB rejects in place); a key-attribute NAME change still surfaces as a KeySchema diff, which remains a blanket replacement. (2) dynamodb-table-provider.ts's update() now actually applies GSI add / remove / per-index throughput changes via UpdateTable's GlobalSecondaryIndexUpdates (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 per UpdateTable (an AWS constraint), a Create carries the full desired AttributeDefinitions, and a new waitForTableAndIndexesActive waits for both the table AND every GSI to be ACTIVE between 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-AWS dynamodb-gsi-update integ fixture deploys a pk-only table, redeploys adding a GSI, and asserts the table's CreationDateTime is unchanged (no replacement) + the GSI reaches ACTIVE before 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.json kept only the last 20 runs but the underlying {runId}.jsonl objects were never deleted, so after 20 runs every older run's stream lingered in S3 forever, and there was no cdkd command to purge them (so cdkd 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 new pruneSupersededRunFiles(keptRunIds) after the index write, deleting {runId}.jsonl streams 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 — new cdkd events prune <stack> subcommand (createEventsPruneCommand / eventsPruneCommand) routed through DeploymentEventsReader.pruneRuns: --all purges 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 via runIdTimestampMs off 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-region disambiguates a multi-region stack. pruneRuns deletes the matching streams then rewrites index.json to drop the pruned runs (or removes it entirely when none remain), so prune --all returns the stack's deployments/ prefix to empty. Both paths batch-delete through the new S3StateBackend.deleteRawObjects(keys) (chunked to the 1,000-key DeleteObjects ceiling, idempotent, aggregating per-key DeleteObjects errors 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. The cdkd events prune subcommand reads its inherited options via command.optsWithGlobals() because the parent events already 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 the events prune command (purge / retention / --all+--keep rejection / 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 separate src/state/ key family, never touches resource deletion). Verified end-to-end against real S3.


Recently Implemented (2026-06-15):

  • Backfill: AWS::StepFunctions::StateMachine DefinitionS3Location (silent-drop → handled, #609) — src/provisioning/providers/stepfunctions-provider.ts. DefinitionS3Location ({ Bucket, Key, Version? }) has NO field on the SDK CreateStateMachine input — CloudFormation reads the S3 object (the Amazon States Language definition) and inlines its contents as the state-machine Definition, 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 than unhandledByDesign: cdkd already does S3-fetch-and-inline for AWS::AppSync::GraphQLSchema.DefinitionS3Location. Fix: DefinitionS3Location moves from the StateMachine silentDrop set into handledProperties; buildDefinitionString() becomes async — when no inline DefinitionString / Definition is present, it fetches the S3 object (GetObject, honoring an optional Version → VersionId) and uses the body as the definition. It then applies DefinitionSubstitutions (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 into Fn::Sub and cdkd's resolver pre-resolves them before the provider sees them. Inline DefinitionString / Definition still take precedence over DefinitionS3Location (CloudFormation parity); an empty S3 body throws before CreateStateMachine runs (no half-created state machine). Substitution values are coerced from string/number/boolean only. New unit tests cover S3 fetch / VersionId passthrough / substitution application / inline-precedence (no S3 fetch) / empty-body throw. New real-AWS stepfunctions-s3-definition integ fixture (L1 CfnStateMachine + an s3_assets.Asset ASL file + a ${Greeting} substitution) asserts (via describe-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) AND AssetManifestLoader.getFileAssets (cdkd local invoke-agentcore) — excluded EVERY .json file asset (endsWith('.json')) to skip the CFn template, which wrongly dropped legitimate user .json file assets — so the ASL document (asset.<hash>.json) was never published to the bootstrap bucket and the deploy failed with The specified key does not exist. Both now exclude ONLY .template.json (top-level + .nested.template.json) via a new shared isCfnTemplateAssetPath() predicate so the two sites cannot drift. The S3-fetch path also now throws on a zero-byte object (a present Body whose transformToString() is '') before CreateStateMachine, rather than sending an empty definition. Unit tests cover the predicate, both selection sites (non-template .json published, top-level + nested templates skipped), and the empty / no-body throws.

  • Cleanup: reclassify AWS::CloudWatch::Alarm EvaluationCriteria / EvaluationInterval as unhandledByDesign (#609) — src/provisioning/providers/cloudwatch-alarm-provider.ts. Both properties exist in the CloudFormation AWS::CloudWatch::Alarm registry schema but are absent from BOTH the AWS SDK PutMetricAlarm input AND the aws-cdk-lib CfnAlarm L1 (a newer CFn-schema surface ahead of SDK / CDK support), so per the #609 backfillability rule they are not backfillable. They previously carried the generic silentDrop "not yet implemented by cdkd" placeholder (falsely implying pending work); a new provider unhandledByDesign map now records the real rationale, which the property-coverage codegen surfaces in property-coverage.generated.ts. No behavior change (the unhandledByDesign map is consumed only by the property-coverage check). Sibling reclassification candidates (Cognito WebAuthnFactorConfiguration, ECS InferenceAccelerators) were already classified in prior work.

  • Backfill: AWS::ServiceDiscovery::Service ServiceAttributes (silent-drop → handled, #609) — src/provisioning/providers/servicediscovery-provider.ts. CreateService does NOT accept ServiceAttributes (a key→value map); they ride on a separate post-create UpdateServiceAttributes control-plane call, so cdkd was silently dropping them. Fix: ServiceAttributes moves from the Service silentDrop set into handledProperties; createService() issues an UpdateServiceAttributes (wrapped in the shared withRetry transient helper) after CreateService succeeds, and on failure issues a best-effort DeleteService + 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 via UpdateServiceAttributes (changed/added keys) and DeleteServiceAttributes (keys present only in the previous state), throwing on failure so state is not written as-if-applied; the attribute diff runs even when the ServiceChange body is empty. readCurrentState for the Service gains a GetServiceAttributes readback (emits the full key→value map; best-effort on permission error). Map values are coerced from string/number/boolean only (never String() 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-AWS servicediscovery integ fixture (bare VPC + PrivateDnsNamespace + L1 CfnService with serviceAttributes) asserts (via get-service-attributes) the attributes reached AWS on a real deploy before destroying clean.

  • Backfill: AWS::ApiGatewayV2::Authorizer AuthorizerCredentialsArn (silent-drop → handled, #609) — src/provisioning/providers/apigatewayv2-provider.ts. AuthorizerCredentialsArn is the REQUEST-authorizer-only IAM role ARN API Gateway assumes to invoke the REQUEST Lambda authorizer; it is a simple create-time scalar on CreateAuthorizer / UpdateAuthorizer / GetAuthorizer (no post-create control-plane call, no atomicity handling). Fix: AuthorizerCredentialsArn moves from the Authorizer silentDrop set into handledProperties; createAuthorizer() passes it through on CreateAuthorizerCommand (ungated — AWS rejects it on JWT authorizers and CDK only emits it for REQUEST, so passing undefined when absent is a no-op); updateAuthorizer() adds it to the diffed UpdateAuthorizerCommand input alongside the other REQUEST-only fields; readAuthorizer() emits it (gated !== undefined) inside the existing AuthorizerType === '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. The serverless-api integ fixture gains an iam.Role (assumed by apigateway.amazonaws.com) and sets authorizerCredentialsArn on its standalone REQUEST authorizer; its verify.sh Assertion 5 now also asserts (via get-authorizers) that AuthorizerCredentialsArn is an IAM role ARN that reached AWS on a real deploy before destroying clean.

  • Backfill: AWS::ElasticLoadBalancingV2::Listener ListenerAttributes (silent-drop → handled, #609) — src/provisioning/providers/elbv2-provider.ts. CreateListener does NOT accept ListenerAttributes (e.g. routing.http.response.server.enabled, tcp.idle_timeout.seconds) — they ride on a separate post-create ModifyListenerAttributes control-plane call, so cdkd was silently dropping them. Fix: ListenerAttributes moves from the Listener silentDrop set into handledProperties; create() issues a ModifyListenerAttributes (wrapped in the shared withRetry transient/throttle helper) after CreateListener succeeds, and on failure issues a best-effort DeleteListener + rethrows (create atomicity — otherwise a half-configured listener strands and the next CREATE hits DuplicateListener). 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 Listener readCurrentState gains a DescribeListenerAttributes readback (sorted by Key for stable positional drift compare). Attribute Values are coerced from string/number/boolean only (never String() an object). New unit tests cover create-apply / update-diff / clear-on-remove / no-op / create-atomicity / string-passthrough / readback. The alb integ fixture sets routing.http.response.server.enabled=false via the L1 CfnListener.listenerAttributes escape hatch, and its new verify.sh asserts (via describe-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) when resolveOutputs could not resolve every output. resolveOutputs already logs a per-output warn on 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 reports No changes detected while a newly-added export silently failed to land" path is explicit in the log instead of leaving a later downstream Fn::ImportValue failure with no obvious link back. Also adds an explanatory comment on why resolving against effectiveTemplate vs the raw template is equivalent in the no-change path (condition pruning only touches Resources; outputs resolve against currentState.resources). No behavior change beyond the added log line; the resolutionFailed-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::ImportValue break, issue #875) — src/deployment/deploy-engine.ts. Bug: DiffCalculator.calculateDiff compares only Resources, so when a downstream stack newly references an already-deployed producer, CDK synth adds a new Output (with Export.Name) to the producer's template WITHOUT changing any of its resources — and the diff yields hasChanges = false. The deploy engine's no-change early-return then never called resolveOutputs() / exportIndexStore.updateForStack(), so the new export was never written to the producer's state or the exports index, and the consumer's subsequent Fn::ImportValue failed with export '...' not found in any stack. (Deploying both stacks together for the FIRST time worked, because the producer's resources are all CREATE and 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 from state.outputs, persists the new outputs (outputs-only state save) and calls exportIndexStore.updateForStack() — alongside the existing observed-properties refresh, in a single state save. A resolution that produces any undefined value (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-run does nothing; imports[] / outputReads[] preserved). New outputs-only-export integ 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 its Fn::ImportValue to the producer bucket ARN. New canonical scenario multi-stack-outputs-only-export.

  • Fix: stop unwrapping AWS::CloudFront::Distribution.OriginGroups in drift readback (closes the OriginGroups phantom drift, issue #873) — src/provisioning/providers/cloudfront-distribution-provider.ts. Bug: OriginGroups was listed in QUANTITY_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 unlike Origins / CacheBehaviors / Aliases / CustomErrorResponses (genuinely bare lists in CFn), the CFn DistributionConfig.OriginGroups property is ITSELF a { Quantity, Items } object — confirmed by synthesizing a distribution with an origin group (Quantity + Items[], each item carrying inner { Quantity, Items } Members and FailoverCriteria.StatusCodes). So convertToCfnFormat was unwrapping the top-level OriginGroups to a bare array, which no longer matched the template's { Quantity, Items }, firing phantom drift on every cdkd drift for any distribution with an origin group. (#871 had suppressed OriginGroups via getDriftUnknownPaths as a stopgap, under the mistaken assumption — from that PR's review — that the inner Members/StatusCodes were bare arrays needing a revertOriginGroup; the synth evidence shows the whole subtree is { Quantity, Items }, so the real fix is the opposite: don't touch it at all.) Fix: remove OriginGroups from QUANTITY_ITEM_FIELDS so it passes through UNTOUCHED in both directions (create still works — wrapWithQuantity was a no-op on the already-{ Quantity, Items } value), and remove DistributionConfig.OriginGroups from getDriftUnknownPaths so origin-group drift is now actually reported. 1 new unit test (OriginGroups { Quantity, Items } with inner Members/StatusCodes passes through byte-equal, not unwrapped); getDriftUnknownPaths test updated. Validated against real AWS: the s3-cloudfront fixture gains an OriginGroup (primary S3-OAI origin + HTTP fallback + failover status codes), and verify.sh asserts 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's Fn::GetAtt [<OAI>, S3CanonicalUserId] resolves to). But s3:GetBucketPolicy returns that same principal in TWO other unstable forms over the policy's lifetime: a transient { AWS: <IAM-unique-id> } (e.g. AIDA…) right after PutBucketPolicy, 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 every cdkd drift run for any stack with an OAI + S3 bucket policy (the deploy-time observedProperties capture caught the transient unique-id form; the later drift read got the ARN form — neither matched the template's canonical user id). Fix: readCurrentState now canonicalizes every recognizable OAI principal back to { CanonicalUser: <id> }. For the settled arn:…:cloudfront:user/…<oaiId> form it maps the OAI id to its S3CanonicalUserId, preferring the same-stack sibling OAI resource's already-read state attribute (zero AWS call / no cloudfront:GetCloudFrontOriginAccessIdentity IAM grant — the OAI's S3CanonicalUserId is a readOnly attribute cdkd already resolved at deploy time, threaded into the drift read via the new ReadCurrentStateContext.siblings[].attributes + .physicalId fields) and falling back to GetCloudFrontOriginAccessIdentity(<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. The ReadCurrentStateContext.siblings shape gained optional physicalId + attributes (populated by buildReadCurrentStateContext in src/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 via GetCloudFrontOriginAccessIdentity fallback 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 the s3-cloudfront integ's verify.sh to assert the AWS::S3::BucketPolicy reports clean (in the clean drift bucket, not drifted) 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's S3CanonicalUserId from already-read state rather than re-fetching it.)

Recently Implemented (2026-06-14):

  • Fix: AWS::DynamoDB::Table in-place BillingMode / ProvisionedThroughput UPDATE (was a silent drop) — src/provisioning/providers/dynamodb-table-provider.ts. Bug: update() handled OnDemandThroughput / WarmThroughput / PITR / TTL / ResourcePolicy / Kinesis / ContributorInsights / Tags but issued NO UpdateTable for BillingMode or ProvisionedThroughput, even though both are declared in handledProperties and both are mutable (CFn createOnly = only TableName + 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 in feedback_tags_on_update_must_throw). Fix: a new branch, ordered BEFORE the OnDemand/Warm throughput branches, fires a SINGLE UpdateTable whenever BillingMode OR ProvisionedThroughput changed. It forwards BillingMode when present and ProvisionedThroughput only when the (desired) mode is not PAY_PER_REQUEST (AWS rejects caps on on-demand; PROVISIONED requires them), coercing the string-typed capacity values to numbers via Number() (matching create(); 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 existing waitForTableActiveAfterUpdate helper so later branches don't race a still-UPDATING table. Per-index GlobalSecondaryIndexes ProvisionedThroughput is explicitly NOT handled (a documented deferred gap, not a silent one — needs GlobalSecondaryIndexUpdates). 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: the dynamodb-ondemand fixture gains a standalone PROVISIONED table whose capacity flips RCU 5→20 / WCU 5→10 under CDKD_TEST_UPDATE=true, and verify.sh adds a Phase-1.5 re-deploy + describe-table assertion 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 computed Fn::GetAtt attributes 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 computed readOnly attribute from silently falling through constructAttribute to the physicalId — the exact bug class fixed one-off in #844 / #864 / #865 / #866. Deliverable: a codegen + CI critic modeled on the existing gen-property-coverage / gen-unsupported-types pattern (TS-Compiler-API parse of the source, drift-on-git diff in CI). The generator parses the enrichResourceAttributes switch via the TypeScript Compiler API (extracting, per case, the enriched['Attr'] = ... keys — flat-keys like Endpoint.Address matched against their nested readOnly prop Endpoint), cross-references each type's readOnlyProperties from 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), or unenriched-computed (gap on a pure-CC type with no SDK provider — the real bug class). The audit:enrichment-coverage:check critic hard-fails ONLY on unenriched-computed. A readOnly prop that IS the type's primaryIdentifier is auto-classified not-a-gap (the resolver's physicalId fallback resolves it); refresh-cfn-schemas.mjs is extended to capture primaryIdentifier into the fixtures so this is data-driven going forward. Seed ENRICHMENT_ALLOW_LIST carves out AWS::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-enriched assignment), the classifier (every bucket, flat-key-to-nested matching, primaryIdentifier auto-allow-list, enrichment-precedence, partial coverage), and buildReport (bucketing + summary + cache-less-enrichment-case reporting). No AWS integ (pure static analysis / codegen); only /check + /check-docs gates apply.

  • Fix: Glue Job/Crawler/Trigger/Workflow update + delete hardening (4 bugs) + new glue-update-hardening integ — src/provisioning/providers/glue-provider.ts + tests/integration/glue-update-hardening/. Four confirmed bugs in the Glue providers, all in one file:

    1. Glue Job stringly-typed numeric coercion. buildJobCommonFields (shared by create + update) copied MaxRetries / AllocatedCapacity / Timeout / MaxCapacity / NumberOfWorkers verbatim, plus nested ExecutionProperty.MaxConcurrentRuns / NotificationProperty.NotifyDelayAfter. CFn can deliver these as STRINGS ("60") while the Glue SDK types them int/double; as number is compile-only and does not coerce at runtime, so a string would reach the SDK for a number-typed field (the feedback_cfn_stringly_typed_numerics_need_coerce pattern). Fix: new coerceNumber() 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 than NaN).
    2. Glue Crawler CrawlerRunningException on delete/update. delete() / update() only caught EntityNotFoundException; a mid-run crawler throws CrawlerRunningException on DeleteCrawler / UpdateCrawler → uncaught → destroy/update failed. Fix: on CrawlerRunningException, the new stopCrawlerAndWait() helper issues StopCrawler and polls GetCrawler until the crawler leaves RUNNING/STOPPING (tolerates the already-stopping race), then the delete/update is retried.
    3. 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: new waitForTriggerDeactivated() polls GetTrigger after StopTrigger; the StartTrigger re-activation always runs even when UpdateTrigger throws, but is now sequenced via capture-and-rethrow (NOT a bare finally) 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.
    4. Glue Workflow Tags silently dropped for the MAP shape. workflowTagsForCreate() only accepted Array.isArray(tags) and returned undefined for the tag-map shape CDK's CfnWorkflow.tags synths — silently dropping every tag. Fix: Workflow create now uses the map-tolerant cfnTagsToMap() (the helper every other Glue type already uses); the dead workflowTagsForCreate helper is removed. Workflow MaxConcurrentRuns (create + update) is also now run through coerceNumber().
    5. 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, so CreateCrawler was 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' to RETRYABLE_ERROR_MESSAGE_PATTERNS in src/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 outer withRetry (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 (no NaN); 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. New glue-update-hardening integ fixture (L1 CfnJob with numeric props + idle CfnCrawler + ON_DEMAND CfnTrigger running the Job + CfnWorkflow with MAP-shape tags); verify.sh asserts 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=true re-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.)
  • Feature: CloudFrontDistributionProvider.readCurrentState + getDriftUnknownPaths (closes the AWS::CloudFront::Distribution drift 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) lacking readCurrentState, so cdkd drift fell back to the generic CC-API GetResource path. CloudFront's deeply-nested DistributionConfig carries { 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: readCurrentState calls the read-only GetDistributionConfigCommand and inverts the provider's existing convertToSdkFormat via a new convertToCfnFormat (unwraps every top-level + nested-cache-behavior + origin { Quantity, Items } wrapper back to bare arrays, hoists the AWS-nested CachedMethods out of AllowedMethods to the CFn sibling shape, and drops the CallerReference idempotency token), then overlays AWS-current tags (GetDistribution ARN → ListTagsForResource, filtering aws:cdk:* auto-tags; best-effort — a tag read failure still returns the config). getDriftUnknownPaths declares DistributionConfig.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 by convertToCfnFormat, and convertToSdkFormat is symmetrically unaware of them, so OriginGroups is suppressed to avoid a properties-fallback-baseline false positive until a dedicated revertOriginGroup + a real OriginGroups integ fixture lands; PR #871 review) as drift-unknown. Because the deploy-time observed baseline is produced by the SAME readCurrentState, 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.sh extended to assert (via cdkd drift --json + jq, scoped to the AWS::CloudFront::Distribution resource) that the distribution is in the clean bucket and NOT in drifted on a freshly-deployed stack, then mutates Comment out-of-band via aws cloudfront update-distribution and asserts the distribution now appears in drifted with a Comment change, 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 but GetBucketPolicy returns it as arn: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: convertToCfnFormat is not perfectly lossless (SDK-required defaults convertToSdkFormat injects — 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 the properties-fallback baseline only walks state keys, so the extra defaults can never fire false drift. NOTE: drift.ts / drift-calculator.ts are unchanged (not in integ-destroy scope), but the provider file IS, so the merge still requires a real-AWS deploy+destroy (/run-integ s3-cloudfront).

  • Fix: AWS::S3Vectors::VectorBucket in-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 a Tags change 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 in feedback_tags_on_update_must_throw). Tags is the ONLY in-place-updatable property of a VectorBucket (the CFn registry schema marks VectorBucketName + EncryptionConfiguration create-only, so a change to either drives a replacement, not an update). Fix: updateVectorBucket() diffs old vs new tags and applies the delta via TagResource (added / changed keys) + UntagResource (removed keys), resolving the bucket ARN via GetVectorBucket first (the update() 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 reaches update() is surfaced as ResourceUpdateNotSupportedError (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 under CDKD_TEST_UPDATE=true to change env, add owner, remove team; verify.sh asserts the AWS-side tags reflect all three via ListTagsForResource; deploy + update + destroy clean, 0 orphans).

  • Fix: enrich AWS::OpenSearchService::Domain endpoint/ARN attributes on the Cloud Control path + new opensearch-domain-getatt integ — 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') (the https://search-...es.amazonaws.com URL clients connect to) and Fn::GetAtt(<Domain>, 'Arn') / DomainArn therefore fell through the resolver's constructAttribute to 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 an AWS::OpenSearchService::Domain case to enrichResourceAttributes that calls DescribeDomain and overlays the flat-key DomainEndpoint (from DomainStatus.Endpoint, falling back to the vpc entry of DomainStatus.Endpoints for a VPC domain) + Arn / DomainArn (from DomainStatus.ARN) + Id (from DomainStatus.DomainId). Best-effort: a failed Describe leaves the CC-API attribute shape unchanged and never fails the deploy. Adds @aws-sdk/client-opensearch as 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-node t3.small.search domain (10 GiB gp3, no VPC); Fn::GetAtt(DomainEndpoint) / Arn stored into SSM Parameters resolved to the real *.es.amazonaws.com hostname / arn:aws:es:...:domain/... ARN, not the domain name; deploy + destroy clean, 0 orphans). New scenario tag cc-api-getatt-enrichment-opensearch-domain. Third fix of the CC-API enrichment-gap batch (remaining: MSK BootstrapBrokers). NOTE: the legacy AWS::Elasticsearch::Domain is a Tier-3 NON_PROVISIONABLE type (cdkd rejects it pre-flight), so it needs no enrichment — the modern AWS::OpenSearchService::Domain is the only provisionable variant.

  • Fix: enrich AWS::Redshift::Cluster endpoint attributes on the Cloud Control path + new redshift-cluster-getatt integ — 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's constructAttribute to 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 an AWS::Redshift::Cluster case to enrichResourceAttributes that calls DescribeClusters and overlays the flat-key Endpoint.Address / Endpoint.Port (the SDK Cluster.Endpoint object 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-redshift as 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-node ra3.large cluster (the smallest orderable node type — the legacy dc2.large is no longer orderable) with AWS-managed master password; Fn::GetAtt(Endpoint.Address) stored into an SSM Parameter resolved to the real *.redshift.amazonaws.com hostname, not the cluster id; deploy + destroy clean, 0 orphans). New scenario tag cc-api-getatt-enrichment-redshift-cluster. Second fix of the CC-API enrichment-gap batch (remaining: OpenSearch / Elasticsearch DomainEndpoint, MSK BootstrapBrokers).

  • Fix: enrich AWS::ElastiCache::ReplicationGroup endpoint attributes on the Cloud Control path + new elasticache-replicationgroup-getatt integ — 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's constructAttribute to 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 an AWS::ElastiCache::ReplicationGroup case to enrichResourceAttributes that calls DescribeReplicationGroups and overlays the flat-key endpoint attributes with the CFn casing (PrimaryEndPoint.Address / PrimaryEndPoint.Port / ReaderEndPoint.* / ConfigurationEndPoint.* for cluster-mode + the comma-delimited ReadEndPoint.Addresses / ReadEndPoint.Ports list across node groups) — note the SDK fields are Endpoint (lower p) while the CFn GetAtt names are EndPoint (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 in tests/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.com hostname, not the RG id; deploy + destroy clean, 0 orphans). New scenario tag cc-api-getatt-enrichment-elasticache-replicationgroup. First fix of the CC-API enrichment-gap batch (remaining latent gaps the audit found: OpenSearch / Elasticsearch DomainEndpoint, MSK BootstrapBrokers, Redshift Endpoint).

  • New deletion-ordering-complex failure-seeking integ stresses cdkd's ELBv2 destroy-ordering web (test-only — no src/ 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 no implicit-delete-deps edge today. The CdkdDeletionOrderingComplexExample stack is a minimal VPC (natGateways:0, 2 public subnets) with an ApplicationLoadBalancer + TargetGroup (TargetType: IP) + Listener (:80 forward) + a non-default ListenerRule (/app/*) + a t3.nano EC2 instance registered as the TG's IP target. It exercises five AWS-enforced delete-ordering constraints that are not trivially visible as forward Ref / DependsOn: (1) Listener / ListenerRule before TargetGroup (else DeleteTargetGroup → ResourceInUse); (2) ListenerRule before Listener; (3) TargetGroup + Listener before the LoadBalancer (the Listener Refs the LB so reverse-DAG handles it, but the TG does NOT Ref the 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 (else DependencyViolation), but ELBv2Provider.deleteLoadBalancer fires DeleteLoadBalancerCommand and returns immediately (no waitUntilLoadBalancersDeleted), so the reverse-DAG can race the async ENI teardown; (5) the EC2 target's ENI must release before the Subnet / SG delete. verify.sh deploys, asserts the ALB + TargetGroup + Listener + non-default ListenerRule exist, then runs the KEY test — cdkd destroy --force MUST exit 0 (a wrong delete order surfaces as a non-zero exit, and the script prints the exact AWS DependencyViolation / ResourceInUse error + 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 own cdkd:integ-fixture=deletion-ordering-complex tag since AWS reserves the aws: prefix so cdkd cannot set aws:cdk:path; state-empty alone is not trusted per the #796 lesson). On any failure exit the EXIT trap 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 (no grep -P / date -d), captures real exit codes, and prints [verify] PASS only on full success. New canonical scenario tag elbv2-listener-tg-lb-deletion-order in scripts/build-scenario-coverage-matrix.ts; integ-coverage + scenario-coverage matrices regenerated (the fixture register-covers AWS::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, since ELBv2Provider.deleteLoadBalancer does not waitUntilLoadBalancersDeleted) was predicted to fail but does NOT on current cdkd: the would-be DependencyViolation is absorbed by cdkd's existing transient-error retry ('DependencyViolation' / 'has dependencies and cannot be deleted' are retryable patterns in src/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 explicit implicit-delete-deps ELBv2→Subnet edge (the retry is the safety net). The deploy side needs PR #851's EC2 PrivateIp GetAtt 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-asset integ stresses the asset-publishing layer when MANY assets of TWO kinds publish concurrently in one deploy — tests/integration/multi-asset/**. The existing docker-image-asset and s3-asset-deploy fixtures each exercise ONE publisher in isolation (ECR build+push vs a single S3 zip upload), leaving the CONCURRENT multi-asset case — FileAssetPublisher + DockerAssetPublisher interleaved, ECR + S3 in one run, and several distinct S3 uploads in flight together — without a dedicated regression. The new CdkdMultiAssetExample stack (no VPC) publishes 1 ECR image + 4 S3 objects in a single deploy: a lambda.DockerImageFunction (DockerImageCode.fromImageAsset(docker/, { platform: LINUX_ARM64 }) + architecture: ARM_64, matched, to avoid the cross-arch Runtime.InvalidEntrypoint: ProcessSpawnFailed trap on Apple-Silicon hosts) goes through DockerAssetPublisher; three lambda.Functions (AlphaHandler/BetaHandler/GammaHandler, Python 3.12) each Code.fromAsset('<distinct multi-file dir>') produce three DISTINCT FileAssetPublisher S3 uploads (distinct content -> distinct content-addressed asset hash, confirmed at synth: 5 file assets + 1 docker image in the manifest); and a generic s3_assets.Asset (asset-data/) is a 4th S3 upload whose resolved s3BucketName/s3ObjectKey are threaded into the alpha Lambda via CONFIG_BUCKET/CONFIG_KEY env (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's Code.S3Bucket/S3Key at 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) when docker info fails 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 is PackageType=Image with OUR pushed image present in ECR by content-tag (parsed from Code.ImageUri), asserts each zip Lambda's CodeSize > 500 bytes (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 tag multi-asset in 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-dependency integ surfaces create/destroy DAG-ordering bugs with a circular Security Group reference (test-only — no src/ 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 STANDALONE AWS::EC2::SecurityGroupIngress resource (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 (each Fn::GetAtts GroupId on the SG it attaches to and SourceSecurityGroupId on the OTHER SG), breaking what would otherwise be a genuine SG-to-SG cycle. The stack is a natGateways: 0 single-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 runs cdkd synth and asserts >= 2 standalone AWS::EC2::SecurityGroupIngress resources each carrying a SourceSecurityGroupId (true SG-to-SG cross-ref) so the cycle-breaking shape is confirmed before any AWS call; Phase 1 deploys (the DAG builder in src/analyzer/dag-builder.ts must NOT raise a false DependencyError — the standalone ingress resources break the would-be cycle) and asserts both SGs exist with the live cross-reference (SG-A's IpPermissions[].UserIdGroupPairs[].GroupId contains 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 rejects DeleteSecurityGroup with DependencyViolation: 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 the cdkd:integ-fixture=sg-circular-dependency tag (NOT aws: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 existing AWS::EC2::SecurityGroup -> AWS::EC2::SecurityGroupIngress implicit-delete-dep edge in src/analyzer/implicit-delete-deps.ts is what this fixture exercises end-to-end on real AWS. New scenario tag sg-circular-dependency in 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 SecurityGroup GroupDescription, so the two GroupDescription strings 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-stress integ 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 new CdkdIamPropagationStressExample stack 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 (CreateFunction validates lambda.amazonaws.com can assume it); (2) a fresh SFN role -> AWS::StepFunctions::StateMachine (CreateStateMachine validates the role — the SFN provider has NO propagation retry of its own); (3) a fresh EventBridge target role -> AWS::Events::Rule with an SFN target (PutTargets validates the rule can assume the role to StartExecution); (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 via Fn::GetAtt <Role>.Arn. Everything is cheap (no VPC, no NAT, no long-lived compute); the EventBridge rule is enabled: false on a 365-day schedule so it never fires (no cost) but PutTargets still 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), so verify.sh prints WHICH resource failed plus the error (deploy-log tail + cdkd events per-resource RESOURCE_FAILED lines + the partial state's logicalId -> type map) 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 -> poll describe-execution to SUCCEEDED, proving both the fresh SFN role AND the SFN->Lambda invoke grant; list-targets-by-rule shows 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 (per feedback_protection_integ_must_instantiate_resource — state-empty alone can miss an orphan carrying no stack name). verify.sh is BSD/macOS-portable (no grep -P / date -d), recovers the real deploy exit code from PIPESTATUS so a tee'd non-zero is not masked, traps aggressively (state + lock + deployment-events sidecar), and prints [verify] PASS only on full success. New scenario tag iam-fresh-role-immediate-assume in 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 SQS QueuePolicy + SNS TopicPolicy PUT 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 to RETRYABLE_ERROR_MESSAGE_PATTERNS in src/deployment/retryable-errors.ts: Policy Error: PrincipalNotFound (the SNS SetTopicAttributes rejection wording) and Invalid value for the parameter Policy (the SQS SetQueueAttributes rejection — 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. Two verify.sh assertion fixes landed alongside: the post-destroy StepFunctions check now polls for DeleteStateMachine's async deletion (the API returns before the state machine is gone), and the edge-2 SFN execution assertion retries start-execution -> describe-execution across 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-stack integ stresses a realistic single-instance RDS deployment + Fn::GetAtt of a computed DB endpoint, plus a real src/ fix enriching AWS::RDS::DBInstance Endpoint 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 + the provisionedBy=sdk routing guard; neither uses an explicit DBSubnetGroup + DBParameterGroup pair on an L2 rds.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) + explicit rds.SubnetGroup + explicit rds.ParameterGroup (Postgres 17.4, non-default application_name = cdkd-rds-full-stack) + explicit SecurityGroup + a small rds.DatabaseInstance (db.t3.micro, single-AZ, 20 GiB gp2, CDK-managed Secrets Manager credentials, deletionProtection: false, RemovalPolicy.DESTROY, no final snapshot) + an ssm.StringParameter (/cdkd/rds-full-stack/db-endpoint) whose value is Fn::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 computed Endpoint.Address attribute back, and only THEN create the SSM Parameter with the resolved value. verify.sh (BSD/macOS-portable — no grep -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 (DescribeDBInstances DBSubnetGroup.DBSubnetGroupName / DBParameterGroups[]) and the group carries the non-default application_name (DescribeDBParameters), asserts the SSM parameter value equals the LIVE DescribeDBInstances endpoint address (proving the computed Fn::GetAtt resolved 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 via state 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 tag rds-full-stack in the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. It also carries a real cdkd src/ fix: src/provisioning/cloud-control-provider.ts enrichResourceAttributes had no AWS::RDS::DBInstance case, so a CC-API-routed DBInstance (routed via a #614 silent-drop property) never had its computed Endpoint.Address / Endpoint.Port / Endpoint.HostedZoneId / Arn attributes enriched after create — a downstream Fn::GetAtt(<db>, 'Endpoint.Address') resolved to the raw physicalId instead of the real endpoint. Only AWS::RDS::DBCluster was enriched (per #381). The fix adds a parallel DBInstance case (DescribeDBInstances -> flat keys Endpoint.Address, Endpoint.Port, Endpoint.HostedZoneId, Arn; the DBInstance Endpoint is a nested {Address, Port, HostedZoneId} object, distinct from the DBCluster's flat Endpoint/ReaderEndpoint scalars). Unit tests in tests/unit/provisioning/cloud-control-provider.test.ts cover the new case. Validated GREEN end-to-end against real AWS (/run-integ rds-full-stack): deploy clean, the computed-endpoint Fn::GetAtt resolved to the live endpoint address, destroy reported 15 deleted / 0 errors, and the post-destroy orphan sweep was empty (0 orphans).

  • Fn::Join over a list-returning intrinsic + AWS::NotificationARNs pseudo-parameter resolves to empty string in Fn::Sub/Ref (issue #838), plus a new intrinsics-torture integ 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 in src/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 existing intrinsic-functions fixture only exercised Ref / Fn::GetAtt / Fn::Join / Fn::Sub. The new failure-seeking intrinsics-torture integ surfaced two real bugs that the fix in this PR closes: (a) issue #838 — Fn::Join over a list-returning intrinsic (e.g. Fn::Join['', Fn::Cidr[...]]): the resolver mapped each element of the Fn::Join second 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::NotificationARNs pseudo-parameter now resolves to an empty string in Fn::Sub / Ref instead 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 a Fn::Sub context), so emitting an empty string matches CloudFormation's own behavior rather than leaking an unresolved placeholder downstream. The new CdkdIntrinsicsTortureExample stack is cheap (an SNS topic + SQS queue + ten AWS::SSM::Parameters; no VPC / NAT / Lambda) and computes each SSM parameter's Value via 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 BEYOND intrinsic-functions: Fn::Cidr (['10.0.0.0/16', 8, 8] carved into eight /24 blocks — both Fn::Select[3] = 10.0.3.0/24 and the full Fn::Joined list asserted — the list-returning-Fn::Join case that exercises fix (a)), Fn::FindInMap (a Mappings section with a {Ref: AWS::Region} top-level key + a region-independent DEFAULT row), Fn::GetAZs + Fn::Select[0] (first AZ, computed the same way cdkd sorts the list), Fn::Base64, nested Fn::Split + Fn::Select + Fn::Join (expect a|c|e), deeply-nested two-arg Fn::Sub (literal-map var via a nested Fn::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 — no grep -P / date -d, real deploy-rc capture, explicit [verify] PASS) deploys, reads each parameter back via aws 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). The pseudo assertion pins the new behavior that AWS::NotificationARNs resolves to an empty string inside Fn::Sub (fix (b)), so a regression that changed it would flip the assertion. Unit tests in tests/unit/deployment/intrinsic-functions.test.ts cover both fixes (Fn::Join over Fn::Cidr / Fn::GetAZs / Fn::Split, and AWS::NotificationARNs -> empty string in Fn::Sub + Ref). New scenario tag intrinsics-torture in 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 the bench-cdk-sample broad 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 new conditions-update-2 integ that surfaced it — src/provisioning/providers/sqs-queue-provider.ts + tests/integration/conditions-update-2/**. The sibling conditions-and-if integ surfaced bug #840 (a resource whose Condition: flipped true → false on 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 new CdkdConditionsUpdate2Example stack (cheap: 4x AWS::SSM::Parameter + 2x AWS::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 mechanism conditions-and-if uses since cdkd has no deploy-time --parameter flag) 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 reverse AppearParam (Condition: IsPhaseB, ABSENT in phase a) is CREATED on the phase-b redeploy (absent → present); (2) Fn::If → AWS::NoValue removing a NESTED property block on an in-place UPDATE — the case that surfaced THIS PR's src/ bug — WorkQueue.RedrivePolicy (a DLQ-target + maxReceiveCount JSON block) is SET in phase a and GONE in phase b on the SAME physical queue (QueueName unchanged). cdkd's diff layer correctly classified the removed property as a change (its compareProperties unions current + desired keys, so a key present in state but absent from the resolved desired template is detected), but the SQS provider's update() loop only acted on keys PRESENT in the new properties — so the stale RedrivePolicy was never cleared on AWS (the classic "providers only act on keys present in newProperties" gap from feedback_internal_contract_audit_first). The fix adds a removal branch to SQSQueueProvider.update(): a CDK-managed attribute present in previousProperties but absent from the resolved desired properties is reset to its default via SetQueueAttributes (a new SQS_ATTRIBUTE_REMOVAL_RESET map — the JSON policy attributes RedrivePolicy / RedriveAllowPolicy + KmsMasterKeyId clear to the empty string SQS documents for removal; numeric attributes reset to their documented SetQueueAttributes defaults), mirroring CloudFormation's reset-to-default-on-removal behavior. The branch is gated on the attribute being present in previousProperties AND in the reset map, so it never spuriously clears an attribute that was never set (e.g. a tag-only update issues no SetQueueAttributes), 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 in tests/unit/provisioning/sqs-queue-provider-update.test.ts cover the clear-on-removal (RedrivePolicy → ""), the numeric-reset-on-removal (VisibilityTimeout → 30), and the no-over-clear guard (attribute absent on both sides → no SetQueueAttributes). This exercises the provider.update() drop-a-block path, not the create-time omission conditions-and-if's SNS DisplayName covered; (3) a condition-gated OUTPUT — the MoverParamName output (condition: IsPhaseA) is present in the cdkd state outputs map in phase a and absent in phase b (asserted by reading the state file directly); (4) a DependsOn to a condition-EXCLUDED resource — KeeperParam (always present) DependsOn MoverParam, which is pruned in phase b, so cdkd must DROP the dangling DependsOn (like CloudFormation) and still deploy/update KeeperParam in place (its Fn::If value flips keeper-phase-a → keeper-phase-b); (5) a Ref to a condition-excluded resource INSIDE a condition-false resource — RefHolderParam (Condition: IsPhaseA, Value Refs MoverParam which is also IsPhaseA-gated) is pruned together with MoverParam in phase b, so the surviving template carries no dangling Ref and the deploy must not crash. verify.sh (BSD/macOS-portable — no grep -P / date -d, real exit codes, explicit All N passed pass line) runs three phases (Phase A -c phase=a presence/SET/PRESENT, Phase B -c phase=b DELETE/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 tag conditions-update-semantics in KNOWN_SCENARIOS (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ conditions-update-2 before merge.)

  • A resource whose Condition: flips true → false on a redeploy is now DELETED (issue #840) — src/analyzer/template-parser.ts (new filterResourcesByCondition) + src/deployment/deploy-engine.ts. The new conditions-and-if integ surfaced a real cdkd gap: CloudFormation does not strip condition-gated resources at synth time — CDK emits a resource carrying a Condition: key into Resources regardless of the condition's value (verified by synthing the fixture both ways: PremiumOnlyParam is present in Resources with Condition: IsPremium in BOTH -c tier=premium and -c tier=basic, differing only in the Tier parameter default that flips the condition). cdkd evaluated the Conditions section for Fn::If resolution but NEVER consulted the resource-level Condition: 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 as NO_CHANGE). The deploy engine now prunes every resource whose Condition: resolved to false (via TemplateParser.filterResourcesByCondition) immediately after evaluating the Conditions section, 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 whose Condition: names an unevaluated/unknown condition is kept (treated as present, not silently dropped). Tests: 5 new filterResourcesByCondition unit 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 the conditions-and-if integ's Phase 2 asserts (PremiumOnlyParam ABSENT after the basic redeploy). (NOTE: needs /run-integ conditions-and-if against real AWS before merge.)

  • New conditions-and-if integ stresses cdkd's own CloudFormation Conditions + Fn::If evaluation (test-only — no src/ change) — tests/integration/conditions-and-if/**. cdkd evaluates the Conditions section + the resource-level Condition: key + Fn::If / Fn::Equals / Fn::And / Fn::Or / Fn::Not ITSELF (no CloudFormation engine underneath), but the existing conditions fixture has no verify.sh and only exercised a single Fn::And + one conditionally-created S3 bucket + an Fn::If bucket name — leaving condition-gated resource CREATION, Fn::If -> AWS::NoValue property OMISSION, and Fn::Or / Fn::Not without an end-to-end real-AWS backstop. The new CdkdConditionsIfExample stack (cheap: 3x AWS::SSM::Parameter + 1x AWS::SNS::Topic, no VPC / NAT) closes the gap. Its Conditions section combines Fn::Equals on a CfnParameter with Fn::And (IsPremiumPrimary), Fn::Or (IsPremiumOrSecondary), and Fn::Not (IsSecondaryRegion). It carries TWO resources with a Condition: key (PremiumOnlyParam on the bare Fn::Equals condition, PremiumPrimaryParam on the Fn::And condition), an always-created parameter whose Value is an Fn::If branch (TierLabelParam), and an SNS topic whose DisplayName is Fn::If(IsPremium, 'Premium Notifications', AWS::NoValue) plus two tag values driven by Fn::If. The Tier CfnParameter default is read from CDK context (-c tier=premium|basic) at synth — cdkd has no deploy-time --parameter flag, so flipping the context is the param-flip mechanism. verify.sh (BSD/macOS-portable — no grep -P / date -d, real exit codes, explicit pass line) runs three phases against real AWS: Phase 1 (-c tier=premium) — asserts the Fn::If property branch reached AWS (TierLabelParam Value == tier-is-premium), both condition-gated parameters are PRESENT (aws ssm get-parameter), the SNS DisplayName is SET to Premium Notifications, and the Fn::If tag values / Fn::Or tag value reached AWS; Phase 2 (-c tier=basic, redeploy in place) — asserts the Fn::If branch FLIPPED on AWS (tier-is-basic), both condition-gated parameters are now ABSENT (resource removed because its Condition: went false), the SNS DisplayName is 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 tag conditions-and-if in KNOWN_SCENARIOS (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ conditions-and-if before merge.)

  • cdkd diff --recursive no longer reports spurious changes on a freshly-deployed nested-stack tree, plus a new nested-stack-3level integ 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 deployed resources but omitted the resolved input Parameters that the deploy engine forwards to the child (NestedStackProvider.extractParameters -> DeployEngineOptions.parameters). So a child property whose value derives from a DOWN-passed nested-stack Parameter (CDK's synthesized referenceto<Parent>… input — e.g. the great-grandchild's Value: Fn::Join['', ['…:', {Ref: referenceto…RootTopicName}]]) kept its raw intrinsic at diff time (the Ref resolved to neither a resource nor a parameter), while cdkd state held the resolved string; the diff calculator's valuesEqual then saw "intrinsic vs concrete" and reported a phantom UPDATE on every freshly-deployed tree. The fix threads a parameters map through computeStackDiff (into the resolver ResolverContext.parameters) and adds resolveChildStackParameters, which resolves each child AWS::CloudFormation::Stack row's Properties.Parameters against 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's parameters — exactly mirroring the deploy engine's parent->child forwarding so the recursive diff matches what deploy wrote to state. The bottom-up Fn::GetAtt output chain was already correct (it resolves against attributes['Outputs.<key>'], which the diff path already supplied). Tests: 4 new unit tests in tests/unit/cli/diff-recursive.test.ts (a freshly-deployed down-passed-parameter child diffs NO_CHANGE; a genuinely-changed resolved value still diffs UPDATE; computeStackDiff with 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's NestedStackProvider recursion, the v6 <parent>~<childLogicalId> state-key derivation, and diff --recursive / state list --tree are most likely to drift: nested-stack is 1 level, and nested-stack-deep is 3 levels with exactly one SSM Parameter per level and bottom-up Fn::GetAtt outputs 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 than nested-stack-deep), so the ${parentStackName}~${nestedLogicalId} join is exercised at the CdkdNestedStack3LevelExample~Child~Grandchild~GreatGrandchild key; (2) wider — the grandchild is a BRANCHING node owning two own resources (an AWS::SNS::Topic AND an AWS::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-up Fn::GetAtt output chain (great-grandchild param name bubbles up through grandchild → child → root's RootRef), the root RootTopic name is threaded DOWN all three boundaries as a synthesized nested-stack Parameter (verified in synth: each AWS::CloudFormation::Stack carries a referenceto…RootTopic…TopicName entry and the great-grandchild template declares the matching input Parameter), exercising cdkd's NestedStackProvider Parameters extraction + DeployEngineOptions.parameters forwarding — a path nested-stack-deep never touches. verify.sh (BSD/macOS-portable — no grep -P / date -d, real exit codes captured to variables, explicit PASS only 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 v6 parentStack / parentLogicalId point 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), runs cdkd diff --recursive clean (plus a changed great-grandchild value surfacing a [~] UPDATE under the Nested stack: …~GreatGrandchild header), asserts cdkd state list --tree renders 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 tag nested-stack-deep-deploy-cascade in the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ nested-stack-3level before merge.)

  • Fn::FindInMap now supports the optional 4th-argument { DefaultValue: <x> } (returns the default when the requested key is absent instead of throwing), plus the new intrinsics-torture-2 integ that targets the HARDER intrinsic-resolution arg-shapes (sibling of intrinsics-torture, which found bug #838) — src/deployment/intrinsic-function-resolver.ts + tests/integration/intrinsics-torture-2/. The first torture fixture surfaced that Fn::Join over a list-returning intrinsic crashed (#838); this second fixture goes after the next tier of less-common / harder arg shapes in src/deployment/intrinsic-function-resolver.ts, each feeding a REAL AWS::SSM::Parameter.Value (written via the raw L1 ssm.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. The src/ fix it surfaced: resolveFindInMap destructured only the 3 classic args ([MapName, TopLevelKey, SecondLevelKey]) and threw Fn::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 the FindInMapDefault resource in this fixture, which uses a deliberately-missing top-level key). The handler now accepts the 4th arg, resolves the DefaultValue (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 no DefaultValue is supplied the existing throws are preserved byte-for-byte (backward compatible), and the present-key 3-arg path is unchanged. The CdkdIntrinsicsTorture2Example stack (1 SNS Topic + 10 SSM String parameters, no VPC / Lambda) exercises: Fn::Select[1, Fn::GetAZs('')] and Fn::Select[0, Fn::Split(',', {Ref})] (the Select analogue of the #838 resolveJoin-over-list-intrinsic bug class); Fn::FindInMap with 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 a Ref-valued attribute name (CFn allows it; resolveGetAtt treats getAtt[1] as a literal string); the Fn::Sub ${!Literal} escape (renders a literal ${Literal}); Fn::Base64 of an intrinsic ({Ref} → string → base64); a triple-nested Fn::If-in-Fn::Sub-in-Fn::Join; and Fn::Cidr IPv6 (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 different cidrBits (10.0.0.0/24, 4, 4 → Select[2] = 10.0.0.32/28). verify.sh (BSD/macOS-portable — no grep -P / date -d, captures real exit codes, prints an explicit All N checks passed) computes each expected concrete value in-script from account / region (AZ list via describe-availability-zones, base64 via base64, 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::ImportValue with an Fn::Sub export 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 by import-value-strong-ref); Fn::Cidr IPv6 is INCLUDED (the resolver supports IPv6 via resolveCidr's isIpv6 branch), so the IPv4-only-fallback alternative was unnecessary. Unit tests in tests/unit/deployment/intrinsic-functions.test.ts cover 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 tag intrinsic-hard-arg-shapes in KNOWN_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-2 integ — src/deployment/retryable-errors.ts + tests/integration/propagation-races-2/. cdkd's fast SDK path creates an IAM role and then, within ~1s, issues a KMS:CreateKey whose key policy references that brand-new role principal; KMS rejects it with MalformedPolicyDocumentException: Policy contains a statement with one or more invalid principals because 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 KMS CreateKey / PutKeyPolicy is 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) to RETRYABLE_ERROR_MESSAGE_PATTERNS — the same array #839 extended. The KMS Key provider is a normal SDK provider whose create() runs under the deploy engine's withRetry (it does NOT opt out via disableOuterRetry like the Custom Resource provider), so the message pattern alone is sufficient — withRetry catches 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 in tests/unit/deployment/retryable-errors.test.ts cover the new pattern (the KMS invalid-principal message classifies retryable; a clearly-different KMS error stays non-retryable). The propagation-races-2 fixture is the race detector that surfaced this (deploy SUCCESS is the pass condition). (NOTE: needs /run-integ propagation-races-2 against real AWS before merge.)

  • New propagation-races-2 integ 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-role PrincipalNotFound); 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 — RunInstances validates 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 RAW ec2.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 emits AvailabilityZone, a silent-drop that flips it onto Cloud Control) in a minimal single-AZ no-NAT VPC; (2) AWS::Lambda::Permission granting a fresh S3 bucket source — AddPermission validates the just-created SourceArn + function in one call; (3) AWS::S3::BucketPolicy referencing a fresh IAM role principal — PutBucketPolicy validates the role principal (the classic Invalid principal in policy S3 race); (4) AWS::KMS::Key key policy referencing a fresh IAM role principal — CreateKey validates every principal in the key policy (this edge surfaced the KMS retry fix in src/deployment/retryable-errors.ts above). The PASS CONDITION is that cdkd deploy SUCCEEDS — the fixture is a RACE DETECTOR: if cdkd does not retry the fresh-principal propagation error for one of these edges, the deploy fails and verify.sh prints which resource failed + the AWS error + the cdkd events --format json RESOURCE_FAILED lines 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-owned cdkd:integ-fixture=propagation-races-2 tag / state-resolved physical ids — NOT the AWS-reserved aws:cdk:path tag — 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-terminated wait so a lingering ENI does not block the SG/subnet) and is BSD/macOS-portable (no grep -P / date -d, real exit codes captured, explicit === PASS === only on full success). New scenario tag fresh-principal-consumer-race in KNOWN_SCENARIOS (scripts/build-scenario-coverage-matrix.ts); the .scenarios.json also tags the existing iam-policy-propagation-retry; coverage matrices regenerated. (NOTE: needs /run-integ propagation-races-2 against real AWS before merge; the deploy step is the bug-finder.)

  • Fn::GetAtt of an unenriched computed attribute resolved to the raw physicalId instead of the real attribute, plus a new failure-seeking deep-getatt-chains integ for LONG Fn::GetAtt chains — 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's constructAttribute (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 for AWS::CloudWatch::CompositeAlarm (so Fn::GetAtt(<CompositeAlarm>, 'Arn') fell through to the physicalId default and resolved to the alarm NAME, not its ARN) and no case for AWS::EC2::Instance (so Fn::GetAtt(<Instance>, 'PrivateIp') resolved to the instance id, not the IP — breaking an ELBv2 IP-target registration with not a valid IPv4 address). The fix adds both: CompositeAlarm Arn is 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 Instance PrivateIp / PublicIp / PrivateDnsName / PublicDnsName / AvailabilityZone require a LIVE DescribeInstances lookup (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 new CdkdDeepGetAttChainsExample stack is a 5-deep chain (cheap; SNS / CloudWatch / SSM / IAM / Lambda only, no VPC, inline Lambda ZipFile): 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::Sub joining C.Arn + B.Arn) --Name (Ref)--> E AWS::Lambda::Function (SDK, terminal multi-attribute Fn::Sub env pulling A.TopicArn + C.Arn + D(Ref) at once). The critical link is C: AWS::CloudWatch::CompositeAlarm is unregistered, so cdkd routes it via Cloud Control API and its Arn now comes from the new constructAttribute case rather than falling through to the physicalId default. verify.sh (BSD/macOS-portable — no grep -P / date -d — real rc capture via PIPESTATUS, explicit [verify] PASS) deploys, reads each upstream resource's REAL attribute back from AWS as ground truth, and asserts per-link: alarm B's AlarmActions[0] == A's real TopicArn; composite C's AlarmRule references B's real name; SSM param D's Value == composite=<C.Arn>;alarm=<B.Arn> built from the REAL ARNs (the CC-API → SDK hop); and Lambda E's env resolves UPSTREAM_TOPIC_ARN / UPSTREAM_COMPOSITE_ARN / UPSTREAM_PARAM_NAME / UPSTREAM_JOINED to the real upstream attributes — a mismatch fails with a message naming the broken link. It then destroys with --force and asserts every named resource is gone (each carries an OWN cdkd:integ-fixture tag; assertions resolve by name / state — never aws:cdk:path) plus the S3 state file removed. The EC2 Instance live-lookup path is integ-covered by the sibling deletion-ordering-complex fixture (an ELBv2 IP-target group registered with a Lambda-less EC2 instance's PrivateIp). Unit tests in tests/unit/deployment/intrinsic-functions.test.ts cover the CompositeAlarm ARN construction and the EC2 PrivateIp live-lookup (mocked DescribeInstances). New scenario tag deep-getatt-chain-resolution in KNOWN_SCENARIOS (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated (the fixture also newly registers AWS::CloudWatch::CompositeAlarm in the integ-coverage matrix). (NOTE: needs /run-integ deep-getatt-chains against real AWS before merge.)

  • New sdk-ccapi-crossref integ surfaces bugs at the SDK-Provider <-> Cloud Control API cross-reference boundary (test-only — no src/ 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's create() / delete() entirely — typed attribute writes never happen and the physical id becomes whatever CC API returns. Fn::GetAtt / Ref references crossing that SDK <-> CC seam (and CC API's physical-id shapes) are a documented fragile area (memory feedback_silent_drop_forces_cc_api_routing + feedback_cc_api_routing_bypasses_sdk_delete_logic), but no integ exercised it directly. The new CdkdSdkCcApiCrossrefExample stack forces a heterogeneous routing mix in ONE stack (no VPC / NAT): KinesisStream (AWS::Kinesis::Stream, silent-drop DesiredShardLevelMetrics) and CcLambda (AWS::Lambda::Function, silent-drop RuntimeManagementConfig) auto-route via CC API, while ExecRole (AWS::IAM::Role) and StreamArnParam (AWS::SSM::Parameter) stay on the SDK path. The routing was confirmed against this cdkd version with the findActionableSilentDrops registry helper on the synthesized template before finalizing. It crosses the boundary with Fn::GetAtt in 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 are provisionedBy: '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-drop RuntimeManagementConfig.UpdateRuntimeOn reached AWS (FunctionUpdate — the CC route forwarded the full property map), then cdkd destroy --force (which exercises the CC delete path that bypasses the SDK provider delete() for the stream + Lambda) and asserts every named resource (stream / function / role / parameter) and the state file are gone. New scenario tag sdk-ccapi-crossref-boundary in the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ sdk-ccapi-crossref before merge.)

  • New custom-resource-getatt-data failure-seeking integ for the Custom Resource response-Data attribute path consumed via Fn::GetAtt into a dependent resource (refs #756 / #804, test-only — no src/ change) — tests/integration/custom-resource-getatt-data/**. The existing custom-resource-provider fixture exercises the CDK Provider framework's async isCompleteHandler polling path but only asserts the CR's own output; it does NOT prove that a CR's response Data flows THROUGH cdkd's intrinsic resolver INTO another resource's property — the fragile path (#756 / #804: CR Data attributes 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 new CdkdCrGetAttDataExample stack closes that gap with the cheapest shape: an inline NodeJS Lambda-backed AWS::CloudFormation::CustomResource whose handler returns Data: { ComputedValue: 'computed-integ', Another: 'another-<region>', NumericValue: '42' } directly in its response payload (the simple synchronous path, no Provider framework / VPC), consumed by THREE AWS::SSM::Parameter dependents whose Value is Fn::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 explicit addDependency(cr) on one parameter to exercise the DAG ordering (CR must complete + have attributes populated 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 its Value equals the value the CR handler returned — the load-bearing check that the CR Data.<key> attribute resolved into the dependent property (a blank/wrong value would otherwise pass unnoticed). It also cross-checks the resolved ComputedValue in state.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 tag custom-resource-getatt-data in KNOWN_SCENARIOS (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ custom-resource-getatt-data before merge.)

  • Fix: retry AWS throttling rejections that surface as HTTP 400 (not 429) by error NAME + new throttle-wide-dag failure-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. SSM PutParameter rejects a burst with ThrottlingException / Rate exceeded. Ensure you have the high-throughput setting enabled ... at status 400), so cdkd's withRetry never retried them and the deploy failed. Because cdkd's event-driven DAG dispatches with no level barrier, a wide stack at a high --concurrency fires 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 new THROTTLING_ERROR_NAMES set (mirrors the AWS SDK v3 @aws-sdk/service-error-classification THROTTLING_ERROR_CODES) checked against the error AND its wrapped .cause chain (the original SDK error is one cause-link deep under cdkd's ProvisioningError), plus a Rate exceeded message-pattern backstop. cdkd's outer withRetry (1s/2s/4s/8s backoff, deeper than the SDK's) then spreads the remaining creates out until the rate window clears. The new throttle-wide-dag fixture deploys a ~100-resource stack (80 SSM Parameters + a 10-deep Fn::Sub chain + 10 IAM Roles + 10 SNS Topics) at --concurrency 40 to 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 run AWS_RETRY_MODE=adaptive / AWS_MAX_ATTEMPTS=10 so they survive the same SSM rate window the test deliberately induces. 6 new unit tests in tests/unit/deployment/retryable-errors.test.ts (name on the error; name one cause-link deep; Rate exceeded message 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-propagation failure-seeking integ verifies STACK-LEVEL tag propagation across many taggable types on BOTH the SDK-provider and Cloud Control API paths (test-only — no src/ change) — tests/integration/tags-propagation/**. cdk.Tags.of(app).add(k, v) injects the same tags into the CFn Tags property 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. Notably AWS::SSM::Parameter.Tags is a CFn MAP (not the list almost every other type uses) — the historical properties.Tags.map() deploy-crash type recorded in feedback_ssm_parameter_tags_is_a_map, which hid because the unit tests + the only SSM-tag fixtures used the wrong (list) shape. The new CdkdTagsPropagationExample stack applies 3 stack-level tags (CdkdTagOwner / CdkdTagEnv / CdkdTagCostCenter, set via cdk.Tags.of(app) in bin/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 CFn Tags property). No VPC / NAT / instances — every resource is control-plane-only and cheap. verify.sh (BSD/macOS-portable — no grep -P / date -d, real cdkd exit codes captured, jq field extraction, [verify] PASS only on full success): (1) deploys (a wrong-Tags-shape crash on any type fails here with specifics); (2) reads state.json and asserts the routing split — Athena WorkGroup provisionedBy == '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 drift immediately after deploy must report exit 0 — a tag-list reorder from AWS must not show as a false-positive drift (issue #802 canonicalizeTagListsDeep), nor a map-vs-list readback-shape mismatch; (5) cdkd destroy --force cleans up. New scenario tag stack-level-tag-propagation-multitype in the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ tags-propagation before merge.)

  • New getstackoutput-crossregion integ exercises cdkd's UNIQUE same-account / CROSS-REGION Fn::GetStackOutput intrinsic (test-only — no src/ change) — tests/integration/getstackoutput-crossregion/**. cdkd's Fn::GetStackOutput can 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 reads cdkd/{Producer}/{producerRegion}/state.json from the same bucket the consumer state lives in), but that path was under-tested: the existing cross-stack-references / schema-v7-to-v8-migration fixtures both resolve Fn::GetStackOutput only WITHIN one region (no Region argument). The new fixture is a two-stack, one-app pair pinned to different regions via env.region: a PRODUCER (CdkdGsoProducer, us-west-2) SSM parameter whose ARN is exported via CfnOutput ProducerArn, and a CONSUMER (CdkdGsoConsumer, us-east-1) SSM parameter whose Value is {Fn::GetStackOutput: {StackName, OutputName: ProducerArn, Region: us-west-2}} (injected via addPropertyOverride — aws-cdk-lib ships no typed helper). The producer/consumer are SEPARATE cdkd deploy calls with different --region. verify.sh (BSD/macOS-portable — no grep -P / date -d, captures real exit codes, prints an explicit final PASS line): (1) deploys the producer in us-west-2, asserts its state lands at the us-west-2 region-prefixed key and its ProducerArn output ARN carries the :us-west-2: segment; (2) deploys the consumer in us-east-1, asserts its state lands at the us-east-1 key (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 (in us-east-1) must equal the producer's REAL output value (from us-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::GetStackOutput is a weak reference, so order is not strictly required — mirrors recommended order), asserting both AWS resources AND both region-prefixed state.json files are gone. The cleanup trap drops both regions' SSM parameters + both region-prefixed state.json / lock.json sidecars from the account-scoped bucket, so a failed run leaves nothing behind in either region. New scenario tag getstackoutput-cross-region in the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); the fixture's .scenarios.json also tags the existing multi-stack-getstackoutput; coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ getstackoutput-crossregion before merge.)

  • New eventsourcemapping-race failure-seeking integ for AWS::Lambda::EventSourceMapping created against a FRESH source + role in the same deploy (test-only — no src/ change) — tests/integration/eventsourcemapping-race/**. cdkd's event-driven DAG dispatches each resource the instant its own deps complete with NO level barrier, so an AWS::Lambda::EventSourceMapping that references a queue, function, and execution-role all created in the SAME cdkd deploy can fire CreateEventSourceMapping before AWS has propagated them — surfacing as InvalidParameterValueException ("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 new CdkdEsmRaceExample stack is cheap (one sqs.Queue + one inline-Python lambda.Function + the synthesized ESM wiring them via SqsEventSource; no VPC, no KMS; synth confirms exactly one AWS::Lambda::EventSourceMapping with EventSourceArn: Fn::GetAtt[queue, Arn], FunctionName: Ref[fn], Enabled: true). verify.sh (BSD/macOS-portable — no grep -P, no date -d; captures the real deploy exit code and prints an explicit PASSED line) runs: (1) a PRE-FLIGHT orphan scan per the run-integ skill — list-event-source-mappings filtered 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 + reaches State=Enabled (get-event-source-mapping poll + a cross-check that list-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's CDKD_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-mappings by 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 tag eventsourcemapping-fresh-source-race in KNOWN_SCENARIOS (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ eventsourcemapping-race before merge.)

  • New update-policy-mutations integ surfaces UPDATE-time handling of DeletionPolicy / UpdateReplacePolicy / DependsOn changes + metadata-only no-ops + orphan-on-replace (test-only — no src/ 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 in diff-calculator.ts's compareAttributes + deploy-engine.ts's Retain-on-replace / DeletionPolicy destroy-skip paths. The new CdkdUpdatePolicyMutationsExample stack drives two deploys via a CDK context flip (-c phase=a then -c phase=b, read at synth time) and asserts four edge cases against real AWS: (1) UpdateReplacePolicy: Retain orphan-on-replace — RetainReplaceBucket (AWS::S3::Bucket, RemovalPolicy.RETAIN) changes BucketName (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) DeletionPolicy flip — PolicyFlipParam (AWS::SSM::Parameter) flips RemovalPolicy.DESTROY -> RETAIN between 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) DependsOn add/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 report No 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 (no grep -P / date -d), captures real rc, prints All update-policy-mutations checks passed only on full success. SNS ARNs are captured from cdkd state show --json. New scenario tag update-policy-mutations in the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ update-policy-mutations before merge.)

  • New importvalue-chain failure-seeking integ for TRANSITIVE 3-stack Fn::ImportValue chains (test-only — no src/ change) — tests/integration/importvalue-chain/**. Goes deeper than the two existing cross-stack fixtures: cross-stack-references (1 producer + 1 consumer; Fn::ImportValue vs Fn::GetStackOutput side by side) and import-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 exporting ChainTopicArn; Stack B (CdkdImportChainB) imports ChainTopicArn (stored in an SSM Parameter) AND derives a value from it via Fn::Sub (derived::<topicArn>::from-b) which it re-exports as ChainDerivedValue; Stack C (CdkdImportChainC) imports ChainDerivedValue. 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, explicit All 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 BOTH ChainTopicArn + ChainDerivedValue (with ChainTopicArn's indexed value matching B's import). (2) Error path — deploying C with --exclusively against a throwaway --state-prefix (no producers) must fail with a clear not found/export error naming ChainDerivedValue (cdkd does not silently resolve a dangling token). --exclusively is load-bearing: like cdk deploy, a bare cdkd 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 --exclusively only C is deployed, so its Fn::ImportValue genuinely 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, names CdkdImportChainC + ChainDerivedValue); destroying A while B imports it is refused (names CdkdImportChainB + 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, per feedback_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 existing multi-stack-importvalue-strong-ref scenario tag (the chain exercises the same Fn::ImportValue strong-ref + exports-index machinery transitively, so no new KNOWN_SCENARIOS entry); 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 new update-replace integ (PR #830) surfaced a hardening gap shared by several of the new fixtures: their post-destroy assertions checked only that state.json was 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 the feedback_protection_integ_must_instantiate_resource rule ("state-empty misses an orphan carrying no stack name"), a state destroy that 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-suspenders aws s3 rb --force of the predictable cdkd-update-replace-{account}-{region}-v1/-v2 bucket 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-attributes against the deterministic ${STACK}-topic ARN resolved via sts get-caller-identity) plus a matching topic delete in the failure-cleanup path. All three scripts stay BSD/macOS-portable (no grep -P / date -d), use jq has() for boolean probes, capture real exit codes, and print [verify] PASS only on full success — following the own-cdkd:integ-fixture-tag pattern already used by rollback-failure-injection (these three resolve physical ids from cdkd state instead, which is equally reliable and needs no lib/ change). Resource TYPES are unchanged, so the integ-coverage / scenario-coverage matrices regenerated with no diff. (NOTE: update-replace and deployment-events need a real-AWS re-run to validate the new assertions; destroy-interrupt adds only two cheap not-found checks to an already-passing fixture.)

  • New replacement-fanout integ stresses replacement propagation (issue #807) at FAN-OUT scale — tests/integration/replacement-fanout/ (new fixture; no src/ change). #807 fixed the basic replacement-propagation case (promoteReplacementDependents in src/analyzer/diff-calculator.ts: a dependent whose only "change" is a Ref/Fn::GetAtt/Fn::Sub to a to-be-REPLACED resource is promoted NO_CHANGE → UPDATE so it re-points at the new physical id), and it was verified end-to-end with a single dependent (the ECS Service → replaced TaskDefinition case in ecs-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: CdkdReplacementFanoutExample is an AWS::SNS::Topic (BaseTopic) with an explicit, phase-keyed TopicName (cdkd-replacement-fanout-{region}-a), referenced by 10 AWS::SSM::Parameters whose Value is Fn::Sub("arn=<topicArn>|idx=N", { arn: Ref(BaseTopic) }) (SNS Ref resolves to the topic ARN) plus an AWS::SNS::TopicPolicy whose policy Resource is the topic ARN. A -c phase=a|b flip (read at synth time) renames the topic -a → -b on phase b; TopicName is 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-place Value update — only the embedded ARN changes. verify.sh (BSD/macOS-portable — no grep -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-resolved Value (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 tag replacement-fanout-propagation in KNOWN_SCENARIOS (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ replacement-fanout before merge.)

  • Fix: parse the SecretsManager whole-secret dynamic-reference form + new secrets-dynamic-ref failure-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 — resolveSecretsManagerReference only matched the mid-string delimiter :SecretString: / :SecretBinary: (with a trailing colon), so the end-anchored whole-secret form fell through. Fix: add an end-anchored endsWith(':SecretString') / endsWith(':SecretBinary') fallback that fires only when the mid-string delimiter is absent AND the segment is at the very end, so a SECRET_ID that merely contains :SecretString mid-name is not split incorrectly. cdkd resolves dynamic references itself in resolveDynamicReferences (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 fixture CdkdSecretsDynamicRefExample (cheap, no VPC) deploys a SecretsManager secret with a KNOWN JSON value ({"username":"cdkd-user","password":"cdkd-known-pw-123"}), an SSM String parameter with a KNOWN value (cdkd-known-ssm-value), and a consumer inline-code AWS::Lambda::Function whose ENVIRONMENT VARIABLES are literal {{resolve:...}} strings (CDK emits them as Fn::Join arrays interpolating AWS::AccountId; cdkd's resolveJoin re-runs resolveDynamicReferences on 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 only secretsmanager + ssm; ssm-secure hits 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 via GetFunctionConfiguration, 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, so describe-secret is 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 tag dynamic-reference-resolution in KNOWN_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 a bench-cdk-sample broad integ for the cross-cutting resolver change.

  • New s3-asset-deploy integ exercises the S3 file/ZIP asset-publishing path during a real cdkd deploy — tests/integration/s3-asset-deploy/**. Most Lambda fixtures use inline code (Code.fromInline) or never assert the asset upload itself, leaving cdkd's FileAssetPublisher (S3 zip + upload, content-addressed skip-if-exists) without a dedicated end-to-end regression. The new CdkdS3AssetDeployExample stack 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's Code.S3Bucket/Code.S3Key to the uploaded object; AND a generic s3_assets.Asset (asset-data/) is uploaded to the same bucket with its resolved s3BucketName/s3ObjectKey threaded into the Lambda as CONFIG_BUCKET/CONFIG_KEY env vars (synth confirms both are emitted as Fn::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's CodeSize > 500 bytes (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-zero configBytes from 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 tag s3-asset-deploy in the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs /run-integ s3-asset-deploy before merge.)

  • New drift-revert-arrays integ broadens drift coverage to TAG-heavy / ARRAY-heavy resource types (refs issue #802) — tests/integration/drift-revert-arrays/ (new fixture; no src/ change). Issue #802 added src/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 existing drift-revert / drift-revert-vpc fixtures 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 multiple Action[] (plain scalar arrays — intentionally NOT canonicalized) + multiple Resource[] ARN arrays (canonicalized) + six tags, and a VPC (natGateways: 0, no NAT cost) + SecurityGroup with four CIDR ingress rules + six tags. verify.sh asserts (a) no false positive on a clean deploy (cdkd drift exit 0 even though AWS reorders the tag lists / ARN arrays on readback), (b) no false positive on an induced reorder (inject-drift.ts reorder re-PUTs the same six S3 tags reversed; cdkd drift still exit 0 — proves canonicalizeTagListsDeep), (c) true drift still detected (inject-drift.ts drift changes a tag VALUE + adds a managed-policy Action + authorizes a new SG ingress rule out of band; cdkd drift exit 1), then cdkd drift --revert -y reverts and a follow-up cdkd drift is clean, and destroy leaves 0 orphans. The script is BSD/macOS-portable (no grep -P / date -d), captures each cdkd drift real exit code, hard-fails with a canonicalizer-naming message, and prints [verify] PASS only on full success. The subnet-… / sg-… resource-id branch of canonicalizeIdArraysDeep stays unit-covered (tests/unit/analyzer/drift-normalize.test.ts); the integ exercises the ARN branch of the same function end-to-end. New scenario tag drift-revert-array-canonicalization.

  • New docker-image-asset integ 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 build of a local Dockerfile, ECR auth, docker push) was only covered by the LOCAL-emulation container fixtures (local-invoke-container etc.), 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 tiny CdkdDockerImageAssetExample stack with a single lambda.DockerImageFunction (DockerImageCode.fromImageAsset(docker/, { platform: LINUX_ARM64 }), a trivial public.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 build platform AND the Lambda architecture are BOTH pinned to ARM_64 (matching) on purpose: a default DockerImageFunction synthesizes NO source.platform in the asset manifest and NO Architectures on the template (defaulting the function to x86_64), so cdkd — which correctly honors source.platform when 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 with Runtime.InvalidEntrypoint: ProcessSpawnFailed (the same cross-arch trap CDK CLI users hit on Mac). Pinning LINUX_ARM64 makes CDK emit source.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) when docker info fails so it is robust on a Docker-less box but runs in a Docker env; it then: (1) deploys and asserts the Lambda exists with PackageType=Image and a Code.ImageUri pointing 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, deployedBy env 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-managed cdk-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 tag docker-image-asset-ecr-publish (KNOWN_SCENARIOS in scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated; testing-guide entry added to Testing.

  • New destroy-interrupt integ 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 stack CdkdDestroyInterruptExample (16 synthesized resources) is a VPC with two isolated subnets + an S3 gateway VPC endpoint + a VPC-attached Lambda (HandlerFn) backing a cdk.CustomResource (CrProbe) + four AWS::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 — no grep -P, no date -d, real exit codes captured to variables) runs four phases: (1) deploy clean; (2) first Ctrl-C — launch cdkd destroy --force in the background, poll its log for delete-loop evidence (bounded ~30s) before sending ONE kill -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-run cdkd destroy --force to completion — assert it exits 0 (clean resume), finishes in < 180s (the #804 fail-fast: NO ~10-minute GetFunction stall against the already-deleted backing Lambda), and the log carries no Pending / 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 tag destroy-interrupt in KNOWN_SCENARIOS (scripts/build-scenario-coverage-matrix.ts); the fixture's .scenarios.json also tags custom-resource-async-poll + vpc-lambda-eni-release. No src/ 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 trivial basic single-SQS CDKD_TEST_FAIL injection (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 the src/deployment/deploy-engine.ts performRollback path when several siblings have ALREADY completed before the failure fires. The failure is self-contained (does NOT reuse the basic fixture's CDKD_TEST_FAIL plumbing): gated on ROLLBACK_INTEG_FAIL=true, the stack adds an AWS::SQS::Queue with an out-of-range messageRetentionPeriod (9999999; valid range [60, 1209600]) that AWS rejects on CreateQueue. 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.json removed or 0 resources); (3) the #808 events captured the failure — cdkd events --format json shows the newest run is a FAILED deploy whose per-run stream has a RESOURCE_FAILED for AWS::SQS::Queue, ROLLBACK_STARTED + ROLLBACK_RESOURCE_SUCCEEDED events, and RUN_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's aws: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 the deployment-events fixture README explicitly deferred (RESOURCE_FAILED / ROLLBACK_* events). New scenario tag rollback-failure-injection. Test-only PR — no src/ 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 cached GetBucketLocation probe, short-circuit when it already matches the client's region, else rebuild a region-corrected S3Client reusing the caller's credentials" pattern had drifted into three near-identical private ensureClientForBucket() copies (the state backend PR #60, the lock manager #803, the exports index store #819). They are now one exported helper rebuildClientForBucketRegion(client, bucket, opts): Promise<S3Client | null> — it returns null to 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 share AwsClients.s3 and must NOT), profile + static credentials (the state backend threads its constructor clientOpts into both the probe and the rebuild) vs reuseClientCredentials (the other two authenticate the probe via client.config.credentials() best-effort and reuse the original client's config.credentials PROVIDER REFERENCE — not a resolved snapshot — for the rebuild), and tolerateNonStandardClient (only the exports store gracefully degrades a test double whose config.region is not a function by returning null; the other two read config.region() directly as before). Each store keeps its own per-instance memoization (clientResolved flag + single-flight resolveInFlight promise) and its own debug log wording via an onRebuild callback. The helper deliberately lives in its OWN module rather than alongside resolveBucketRegion in aws-region-resolver.ts: the three stores' unit tests mock resolveBucketRegion via vi.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 across tests/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 in tests/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; destroyOldClient gating; non-standard-client tolerance → null; no-credentials → omit credentials from the rebuilt client). No runtime behavior change for any of the three cross-region-state-bucket paths (lock acquisition, state read/write, exports index).

  • --region deprecation warning no longer contradicts the actual behavior (issue #818) — src/cli/options.ts. warnIfDeprecatedRegion and the hidden deprecatedRegionOption help 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 consumes options.region as the highest-precedence region source: const region = options.region || process.env['AWS_REGION'] || 'us-east-1' feeds the provisioning / state-bucket SDK clients and the applyRoleArnIfSet STS hop, and deploy / destroy / import / export / orphan additionally inject it into process.env.AWS_REGION so the CDK synth subprocess inherits it (e.g. deploy.ts ~L167/L175/L341). The warning and the code therefore disagreed — a user passing --region was told it did nothing while it silently took effect. Investigation determined --region IS legitimately honored everywhere (option B in the issue), so the fix is purely in the warning + help text — no command implementation (deploy.ts etc.) was touched, keeping the change out of the integ-broad merge-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 still AWS_REGION / the AWS profile; the flag stays hidden + deprecated, just honestly described. Docs corrected: two "deprecated and ignored" lines in CLI Reference and the --region bullet 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).

  • destroy waits 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 the VPCGatewayAttachment detach while the NAT Gateway's Elastic IP was still mapped to the VPC's public address space, failing with Network 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::InternetGateway gains AWS::EC2::NatGateway (alongside its existing AWS::EC2::VPCGatewayAttachment dependee) and a new AWS::EC2::VPCGatewayAttachment key lists AWS::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 via AllocationId, 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 in tests/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 existing vpc-nat-gateway fixture (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 fixed LockManager to resolve a cross-region state bucket's actual region via GetBucketLocation before any S3 op; the automated cross-region-state-bucket integ then surfaced that the exports index store (Fn::ImportValue cross-stack reference tracking, writes s3://{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 as Exports 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 canonical state.json is written through the already-region-corrected S3StateBackend and stays correct; the index is a perf-only derived view that self-heals on the next lookup miss-and-patch / rebuild), so the run still passed — but the cross-region exports index was silently never maintained. The fix ports the LockManager.ensureClientForBucket() pattern into ExportIndexStore: before its first S3 read (readIndexRaw) or write (writeIndex) it resolves the bucket's region (cached process-wide via resolveBucketRegion, so when the state backend / lock manager already resolved the same bucket there's no extra GetBucketLocation call) and, if it differs from the supplied client's region, builds a private replacement S3Client for 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 shared AwsClients.s3 instance other components still hold. The resolution is memoized + single-flight (clientResolved / resolveInFlight), and degrades gracefully for a test double whose client lacks the SDK config.region() shape (skips resolution, uses the client unchanged) — so the store stays contained, with no ripple to deploy.ts / destroy.ts / state.ts / local-state-loader.ts. Tests: 4 new unit tests in tests/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: the cross-region-state-bucket fixture stack now publishes a CloudFormation Output with an Export.Name (an export-less stack short-circuits the index write entirely), and verify.sh greps the cdkd deploy + cdkd destroy --verbose output to assert the exports-index 301 warning is GONE on both paths AND that _index/{region}/exports.json was actually written to the cross-region bucket on deploy. New scenario tag exports-index-region-resolve.

  • destroy handles 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 destroy had NO SIGINT handler, so a first Ctrl-C killed the process mid-destroy — the finally that 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 a draining flag — 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 existing finally, which flushes the incremental save-chain from #804 (so the preserved state.json lists 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 NOT deleteState, even though errorCount === 0, because resources remain) and surfaces the outcome via a new DestroyRunnerResult.interrupted flag; both destroy.ts and state.ts stop their multi-stack loop on the first interrupted stack and throw PartialFailureError (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 via process.removeListener('SIGINT', ...) in the finally, so no listener leaks — important for nested-stack recursion, where NestedStackProvider.delete recurses into runDestroyForStack and 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 of cdkd destroy after 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 in tests/unit/cli/destroy-runner-sigint.test.ts (the SIGINT handler is captured by spying on process.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 + marks interrupted; the level-boundary gate stops all subsequent levels; a second Ctrl-C force-quits via process.exit(130); a normal completion leaves interrupted: false and removes the listener; process.removeListener is invoked in the finally. Happy-path (uninterrupted) destroy is unchanged. Docs: destroy-interruption subsection in State Management + the stale-lock note in Troubleshooting.

  • deploy retries 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 Control CreateResource for an AWS::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-created InfrastructureRoleArn before IAM had propagated it and rejected the create with Caught ServiceAccessDeniedException for ECSInfrastructureRole[arn:...]. The CC API handler classifies this as a terminal InvalidRequest (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' to RETRYABLE_ERROR_MESSAGE_PATTERNS — mirroring the ENHANCED_MONITORING pattern added for #794 — so the deploy engine's existing withRetry (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 surfaces ServiceAccessDeniedException is covered. Tests: the exact wire message from the issue classifies retryable + a plain AccessDeniedException (without the handler's "Caught" anchor) stays non-retryable in retryable-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::TaskDefinition Volumes[].ConfiguredAtLaunch no longer silently dropped (issue #806) — src/provisioning/providers/ecs-provider.ts. ECSProvider.convertVolumes mapped only Name / Host / EFSVolumeConfiguration when converting CFn Volumes to the RegisterTaskDefinition wire shape; ConfiguredAtLaunch was dropped, so the registered task definition had no configuredAtLaunch volume and a same-stack AWS::ECS::Service carrying VolumeConfigurations (a managed EBS volume — CDK's ServiceManagedVolume) 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 and Volumes IS in handledProperties — the gap was one level down, inside the handled property. convertVolumes now forwards configuredAtLaunch via a coerceBool helper (same pattern as EC2Provider's) that normalizes CFn boolean-ish values (true / "true" / false / "false") at the wire boundary and returns undefined for absent props so the field is omitted from the SDK input (AWS keeps its default). No parallel update() 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, explicit false preserved as distinct from omit). The ecs-fargate integ fixture gains a ServiceManagedVolume (1 GiB gp3, XFS) mounted into the container and attached to the Service via service.addVolume() — synthesizing exactly the ConfiguredAtLaunch + VolumeConfigurations pairing the bug broke (with desiredCount: 0 no task launches, so no EBS volume is actually created); verify.sh asserts the registered task definition's ebs-data volume has configuredAtLaunch == true (probed via jq has() — the // operator would map an explicit false to the fallback) and that DescribeServices shows the deployment carrying the ebs-data volume configuration. Remaining convertVolumes sub-property gaps of the same class (DockerVolumeConfiguration / FSxWindowsFileServerVolumeConfiguration unmapped; Host / EFSVolumeConfiguration cast without PascalCase-to-camelCase conversion) are tracked separately per the issue.

  • AWS::ECS::TaskDefinition Volumes[] sub-configurations fully PascalCase-to-camelCase converted (issue #815) — src/provisioning/providers/ecs-provider.ts. The remaining convertVolumes sub-property gaps deferred from #806 are now closed. Before: DockerVolumeConfiguration and FSxWindowsFileServerVolumeConfiguration were not mapped at all (silently dropped from RegisterTaskDefinition), and Host / EFSVolumeConfiguration were 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 the ContainerDefinitions sub-arrays (convertEnvironment / convertSecrets / convertMountPoints etc.). The property-coverage gate could not catch it — Volumes IS in handledProperties, so the gap was one level down inside the handled property. After: convertVolumes runs 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 *ToCloudFormation mappings: EFS uses FilesystemId (lowercase s) while FSx uses FileSystemId (capital S), and EFS AuthorizationConfig uses IAM (all caps), not Iam. Autoprovision (Docker) and TransitEncryptionPort (EFS) are coerced at the wire boundary (coerceBool / Number(...)) since CFn can carry them stringly-typed. readCurrentStateTaskDefinition now also normalizes the camelCase SDK volumes shape back to PascalCase via the new volumesToCfn SDK-to-CFn converter, so the readCurrentState drift 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-typed TransitEncryptionPort coercion + Docker full-shape + stringly-typed Autoprovision coercion + FSx full-shape + Host.SourcePath + omit-when-absent for every sub-block) plus a readCurrentState normalization 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 PascalCase Host.SourcePath input. Integ: the ecs-fargate fixture gains an efs.FileSystem + efs.AccessPoint (public subnets, RemovalPolicy.DESTROY) and an efsVolumeConfiguration volume on the task definition; verify.sh asserts describe-task-definition shows the efs-data volume's efsVolumeConfiguration reached AWS with camelCase fileSystemId / transitEncryption: ENABLED / authorizationConfig.{accessPointId, iam: ENABLED}. EFS is the integ-verified path; DockerVolumeConfiguration (Docker-daemon-scoped, unsupported on Fargate) and FSxWindowsFileServerVolumeConfiguration (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 + new src/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 without VolumeConfigurations, and UpdateService rejected 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 mirrors terraform-provider-awscc: CloudControlProvider.update now resolves the type's writeOnlyProperties from the registry schema via cloudformation:DescribeType (reduced to the top-level containing property — a nested path like /properties/Foo/Bar strips to Foo), removes those properties from the PREVIOUS side, and regenerates the patch — the generator then naturally emits add ops 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 on createOnlyProperties whose 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 new cloudformation:DescribeType permission — each update simply re-warns and re-falls-back. A DescribeType response without a Schema (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 a remove op 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 in tests/unit/provisioning/cloud-control-provider.test.ts (unchanged write-only prop rides along as add; 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: the ecs-fargate fixture's ServiceManagedVolume + CDKD_TEST_UPDATE pass (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 a Ref / Fn::GetAtt to 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 on NO_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 but UpdateService was 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.promoteReplacementDependents walks reverse reference edges (built from the desired template's per-property Ref / Fn::GetAtt / Fn::Sub / nested-intrinsic references via the new public TemplateParser.extractReferences; DependsOn is excluded — pure ordering carries no value to propagate) from every replacement-triggering UPDATE and promotes NO_CHANGE dependents to UPDATE with synthetic PropertyChange entries for the referencing top-level properties. Each synthetic change is re-evaluated against ReplacementRulesRegistry with undefined old/new values — the referencing property's template value did not actually change (only its resolved physical ID / ARN will), so unconditional replacementProperties still fire on the property name while conditionalReplacements are 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 whose ContainerDefinitions reference a replaced resource) becomes a replacement seed for its dependents — the walk is transitive, and the enqueued guard makes it terminate even on a reference cycle (A→B→A). Dependents that already had their own property changes stay UPDATE and gain the referencing-property entry (no duplicates); CREATE / DELETE dependents 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 synthetic PropertyChange carries replacementPropagated: true (a new optional field on the shared PropertyChange type) so cdkd diff annotates 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, replacementPropagated marker present, conditionalReplacement does NOT spuriously promote grandchildren, reference-cycle termination); the ecs-fargate integ fixture gains a CDKD_TEST_UPDATE=true Phase 1b (container command change → TaskDefinition replacement) whose verify.sh asserts the Service's taskDefinition tracks 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 classify ResourceNotFoundException as RETRY (no error acceptor) and poll GetFunction for the full maxWaitTime: 600, until the lenient delete catch swallowed the timeout. After: delete() issues ONE GetFunction pre-check before preparing the invocation; a definitive ResourceNotFoundException logs 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 — deleteState on 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: runDestroyForStack mirrors deploy's saveStateAfterResource — each successfully deleted resource (including the idempotent "not found → already deleted" path) is removed from a working copy of state.resources and 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 (deleteState on errorCount === 0, a final preserve-write of the remaining resources on errorCount > 0); the save chain is flushed before deleteState (no resurrection race) and before lock release. Nested stacks inherit the behavior automatically — NestedStackProvider.delete routes child destroys through the same runDestroyForStack. cdkd destroy and cdkd state destroy share the runner, so both get it.
    • Persisted destroy snapshots clear outputs / drop imports / outputReads (phantom-export fix). Both the incremental writes and the final partial-failure preserve-write now write outputs: {} and omit imports / outputReads. outputs is 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's scanActiveConsumers strong-ref scan could pick up. The destroy's OWN strong-ref check is unaffected: it reads the in-memory state.outputs BEFORE the delete loop, and the in-memory state object is never mutated (only the persisted snapshot copies are cleared). On a clean destroy the exports-index entry is removed via exportIndexStore.removeStack; on a partial destroy the index may briefly carry stale entries (a perf-only derived view that self-heals), but the canonical state.json no 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 then deleteState, 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).
  • Structured deployment events + cdkd events command (issue #808) — src/types/deployment-events.ts, src/state/deployment-events-store.ts, src/cli/commands/events.ts, plus event-emission seams in src/deployment/deploy-engine.ts, src/cli/commands/deploy.ts, src/cli/commands/destroy-runner.ts, src/cli/commands/destroy.ts. cdkd now records a CloudFormation DescribeStackEvents-equivalent stream of structured deployment events to S3 for every cdkd deploy / cdkd destroy run, readable back with the new cdkd 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-side DeletionPolicy: Retain skip); 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 .cause chain.
    • Emitter seam: the DeployEngine emits per-resource + rollback events through an optional DeploymentEventRecorder injected via DeployEngineOptions.eventRecorder (around the existing provisionResource / performRollback paths — no logging rewrite); the destroy runner emits per-resource DELETE events through DestroyRunnerContext.eventRecorder; the deploy / destroy CLIs own the run-level RUN_STARTED / RUN_FINISHED events (they know the command / version / result) and finalize() the recorder in a finally.
    • S3 layout (no state schema bump): JSONL at s3://{bucket}/{prefix}/{stackName}/{region}/deployments/{runId}.jsonl + a small deployments/index.json (last 20 runs, newest first). Deliberately a separate key family from state.json — state stays at its current version (no integ-schema-migration gate), fully backward compatible. Event files survive cdkd destroy (state deletion does not touch deployments/), 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 .jsonl keys; index.json is last-writer-wins — a derived view, the .jsonl files 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}.jsonl key enumeration); --run reads 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 the deployments/ key listing so it works for destroyed stacks. Registered in src/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 + a cdkd events section in CLI Reference. Out of scope (per the issue, deferred): cdkd doctor --bundle diagnostic bundle + MCP server exposure.
    • Follow-up (review fixes, same PR):
      • cdkd events no longer mislabels a successful run as FAILED in the index-fallback (user-visible correctness fix). When deployments/index.json is missing / corrupt, DeploymentEventsReader.listRuns rebuilds the run listing by enumerating the {runId}.jsonl keys. It previously stamped every fallback row result: 'FAILED' — so a run that genuinely SUCCEEDED but whose index.json write lost the last-writer-wins race showed as FAILED. 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 last RUN_FINISHED event; a run with no terminal RUN_FINISHED (interrupted, or index write lost) reports the new result: 'UNKNOWN' (added to a DeploymentRunSummaryResult = DeploymentRunResult | 'UNKNOWN' type used only on the summary; the run-level emitters still only ever produce SUCCEEDED / 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 the RUN_STARTED / RUN_FINISHED + --dry-run-skips-recorder + extractDeploymentEventError-on-failure contract is directly unit-testable and shared by both deploy.ts and destroy.ts (behavior identical to the prior inline code).
      • Added tests: tests/unit/types/deployment-events.test.ts (extractDeploymentEventError deepest-AWS-shaped-error extraction, bounded-depth-10 + cyclic-chain guard, non-Error inputs), tests/unit/cli/destroy-runner-events.test.ts (destroy-runner RESOURCE_STARTED / SUCCEEDED / FAILED + RESOURCE_RETAINED for a DeletionPolicy: Retain skip + no-recorder back-compat), tests/unit/cli/deployment-events-run.test.ts (run-level bracket: dry-run = no recorder, RUN_STARTED at create, success RUN_FINISHED with counts, failure RUN_FINISHED with result: 'FAILED' + error metadata, no-properties-leak), a ROLLBACK_RESOURCE_FAILED case in deployment-events-emission.test.ts, the no-FAILED-fabrication + UNKNOWN-on-torn cases in deployment-events-store.test.ts, and a listRawKeys multi-page ContinuationToken pagination case in tests/unit/state/s3-state-backend.test.ts.
    • Integration coverage (follow-up PR, refs #808): new tests/integration/deployment-events/ fixture — a tiny CdkdDeploymentEventsExample stack (an AWS::SNS::Topic + an AWS::SSM::Parameter, no VPC / NAT, deploy+destroy in well under a minute) with a verify.sh that exercises the feature end-to-end against real AWS: deploy writes the deployments/{runId}.jsonl + index.json sidecar; cdkd events <stack> --stack-region <r> lists a deploy run as SUCCEEDED; --format json is valid JSON carrying RUN_STARTED / RUN_FINISHED + at least one RESOURCE_* 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); after cdkd destroy --force, state.json is gone but the deployments/ sidecar survives (now carrying the destroy run too) and cdkd events lists 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 tag deployment-events.
  • LockManager resolves the state bucket's actual region before lock operations (issue #803) — src/state/lock-manager.ts. PR #60 taught S3StateBackend to resolve a cross-region state bucket's real region via GetBucketLocation and rebuild its S3 client, but LockManager was left out: it kept using the raw client pinned to the CLI's base region (AWS_REGION / fallback us-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. LockManager now has its own ensureClientForBucket() (awaited at the top of acquireLock / 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 8 new LockManager(...) call sites), and the original client is NOT destroyed (it is the shared AwsClients.s3 instance other components still hold). resolveBucketRegion caches per bucket name, so when the state backend already resolved the same bucket the lock path adds no extra GetBucketLocation call. The fix is contained entirely inside LockManager — 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. The cross-region-state-bucket integ fixture is now AUTOMATED: its new verify.sh creates a temporary uniquely-named state bucket in us-west-2, runs deploy / state ls / destroy with AWS_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::Instance security-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 through EC2Provider's create() + update() + readCurrentState() and added to handledProperties for AWS::EC2::Instance (the type stays open for the remaining ~26 props in tests/fixtures/cfn-schemas/_todo-backfill.json). All five are mutable in-place, so each has an update() path diffed against previousProperties (the cdkd drift --revert no-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 RunInstances at create; ModifyInstanceAttribute on update; readback via the existing DescribeInstanceAttribute(disableApiTermination) call. The destroy-side flip-off already lived in ec2-termination-protection.ts.
    • MetadataOptions — IMDSv2 enforcement (HttpTokens=required) mitigates SSRF credential theft. RunInstances at create; ModifyInstanceMetadataOptions on update; reverse-mapped from DescribeInstances .MetadataOptions on readback, excluding the AWS-managed State field to avoid false-positive drift.
    • Monitoring — detailed CloudWatch monitoring. RunInstances { Enabled } at create; MonitorInstances / UnmonitorInstances on update; readback already mapped .Monitoring.State to a boolean.
    • EbsOptimized — dedicated EBS throughput. RunInstances at create; ModifyInstanceAttribute on update; readback emit-when-present.
    • CreditSpecification — T-family burstable CPU credit mode. RunInstances at create; ModifyInstanceCreditSpecification on update; readback via DescribeInstanceCreditSpecifications (best-effort: non-burstable families error and fall back to omitting the key). Accepts the canonical CFn CPUCredits key and the SDK-style CpuCredits key.
    • CFn boolean-ish (true / "true") and numeric (HttpPutResponseHopLimit) values are coerced at the wire boundary. The ec2-instance integ fixture is rewritten to author the instance as a raw L1 ec2.CfnInstance: the L2 ec2.Instance construct always emits an AvailabilityZone property (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 new verify.sh asserts each prop reached AWS post-deploy and that provisionedBy stayed sdk, then exercises the destroy path with --remove-protection (the instance is termination-protected). Tests: 14 create/update unit tests + 5 readback unit tests.

Recently Implemented (2026-06-09):

  • Property-coverage backfill (issue #609): wired 6 top-level properties on AWS::EFS::FileSystem in one bundle — AvailabilityZoneName, LifecyclePolicies, BackupPolicy, FileSystemPolicy, BypassPolicyLockoutSafetyCheck, and FileSystemProtection — all previously silent-dropped by EFSProvider. One prop is deferred as unhandledByDesign: 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 remaining silentDrop set is exactly { ReplicationConfiguration }.

    • AvailabilityZoneName (One Zone EFS) rides DIRECTLY on CreateFileSystem and is immutable — create() forwards it; a later change is routed through DELETE+CREATE by the replacement-detection layer (it is in updateFileSystem's immutable-key reject guard alongside Encrypted / KmsKeyId / PerformanceMode). readCurrentState surfaces it from DescribeFileSystems.
    • LifecyclePolicies / BackupPolicy / FileSystemPolicy (+ BypassPolicyLockoutSafetyCheck) / FileSystemProtection each 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-time available wait. They are wrapped in a new retryOnTransientControlPlane helper (modeled on the DynamoDB provider's PITR/TTL retry) because back-to-back EFS control-plane ops collide with IncorrectFileSystemLifeCycleState / ConflictException / "in progress". create() is atomic: a post-ACTIVE step failure best-effort DeleteFileSystems the just-created file system (modeled on DynamoDBTableProvider.create's tableCreated rollback) so a half-built file system does not orphan + block the next deploy's CreationToken.
    • FileSystemPolicy casing/shape: the CFn property is a JSON policy object but the SDK's PutFileSystemPolicy.Policy field is a JSON string, so the provider JSON.stringifys an object value; readCurrentState JSON.parses the DescribeFileSystemPolicy.Policy string back to an object so the drift comparator compares object-to-object. BypassPolicyLockoutSafetyCheck is a field ON PutFileSystemPolicy (not a standalone resource on AWS), so it wires together with FileSystemPolicy.
    • update() applies each control-plane prop only on JSON.stringify-deep diff; a LifecyclePolicies removal clears all policies via PutLifecycleConfiguration([]); BackupPolicy / FileSystemPolicy / FileSystemProtection have no clean CFn "drop" mapping so a pure removal is a deliberate no-op. readCurrentState is 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 silentDrop to handled in property-coverage.generated.ts (regenerated via vp run gen:property-coverage). 15 new unit tests in tests/unit/provisioning/providers/efs-provider.test.ts cover the create-input ride (AvailabilityZoneName), each post-ACTIVE Put*/Update* apply, the JSON.stringify policy + 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 existing tests/integration/efs-standalone/ fixture — its L2 efs.FileSystem gains lifecyclePolicy / enableAutomaticBackups / replicationOverwriteProtection / fileSystemPolicy, and a NEW verify.sh deploys, asserts all four reached AWS (describe-backup-policy, describe-lifecycle-configuration, describe-file-systems for FileSystemProtection, describe-file-system-policy), then destroys clean.
  • cdkd local invoke / run-task reach a server on the host via host.docker.internal + start-service/start-alb WARN dedup follows (issues #784 / #785 / #786 / #787) — bumps cdk-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 OWN invoke / run-task command paths (it does NOT embed cdk-local's invoke / run-task factories).

    • #784 (cdk-local #483) — host.docker.internal reachability on invoke / run-task — REQUIRED cdkd code. A Lambda / ECS task container can now reach a server bound on the host loopback (an AWS_ENDPOINT_URL_* local endpoint, or a tunneled VPC resource) via host.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's resolveHostGatewayExtraHosts() (re-exported via src/local/docker-version.ts alongside HOST_DOCKER_INTERNAL_GATEWAY) into cdkd local invoke (threaded into runDetached's extraHosts) and cdkd local run-task (set on RunEcsTaskOptions.hostGatewayExtraHosts, merged with the Cloud Map peer-discovery --add-host flags by the new pure mergeHostGatewayAddHostFlags helper in ecs-task-runner.ts). start-service / start-alb inherit the same reachability automatically from cdk-local's bundled ECS service emulator engine (cdkd's ecs-service-emulator.ts is a re-export shim — no local resolve site). Tests: a mergeHostGatewayAddHostFlags unit 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 memory feedback_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-alb consume 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 pinUnresolved browser hint — N/A for cdkd. cdkd does not embed cdk-local's studio command, so this createLocalStudioCommand-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-task against real Docker (the host-gateway mapping is added on a host-gateway-capable daemon and the containers run cleanly).
  • cdkd local start-cloudfront WARNs when --cache-origin is set without --from-cfn-stack (issue #782) — bumps cdk-local ^0.140.0 -> ^0.142.0. cdkd local start-cloudfront is a THIN pass-through to cdk-local's createLocalStartCloudFrontCommand factory, so cdk-local's #476 is inherited with no cdkd source-logic change — only the dep bump + the local-emulation.md --cache-origin doc line were updated. Behavior delta (cat 4 in #782): start-cloudfront ... --cache-origin with no --from-cfn-stack was previously a fully silent no-op (--cache-origin only 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 (the local-start-cloudfront integ verify.sh greps only the boot banner + specific GET response bodies/headers, and uses neither --cache-origin nor --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's studio command); the rest of the 0.141.0 / 0.142.0 commits are test / docs / chore.

  • cdkd local start-cloudfront --kvs-file accepts a construct path / bare construct id (issue #780) — bumps cdk-local ^0.139.0 -> ^0.140.0. cdkd local start-cloudfront is a THIN pass-through to cdk-local's createLocalStartCloudFrontCommand factory, so cdk-local's #467 is inherited with no cdkd source-logic change — only the dep bump + the local-emulation.md --kvs-file doc 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-suffixed AWS::CloudFront::KeyValueStore resource logical id, and an unrecognized key was silently ignored (the store stayed unbound and the cf.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 exports normalizeKvsFileKeys from cdk-local/internal (cat 3 in #780) for a host building its own --kvs-file flow; 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's studio command). cdkd's unit test asserts only that --kvs-file is a registered option (no assertion on the old silent-ignore behavior), so no test change was needed; the local-start-cloudfront integ fixture does not exercise --kvs-file (no KeyValueStore in the distribution).

  • cdkd local start-agentcore follows cdk-local #454 (warm serve generalization) + #455 (CodeConfiguration build no-install) — issues #774 / #775 / #776 / #777 / #778 — bumps cdk-local ^0.128.0 -> ^0.139.0. cdkd local start-agentcore is a THIN pass-through to cdk-local's createLocalStartAgentCoreCommand factory and every src/local/agentcore-*.ts module is a re-export shim over cdk-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 the local-start-agentcore integ verify.sh were updated. Behavior deltas inherited (verified end-to-end via /run-integ local-start-agentcore against 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-resolved Authorization injected, request/response incl. SSE streamed) alongside the /ws bridge, both on the same host port. A new HTTP contract served on http://... ready line is printed; the existing Server 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 serve POST / (port 9000), with no /ws bridge. (Was: rejected up front with LOCAL_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. A customJwtAuthorizer runtime now boots without a token and verifies each contract request's Authorization per request (401 missing / 403 invalid / forwarded on pass; GET /ping unauthenticated); --bearer-token is the default-when-missing fallback. --sigv4 (new flag, auto-inherited via addStartAgentCoreSpecificOptions) signs each forwarded request with AWS SigV4 (service bedrock-agentcore) when no customJwtAuthorizer is declared; mutually exclusive with --bearer-token. (Was: boot-time --bearer-token validation, 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 as invoke-agentcore --ws --watch). (Was: ran until ^C with no reload.)
    • #774 (cat 4, cdk-local#455 / cdk-local#456) — CodeConfiguration builds no longer install deps. The fromCodeAsset / fromS3 source build (buildAgentCoreCodeImage, shared by both invoke-agentcore and start-agentcore) now runs the bundle as-is — no pip 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 with ModuleNotFoundError the 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.sh extended to probe the new warm HTTP contract (the HTTP contract served on http://... ready line, GET /ping -> 200, POST /invocations echo round-trip with the bridge-injected session-id) and a second --sigv4 boot asserting the forwarded request carries an AWS4-HMAC-SHA256 Authorization header — on top of the existing header-less /ws bridge 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.ts continues to assert the cdkd --from-state / --state-bucket / --state-prefix flags + the inherited option block.

Recently Implemented (2026-06-05):

  • cdkd local invoke / cdkd local start-api pin a ZIP Lambda's --platform to its declared Architectures (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.ts captured Architectures only on the IMAGE variant), so a ZIP container ran at the host's native arch; a provided.* custom-runtime bootstrap compiled for the other architecture failed with fork/exec /var/runtime/bootstrap: exec format error / Runtime.InvalidEntrypoint on an arch-mismatched host. After: ResolvedZipLambda / ResolvedStartApiZipLambda carry architecture (parsed by a shared extractArchitecture / extractStartApiArchitecture helper, default x86_64, arm64 honored, unsupported values rejected), and both the cdkd local invoke ZIP plan (resolveZipImagePlan) and the cdkd local start-api warm-container spec thread architectureToPlatform(architecture) to docker 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's invoke / start-api factories; those paths are cdkd-local code (lambda-resolver.ts / local-invoke.ts / local-start-api.ts). (cdkd local start-alb / start-cloudfront use cdk-local's engine / factory and already inherited #428 via the pinned cdk-local 0.126.6.) Tests: ZIP-arch capture (lambda-resolver.test.ts arm64 / default-x86_64 / reject; local-start-api-container.test.ts same for the start-api resolver) + the ZIP plan --platform threading (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 — bumps cdk-local ^0.126.6 -> ^0.128.0 and threads cdkd's S3-backed --from-state factory into the start-cloudfront pass-through, mirroring start-agentcore / start-alb / start-service. cdk-local 0.128.0 (go-to-k/cdk-local#426 / #436) added the extraStateProviders seam to CreateLocalStartCloudFrontCommandOptions (the factory now passes it through to its two internal createLocalStateProvider calls — the KVS resolver + the S3-origin/Function-URL resolver), which start-cloudfront previously lacked (the reason it shipped --from-state-exempt in the start-agentcore PR #767). src/cli/commands/local-start-cloudfront.ts now passes { embedConfig, extraStateProviders: cdkdExtraStateProviders } to the factory and adds the cdkd-specific --from-state / --state-bucket / --state-prefix flags 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 a cdkd deploy, not only to a CloudFormation stack. The two state sources stay mutually exclusive (enforced by cdk-local's createLocalStateProvider). Tests: tests/unit/cli/local-start-cloudfront.test.ts flips its "exempt from #766" assertions to assert the three cdkd flags are present + defaulted (--from-state false, --state-prefix cdkd); the dispatcher-wiring comment now groups start-cloudfront with 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 the local-start-cloudfront integ (the command boots + serves cleanly on cdk-local 0.128.0; the --from-state substitution path is the shared cdk-local mechanism already real-AWS-verified by local-start-alb-from-state). No cdkd source change beyond the wrapper + the dep bump.

  • Bump cdk-local ^0.126.0 -> ^0.126.6 so the factory pass-through local commands read aws-cdk-lib 2.258.0 (cloud-assembly schema v54) — aws-cdk-lib 2.258.0 (released 2026-06-04) bumped the cloud-assembly schema to v54. The cdk-local-factory-based local commands (start-agentcore / start-cloudfront / start-alb / start-service) synth through cdk-local's toolkit-lib-based Synthesizer, which (via @aws-cdk/toolkit-lib@1.26.2 -> cloud-assembly-schema@53.27.0, max v53) rejected v54 with AssemblyVersionMismatch: Maximum schema version supported is 53.x.x, but found 54.0.0. cdkd's own deploy / synth and the cdkd-implemented local commands (invoke / start-api / run-task / invoke-agentcore) were never affected — cdkd's core Synthesizer is self-implemented (reads manifest.json directly with no schema validation) and tolerates v54 (verified by synthesizing a 2.258.0 app through cdkd 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-lib to ^1.28.0 (cloud-assembly-schema >=54.2.0) + align @aws-cdk/cloud-assembly-api to ^2.2.5. cdkd inherits it by bumping the cdk-local floor to ^0.126.6 (brings @aws-cdk/toolkit-lib@1.28.0 into cdkd's tree; cdkd's manifest.json-direct reader needs no cloud-assembly-api dedup of its own). The tests/integration/local-start-agentcore/ fixture's interim aws-cdk-lib pin (~2.257.0, added in the start-agentcore PR to dodge the v54 break) is relaxed back to ^2.257.0 so it floats to current aws-cdk-lib — verified end-to-end: the fixture now resolves aws-cdk-lib 2.258.0 and cdkd local start-agentcore serves /ws through 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-state for the factory pass-throughs (issues #765 / #766) — bumps cdk-local ^0.106.0 -> ^0.126.0 and adds the long-running serve counterpart of cdkd local invoke-agentcore. cdkd local start-agentcore [target] boots the Bedrock AgentCore Runtime container (same image / env / credential resolution as invoke-agentcore) and fronts its bidirectional /ws WebSocket endpoint with a host WebSocket bridge that injects the AgentCore session-id (and, under a customJwtAuthorizer, the Authorization header) on the container upgrade — so a header-less client (e.g. a browser WebSocket, which cannot set custom upgrade headers) can hold an interactive multi-frame session. HTTP / AGUI protocols only (MCP / A2A runtimes have no /ws). New src/cli/commands/local-start-agentcore.ts is a THIN pass-through to cdk-local's createLocalStartAgentCoreCommand factory (cdk-local#420, released in cdk-local 0.125.0); cdkd re-hands the active embed config (so branding stays cdkd) and — UNLIKE start-cloudfront — threads its S3-backed --from-state factory through the factory's extraStateProviders seam, layering the cdkd-specific --from-state / --state-bucket / --state-prefix flags on top of cdk-local's inherited --from-cfn-stack / --stack-region. Registered in createLocalCommand() between invoke-agentcore and start-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's addStartAgentCoreSpecificOptions. The studio agentcore-ws serve kind (cdk-local 0.126.0 / cdk-local#422) spawns cdkl start-agentcore, but cdkd does NOT embed cdk-local's studio command, 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-prefix declarations + defaults, flag parsing). New integ fixture tests/integration/local-start-agentcore/ (adapted from cdk-local's): builds the EchoAgent container from a local Dockerfile, boots cdkd local start-agentcore --port 0, connects a header-less Node global-WebSocket probe (browser path), asserts the bridge injects a session-id + a second frame round-trips through the bridge (loop-echo:<text>), then SIGTERMs and asserts no cdkd-local-agentcore-* container leaks. Verified end-to-end via /run-integ local-start-agentcore.

  • cdkd local start-cloudfront gains Lambda Function URL + deployed-S3 origins (inherited from the cdk-local bump, cdk-local#380); --from-state stays exempt (#766) — the ^0.106.0 -> ^0.126.0 cdk-local bump changes the thin-pass-through start-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-role state-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-state into start-cloudfront: cdk-local's CreateLocalStartCloudFrontCommandOptions accepts only embedConfig, not the extraStateProviders seam, so start-cloudfront stays exempt from #766 until cdk-local exposes that seam (decided with the user; the start-agentcore / start-alb / start-service pass-throughs DO thread --from-state). The command's doc comment + tests/unit/cli/local-start-cloudfront.test.ts were updated to the new contract (asserts the inherited CFn flags are present AND cdkd's --from-state / --state-bucket / --state-prefix are absent), and the local-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-protection clears EC2 DisableApiTermination on BOTH the SDK and Cloud Control delete paths, retrying through the flip-off propagation race — fixes a real destroy --remove-protection failure surfaced by the remove-protection integ. cdkd flips DisableApiTermination off (ModifyInstanceAttribute) and then deletes the instance, but AWS's modify WRITE lags the delete READ, so the delete 400s with The instance ... may not be terminated. Modify its 'disableApiTermination' instance attribute and try again. even though cdkd just cleared it (empirically: a manual modify-instance-attribute --no-disable-api-termination reports success and describe-instance-attribute reads true for ~25s, yet a terminate-instances immediately 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, an AWS::EC2::Instance is frequently routed through Cloud Control (its template trips the #614 silent-drop routing — confirmed via provisionedBy: cc-api in the integ's state), and CloudControlProvider.delete had NO DisableApiTermination handling at all — so the original SDK-only EC2Provider.deleteInstance flip-off never ran for the integ's instance. The fix adds a shared src/provisioning/ec2-termination-protection.ts helper (disableInstanceApiTermination + isTerminationProtectionPropagationError + TERMINATION_PROTECTION_MAX_ATTEMPTS) used by BOTH EC2Provider.deleteInstance (SDK path) and CloudControlProvider.delete (CC-API path): when context.removeProtection === true and the type is AWS::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-protection must fail fast so the user is told to pass the flag — so the retry is gated on removeProtection === 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-protection fails 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 the remove-protection integ (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, ... | less then q) 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. New installPipeCloseHandler() (src/cli/pipe-close-handler.ts, called once at the top of main() in src/cli/index.ts) attaches an 'error' listener to process.stdout / process.stderr that process.exit(0)s on EPIPE and re-throws every other (real) stream error unchanged. Surfaced by the remove-protection integ, whose cdkd 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 -q closing 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 the remove-protection integ.

  • Route53::HostedZone destroy waits through the *_HOSTED_ZONE_LOCKED accelerated-recovery transients instead of bailing — fixes a destroy failure + orphan surfaced by the 2026-06-02 regression sweep (fixture route53). A hosted zone deployed with HostedZoneFeatures.AcceleratedRecoveryStatus: 'ENABLED' must have the feature disabled before DeleteHostedZone is accepted; the pre-delete guard ensureAcceleratedRecoveryDisabledForDelete (in src/provisioning/providers/route53-provider.ts) issues UpdateHostedZoneFeatures(false) and polls GetHostedZone until the status settles to DISABLED. The bug: the enable/disable transition briefly surfaces ENABLING_HOSTED_ZONE_LOCKED / DISABLING_HOSTED_ZONE_LOCKED (AWS momentarily locks the zone mid-transition), and these were lumped into the TERMINAL_FAILED set alongside the genuinely-failed ENABLE_FAILED / DISABLE_FAILED — so the destroy bailed with operator must resolve the moment it observed a lock transient, even though the zone settles to DISABLED on its own within seconds (confirmed via manual cleanup: the real zone transitioned DISABLING → DISABLING_HOSTED_ZONE_LOCKED → DISABLED). Fix: TERMINAL_FAILED now contains ONLY ENABLE_FAILED / DISABLE_FAILED; the *_HOSTED_ZONE_LOCKED states are treated as in-flight sub-states — the Phase-1 enabling-settle wait fires on ENABLING OR ENABLING_HOSTED_ZONE_LOCKED, the Phase-2 already-disabling skip fires on DISABLING OR DISABLING_HOSTED_ZONE_LOCKED, and the waitFor poll loop polls through any lock transient like any other non-target status until it reaches ENABLED / DISABLED (or the existing env-overridable timeout). Genuinely-failed states still hard-fail immediately with the manual-recovery pointer. Tests: two new cases in tests/unit/provisioning/route53-provider.test.ts (waits through DISABLING_HOSTED_ZONE_LOCKED → DISABLED before DeleteHostedZone; waits through an initial ENABLING_HOSTED_ZONE_LOCKED → ENABLED → disable → DISABLED). Real-AWS verified via the route53 integ: deploy enables accelerated recovery, destroy now disables + waits through the lock transients + deletes clean (was a hard FAIL + manual cleanup before).

  • AWS::SSM::Parameter deploy no longer crashes on Tags (CFn SSM Tags is a key->value MAP, not a list) — fixes a hard deploy failure surfaced by the 2026-06-02 regression sweep (fixtures context-test AND infra-security, both never-run integs until this session). Any SSM Parameter with tags failed to create with Failed to create SSM parameter <id>: properties.Tags.map is not a function. Root cause: unlike almost every other CFn resource (whose Tags is a [{Key,Value}] list), AWS::SSM::Parameter.Tags is a key->value map ({ "Env": "prod" }) — CDK synthesizes the map form, and SSMParameterProvider.create() / update() did properties['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, and context-test / infra-security had never been run as integs. Fix: a cfnTagsToSdkTags() 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 dropping aws:-prefixed reserved keys; create() / update() route through it. readCurrentState() now ALSO emits Tags as the map shape (matching the template shape cdkd stores in state) instead of the {Key,Value}[] list — an array readback would false-positive cdkd drift on every clean run for a tagged parameter (state map vs observed list never compare equal). Tests: new tests/unit/provisioning/ssm-parameter-provider-tags-map.test.ts (create accepts the map shape + applies it as SDK Tag[]; defensive list-shape still works; aws:* keys dropped; empty map fires no AddTags; non-string values coerced; update diffs map shapes for add/remove; unchanged map is a no-op) + updated ssm-parameter-provider-readcurrentstate.test.ts assertions to the map shape. Real-AWS verified via the context-test integ (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 CDK cr.Provider-framework custom resource failed to create with 403 lambda:GetFunction ... no identity-based policy allows even though the framework role's inline policy (which DOES grant it — present since aws-cdk v2.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 / waitUntilFunctionActive then 403s. CloudFormation never hits this because its deployment latency lets IAM settle (confirmed: the outbound.js framework runtime is byte-identical across 2.250.0 -> 2.257.0, so a version bump does NOT fix it; SimulatePrincipalPolicy is NOT a valid signal either — it reports allowed while the live assumed-role session still 403s, because IAM's policy-evaluation store and STS credential vending propagate independently). Fix: invokeCustomResourceWithRetry() in src/provisioning/providers/custom-resource-provider.ts re-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 the disableOuterRetry invariant that guards against stranding a response at an unpolled S3 key) AND recycles the backing function's execution environment via a no-op UpdateFunctionConfiguration so 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's withRetry already applies to every other resource — the CR path opts out of withRetry (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, =0 disables, narrow classifier). Real-AWS verified via the custom-resource-provider integ: deploy 37s (attempt 1 -> 403 -> recycle -> attempt 2 created), destroy 17 deleted / 0 errors / 0 orphans (was a hard FAIL before).

  • destroy retries the transient Lambda EventSourceMapping "in use" delete error — fixes a partial-destroy + orphan surfaced by the 2026-06-02 regression sweep (fixture multi-resource). Deleting an SQS EventSourceMapping on destroy could fail with Cannot delete the event source mapping because it is in use. — a transient AWS state-lifecycle lock during teardown that clears on its own (a manual cdkd destroy re-run succeeded). Root cause: runDestroyForStack in src/cli/commands/destroy-runner.ts carried its OWN inline 4-pattern retryable list (Too Many Requests / has dependencies / can't be deleted since / DependencyViolation) and did NOT use the shared isRetryableTransientError classifier, so the ESM in-use error matched nothing and failed fast. Fix routes the destroy retry decision through isRetryableTransientError (plus an explicit Too Many Requests keep, since 429 $metadata can be lost across the ProvisioningError wrap) and adds the because it is in use message pattern to src/deployment/retryable-errors.ts — matched on the message substring (narrow to the transient delete case) rather than the bare ResourceInUseException name, which the SDK also throws for non-transient create conflicts. The provider's delete() 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-retryable ResourceNotFound guard (no over-broadening) in retryable-errors.test.ts, and a lambda-eventsource-provider delete case (throw-in-use → wrapped ProvisioningError → classified retryable). Real-AWS verified: multi-resource now destroys clean in a single destroy run (previously needed a manual re-run).

  • deploy --all / destroy --all order stacks by cross-stack references (Fn::ImportValue / Fn::GetStackOutput), not just manifest addDependency — fixes a real failure surfaced by the 2026-06-02 regression sweep. Previously --all ordered stacks ONLY by the cloud-assembly manifest's declared dependencies (CDK addDependency). A stack linked to another ONLY via a RAW cdk.Fn.importValue('<name>') / Fn::GetStackOutput (no addDependency) created no manifest dependency, so under the default --stack-concurrency 4 the consumer deployed before the producer and failed: deploy --all errored Fn::ImportValue: export 'X' not found / Fn::GetStackOutput: stack 'Y' not found, and destroy --all destroyed the producer before the consumer (StackHasActiveImportsError -> partial destroy + orphan). New src/analyzer/cross-stack-deps.ts inferCrossStackStackDeps(stacks) derives consumer->producer edges from the synthesized templates (map exportName -> producerStack from every stack's Outputs[*].Export.Name; match literal Fn::ImportValue export names + read each Fn::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.ts unions these with stack.dependencyNames at both --all sites (auto-include walk + inter-stack DAG edges, in-set guard preserved); destroy.ts reverse-sorts (consumer before producer, exported orderConsumersBeforeProducers, guarded to the synth path so the state-only fallback keeps original order). Manifest addDependency behavior 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-integ skill — a committed (NOT gitignored), update-type ledger records, one row per integration test, when it last ran (last_run_iso), its result (PASS/FAIL), duration_s, flow (verify.sh / standard), and a short note. /run-integ now 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 — NOT grep -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-integ skill: 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-integ plan (P0 changed+stale, P1 changed+green, P2 hygiene). No new markgate gate was added — the mandatory /run-integ step plus the committed file (a PR that ran integ but skipped the ledger is visible in review) plus /pick-integ treating 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's runAgentCoreWatchLoop hard-couples to cdk-local's OWN Synthesizer / LocalInvokeAgentCoreOptions types (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 + the ReloadVerdict / ReloadAssetContext types) — the SAME pattern cdkd local start-api --watch uses. A per-firing classifier picks the reload primitive: an interpreted-language source edit inside a CodeConfiguration source tree takes a soft-reload FAST PATH (docker cp the 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 a fromS3 / non-CDK-asset runtime, or any classifier-context failure) forces a full rebuild (SIGTERM + docker rm -f + re-resolve the image + fresh docker run). --watch applies to BOTH the --ws session 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-shot POST /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 --watch is 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 exported bootAgentCoreContainer(...) so the rebuild callback re-runs it against a fresh synth; the existing one-shot --ws / /invocations behavior is byte-for-byte unchanged (the watch path is purely additive). loadAgentCoreAssetContext + deriveOldAssetHash are NOT exported from cdk-local/internal so they are copied into the cdkd module (verified against node_modules/cdk-local/dist/internal.d.ts). New --watch Option (default false) registered near --ws; watch?: boolean added to LocalInvokeAgentCoreOptions. 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, the softReloadAgentContainer docker-cp + restart wiring, the isAgentCoreWatchEligible MCP/A2A no-op predicate, and flag registration). The local-invoke-agentcore integ verify.sh gains a --watch scenario (Test 21): open a long-lived --ws --watch session 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-interactive flag is removed; --ws now 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 --event frame 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 /ws connection. 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-interactive must 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 the wsInteractive option field, the --ws-interactive Option registration, and the wsInteractive && !ws warn guard; the --ws branch computes const interactive = process.stdin.isTTY === true and threads it through frameSource creation + the new exported wrapWsOnMessage(sink, interactive) helper (+ WS_REPL_PROMPT = '> '); readStdinLines() now skips strictly-empty lines. --watch on /ws (cdk-local#270) was deferred from this PR (cdk-local does not export its runAgentCoreWatchLoop, which lives inside cdk-local's own command tightly coupled to its synth / image-build / container-lifecycle internals) — it shipped in the follow-up cdkd local invoke-agentcore --watch entry above (a cdkd-owned watch loop on top of cdk-local's exported watch primitives, not a shim of runAgentCoreWatchLoop). Unit tests: 7 new cases for wrapWsOnMessage (interactive newline+prompt / non-interactive identity / no double-newline) and readStdinLines (skips empty, keeps whitespace-only) in tests/unit/cli/local-invoke-agentcore-pure-helpers.test.ts. The local-invoke-agentcore integ verify.sh Test 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.md updated.

  • cdkd local start-api --assume-role-auto — ports cdk-local's start-api --assume-role-auto flag (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-ARN Properties.Role first, then falls back to a deployed-state lookup (resolveExecutionRoleArnFromState, reused from local-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-auto OR global default --assume-role <arn>) > unset. --assume-role-auto is mutually exclusive with the global-default --assume-role <arn> form (errors at boot via the new normalizeStartApiAssumeRole guard in src/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: AssumeRoleOption gains bareAutoResolve?: boolean; LocalStartApiOptions gains assumeRoleAuto?: boolean; new exported resolveStartApiAssumeRoleArn(...) replaces the bare effectiveAssumeRoleArn(...) call in buildContainerSpec. assumeLambdaExecutionRole is unchanged (region-only). New unit test tests/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-local bumped 0.69.0 → 0.77.1 — cdkd follows the upstream local-emulation engine forward. The bulk of the delta is auto-inherited through the cdk-local/internal leaf-module shims and the ECS service-emulator option helpers (addStartServiceSpecificOptions / addAlbSpecificOptions) that cdkd local start-service / start-alb already call, so the new behavior lands with no cdkd .addOption(...) duplication. Newly inherited on cdkd local start-service / cdkd local start-alb: the --image-override family (--image-override <target>=<imageRef|dir|Dockerfile> plus per-service --image-build-arg / --image-build-secret / --image-target variants — 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 on start-alb (cdk-local#229). Also inherited across the shimmed modules: an interactive spinner during long docker 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), --profile now 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 a Set<string> (warn once ever per URL) to a WarnedAt = Map<string, number> (warn once per time-window). src/cli/commands/local-start-api.ts renames its local jwksWarnedUrls = new Set<string>() to jwksWarnedAt = new Map<string, number>() (passed to startApiServer's renamed jwksWarnedAt option at both the HTTP-API and WebSocket server-construction sites), and src/cli/commands/local-invoke-agentcore.ts's verifyJwtViaDiscovery call passes { warnedAt: new Map<string, number>() } instead of { warned: new Set() }. sigV4WarnedForeignIds is unchanged (still a Set<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 new cdk-local/internal signatures. The agentcore --ws REPL UX polish (cdk-local#278) and the --ws auto-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 cp the new source into each replica + docker restart, no docker build, no shadow boot, typical end-to-end latency well under a second; classifier logs verdict=soft-reload and the runner emits Soft-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 logs verdict=rebuild (…) and the runner emits Rolling replica … swap complete). Either path rolls one replica at a time, so the multi-replica zero-connection-refusal guarantee is preserved. cdkd local start-service previously did NOT expose --watch at all because cdkd was not calling cdk-local's addStartServiceSpecificOptions helper — this PR re-exports the helper from src/cli/commands/ecs-service-emulator.ts and wires it into createLocalStartServiceCommand, so --host-port (cdk-local 0.62+) AND --watch (cdk-local 0.69+) now land in cdkd local start-service --help and any future start-service-only flag the helper adds inherits automatically. cdkd local start-alb --watch (already wired via addAlbSpecificOptions) 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 fixture tests/integration/local-start-service-watch-fast/ (modeled on cdk-local's tests/integration/local-start-service-watch-fast/): single-replica Node-22 ECS service with a webapp/server.cjs interpreted handler (the .cjs extension keeps the committed source out of tests/integration/.gitignore's *.js sweep); verify.sh boots cdkd local start-service --watch, rewrites server.cjs v1 → v2 and asserts verdict=soft-reload + Soft-reloaded replica … complete + the v1 → v2 transition on curl / (with zero rebuild verdicts post-edit), then rewrites the Dockerfile and asserts verdict=rebuild (Dockerfile edit …) + Rolling replica … (swap|single-replica reload) complete + the v2 → v3 transition + clean SIGTERM teardown. Unit test tests/unit/cli/local-start-service.test.ts extended with assertions that --host-port / --watch are declared and that --watch defaults to false. 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's cdkl start-alb (cdk-local#203). A cloud-HTTPS listener is now served over plain HTTP locally — X-Forwarded-Proto: https is 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 --tls to restore TLS termination. New --tls opt-in flag is auto-implied by --tls-cert / --tls-key. Refactor follow-up to PR #725 / PR #731: dropped cdkd's local definitions of parseLbPortOverrides / resolveAlbTarget / albStrategy / pickStack / notFound and 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 bundled addAlbSpecificOptions + ALB strategy/helper exports (cdk-local#203). cdkd's src/cli/commands/local-start-alb.ts collapses from 421 LOC to ~110 LOC; src/cli/commands/ecs-service-emulator.ts re-exports the new ALB symbols from cdk-local/internal. LocalStartAlbOptions gains tls?: boolean. Net change: cdkd's start-alb automatically inherits any future ALB-only flag the upstream cdkl start-alb adds without manual .addOption(...) duplication. Unit test tests/unit/cli/local-start-alb.test.ts trimmed to cover only the cdkd-specific --from-state / --state-bucket / --state-prefix wiring + the cdkdExtraStateProviders singleton-identity check (the parseLbPortOverrides / resolveAlbTarget / albStrategy.resolveBoots blocks moved to cdk-local's own test). Docs local-emulation.md updated with the new --tls row + 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-state boot + plain-HTTP front-door curl + --from-state substitution + clean SIGTERM teardown).

  • BREAKING (cdkd local start-api): SigV4 default flipped from fail-closed to warn-and-pass, matching cdk-local's cdkl 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 v1 AuthorizationType: 'AWS_IAM' / Function URL AuthType: 'AWS_IAM' MUST add --strict-sigv4 to their cdkd local start-api invocation. 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's CDKD_EMBED_CONFIG flips sigV4StrictByDefault: true → false and sigV4OptFlag: '--allow-unverified-sigv4' → '--strict-sigv4'; LocalStartApiOptions.allowUnverifiedSigv4?: boolean renames to strictSigv4?: boolean; the two sigV4Strict: options.allowUnverifiedSigv4 !== true translation sites in local-start-api.ts flip to sigV4Strict: options.strictSigv4 === true; the .addOption(new Option('--allow-unverified-sigv4', ...)) block becomes --strict-sigv4 with the inverted help text; shim header comments in src/local/http-server.ts / src/local/sigv4-verify.ts updated; tests/unit/cli/local-embed-config.test.ts assertion updated to the new values. The memory rule feedback_shim_blocked_by_unadopted_semantic_divergence.md records 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::EventSourceMapping in one bundle — KmsKeyArn, LoggingConfig, MetricsConfig, ProvisionedPollerConfig, Queues, Topics, StartingPositionTimestamp — all previously silent-dropped by LambdaEventSourceMappingProvider. Per the AWS SDK shape audit (@aws-sdk/client-lambda 3.x CreateEventSourceMappingRequest vs UpdateEventSourceMappingRequest), 4 of the 7 ride BOTH create + update (KmsKeyArn / LoggingConfig / MetricsConfig / ProvisionedPollerConfig) and 3 are create-only (Queues / Topics / StartingPositionTimestamp are absent from UpdateEventSourceMappingRequest — 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 as KmsKeyArn (lower-case ms), but the SDK field is KMSKeyArn (upper-case MS); both create() and update() do the flip and readCurrentState() flips back so cdkd state stores the CFn-shaped key. StartingPositionTimestamp coercion: CFn supplies a Number (epoch seconds per the AWS::Lambda::EventSourceMapping schema), the SDK wants a Date; create() coerces (number/ISO-string/Date all accepted), and readCurrentState converts 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 on prev !== next: the 4 mutable props use !== undefined (not truthy) so explicit '' reaches AWS as the documented KMSKeyArn clear-back-to-AWS-owned-key sentinel rather than being silently dropped. readCurrentState is emit-when-present for all 7 — AWS returns these only when the user set them, so a phantom KmsKeyArn: '' / LoggingConfig: { ...defaults } placeholder would force guaranteed drift on every clean run for the typical un-configured ESM. With this slice the AWS::Lambda::EventSourceMapping type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json). 18 new unit tests across 3 files: 8 in lambda-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 in lambda-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 in lambda-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 existing tests/integration/dynamodb-streams/ fixture — the DynamoEventSource L2 gains a small FilterCriteria (so AWS actually persists KmsKeyArn — without filter criteria the key is a no-op and AWS silently doesn't surface it on get-event-source-mapping), a new kms.Key for the filter-criteria encryption with a Lambda-service grantEncryptDecrypt (so AWS authorizes the encryption op), and addPropertyOverride for KmsKeyArn + MetricsConfig on the synthesized L1 (the L2 doesn't surface these top-level props). The verify.sh extension asserts via aws lambda get-event-source-mapping that 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.md restructure (follow-up to PR #731 Part B). New fixture tests/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: 0 to avoid container compute cost) + IAM execution role + LogGroup + 2 SecurityGroups. Each service's TaskDefinition carries an ALB_DNS_NAME env var with Fn::GetAtt: [Alb, DNSName] so the engine's state-source dispatcher MUST substitute the resolved DNS name from cdkd's S3 state when cdkd local start-alb --from-state boots the containers locally. verify.sh does pre-flight Docker orphan sweep, deploys the stack via cdkd, validates ALB via aws elbv2 describe-load-balancers, boots cdkd local start-alb '<stack>/Alb' --from-state --lb-port 80=8080 in background, asserts the boot banner + the ALB front-door: ...:8080 listener banner (proves --lb-port override), curls http://127.0.0.1:8080/ and asserts the response body contains service=web alb=<deployed-alb-dns> (proves default-action routing + --from-state substitution reached the Web container), curls http://127.0.0.1:8080/orders/ and asserts service=orders alb=<deployed-alb-dns> (proves ListenerRule path routing + multi-target boot ordering + --from-state substitution reached the Orders container), SIGTERMs cdkd, asserts zero leftover cdkd-local-* containers + networks, runs cdkd destroy, and verifies the cdkd S3 state for the stack is empty. Closes the gap memory rule feedback_never_defer_integ_from_originating_pr.md records: the engine's host-side wiring (serviceStrategy factory + cdkdExtraStateProviders map + LocalStartAlbOptions index-signature extension) is uniquely exercised end-to-end here; the pure-local sibling tests/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.sh set -o pipefail bug (aws s3 ls returns 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 giant src/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_OCTET paragraph (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 Tags on AWS::CloudFront::Distribution, which CloudFrontDistributionProvider previously silent-dropped on write. Tags is 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's create() switches command class based on whether properties['Tags'] is non-empty (an empty Tags: [] from CFn is treated as "no tags" and routes through the plain CreateDistributionCommand to avoid hitting the tags-enabled control plane for nothing). update() gains a tag diff after the existing UpdateDistributionCommand: 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. readCurrentState is intentionally NOT added in this PR — CloudFront has no readCurrentState today (drift falls back to the CC-API path), and a partial implementation that reads only Tags while ignoring DistributionConfig would surface less drift than CC-API would; full readback is deferred to a separate PR. With this slice, the AWS::CloudFront::Distribution type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json — Tags was the only outstanding entry). Tags moves from silentDrop to handled in property-coverage.generated.ts (regenerated via vp run gen:property-coverage — the raw codegen formatting artifact is normalized by vp check --fix). 8 new unit tests in cloudfront-distribution-provider.test.ts cover the create command-class switch (with Tags → CreateDistributionWithTagsCommand, without and with Tags: [] → plain CreateDistributionCommand), 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 existing tests/integration/s3-cloudfront/ fixture — the L2 cloudfront.Distribution gains two cdk.Tags.of(distribution).add(...) calls; a NEW verify.sh deploys, resolves the distribution ARN via aws cloudfront get-distribution, asserts both tags via aws cloudfront list-tags-for-resource, then destroys clean.

  • Property-coverage backfill (issue #609): wired ReservedConcurrentExecutions on AWS::Lambda::Function, which LambdaFunctionProvider previously silent-dropped on write. Real safety concern before this PR: a CDK template setting reservedConcurrentExecutions: 100 to 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): CreateFunction does NOT accept the field; it requires a separate PutFunctionConcurrencyCommand({ 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 issues DeleteFunctionCommand as delete-on-post-create-failure atomicity rollback (mirrors RecursiveLoop's pattern exactly). update() gates on prev !== next strict-compare; removal (prev: number, next: undefined) maps to DeleteFunctionConcurrencyCommand — unlike RecursiveLoop which has no clear API and just leaves the last-set value pinned, AWS provides a dedicated DeleteFunctionConcurrency so a user dropping the template prop actually un-throttles the function instead of silently leaving the limit in place. readCurrentState adds a separate GetFunctionConcurrencyCommand call after the primary GetFunction, emit-when-present (the AWS response carries ReservedConcurrentExecutions only when the limit is set, so a typical un-throttled function correctly maps to omit-from-readback — no phantom drift). 9 new unit tests in lambda-function-provider.test.ts cover 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's lambda.Function gains reservedConcurrentExecutions: 5; verify.sh extends the existing RecursiveLoop assertion with aws lambda get-function-concurrency --query ReservedConcurrentExecutions returning 5. 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's start-alb shim work, completes the symmetry the ALB PR explicitly deferred). The old 944-line src/cli/commands/local-start-service.ts — owning the per-replica boot loop + shared docker network + Cloud Map registry + per-target createLocalStateProvider + manual env-substitution + SIGINT single-flight cleanup — collapses to a ~120-line shim mirroring local-start-alb.ts: a LocalStartServiceOptions interface extending the engine's EcsServiceEmulatorOptions with cdkd's --from-state / --state-bucket / --state-prefix, a small serviceStrategy(options): EmulatorStrategy (picker via listTargets(stacks).ecsServices, picker text "Select one or more ECS services to run", trivial resolveBoots mapping each chosen target to { target } since the engine's bootOneTarget calls resolveEcsServiceTarget internally, lbPortOverrides: {} since services have no listener ports), and a createLocalStartServiceCommand() that wires the shared runEcsServiceEmulator(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 BOTH start-service AND start-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), and tests/unit/cli/local-start-service-profile-creds.test.ts (resolveSharedSidecarCredentials is now sourced from cdk-local via the ecs-service-emulator.ts shim — testing it from cdkd was dead-coverage). src/local/ecs-network.ts keeps its createTaskNetwork / destroyTaskNetwork / buildMetadataEnv / buildEndpointSubnet exports (used by the still-local ecs-task-runner.ts for cdkd local run-task) but drops createSharedSvcNetwork + 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-facing cdkd local start-service command — 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-stack Fn::ImportValue substitution / ^C teardown) renders identically post-refactor. The tests/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-service joins local-start-alb in the engine-wired category where the dispatcher invocation lives inside cdk-local's runEcsServiceEmulator and reaches cdkd's S3-backed --from-state factory transparently via the shared cdkdExtraStateProviders map. The pre-PR MAX_TASKS_SUBNET_RANGE_CAP export from local-start-service.ts is dropped (the engine's bundled parseMaxTasks enforces the same cap with the same error message). Real-AWS verified via the existing tests/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-state substitution against deployed cdkd state is deferred to a follow-up PR — Part B's risk surface is concentrated in the small serviceStrategy() 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 Tags on AWS::S3Vectors::VectorBucket, which S3VectorsProvider previously silent-dropped on write. Tags is a standard CFn [{ Key, Value }] array. The AWS SDK CreateVectorBucketInput.tags accepts a flat Record<string, string> shape; createVectorBucket() converts the CFn array → SDK map and passes it on CreateVectorBucketCommand (omit-when-absent — an empty Tags: [] array sends no tags field so no spurious CloudTrail event fires). VectorBucket has NO UpdateVectorBucket API (the provider's update() 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. readCurrentState adds a second AWS call (ListTagsForResource(resourceArn=vectorBucketArn)) after the primary GetVectorBucket, converts the SDK Record<string, string> back to CFn [{ Key, Value }] shape, and emits Tags: [] when AWS returns no tags or when ListTagsForResource itself fails (best-effort; the drift comparator stays happy). With this slice, the AWS::S3Vectors::VectorBucket type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json). Tags moves from silentDrop to handled in property-coverage.generated.ts (962 handled, 409 silent-drop). New unit tests in tests/unit/provisioning/providers/s3-vectors-provider.test.ts (Tags forwarded as the SDK Record<string,string> shape; absent and empty-array variants both omit tags from the SDK input) and tests/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 to Tags: [] 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 expect Tags: [] in the result. Real-AWS verified by extending the existing tests/integration/s3-vectors/ fixture — the CfnVectorBucket L1 gains tags: [{ key: 'env', value: 'cdkd-integ' }, { key: 'team', value: 'platform' }], and a NEW verify.sh deploys, resolves the bucket ARN via aws s3vectors get-vector-bucket --query vectorBucket.vectorBucketArn, asserts both tags via aws 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:InferenceAccelerators entry from tests/fixtures/cfn-schemas/_todo-backfill.json. The property was ALREADY declared in ECSProvider.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.ts regenerated to reflect the move from silentDrop to 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 more AWS::ElasticLoadBalancingV2::LoadBalancer resources, discover the ECS / Lambda targets behind each listener's forward action, boot every backing ECS service via the shared engine local start-service uses, and stand up a per-listener node: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 of local start-api for ALB-fronted workloads. Models cdk-local's cdkl start-alb, ported into cdkd's command tree as a 2-shim + 1-command trio: src/local/elb-front-door-resolver.ts (re-exports resolveAlbFrontDoor + isApplicationLoadBalancer + the front-door type set from cdk-local), src/cli/commands/ecs-service-emulator.ts (re-exports the shared runEcsServiceEmulator engine + addCommonEcsServiceOptions + the EcsServiceEmulatorOptions / EmulatorStrategy / Planned* types from cdk-local/internal), and the 421-line src/cli/commands/local-start-alb.ts command file (createLocalStartAlbCommand + exported parseLbPortOverrides / resolveAlbTarget / albStrategy helpers). 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 (via AWS::ECS::Service.LoadBalancers[] binding the TG to a container + port) AND Lambda targets (via TG.Targets[].Id = {Fn::GetAtt: [<FnLogicalId>, "Arn"]}); authenticate-cognito + authenticate-oidc actions enforce a local Bearer-JWT check (or AWSELBAuthSessionCookie pass-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-region mirroring local start-service) ride through to the backing services via the shared engine — the engine internally calls createLocalStateProvider(options, ..., extraStateProviders) per backing-service boot, and cdkd's S3-backed --from-state factory is wired via the new export cdkdExtraStateProviders ({ fromState: fromStateFactory }) in src/cli/commands/local-state-source.ts. The new LocalStartAlbOptions interface extends EcsServiceEmulatorOptions with cdkd-specific fromState / stateBucket / statePrefix fields (carried through cdk-local's [key: string]: unknown index signature). Auth-guard opt-outs: --no-verify-auth disables the JWT check entirely; --bearer-token <jwt> injects a default Authorization header when the inbound request has none. New unit tests in tests/unit/cli/local-start-alb.test.ts (30 cases: parseLbPortOverrides valid / invalid / range / multi-entry semantics, resolveAlbTarget stack-prefix / multi-stack / non-ALB / missing-resource error paths, and the option-builder smoke test asserting the cdkd-specific --from-state / --state-bucket / --state-prefix flags are wired alongside the engine-inherited --from-cfn-stack / --stack-region / --lb-port / --max-tasks / --restart-policy / etc.). Real-AWS verified via NEW tests/integration/local-start-alb/ pure-local fixture (no AWS deploy): VPC-free Cfn* topology with one ALB + one HTTP:80 listener + one TargetGroup + one EC2-launchType ECS Service running busybox httpd on container port 80; verify.sh boots cdkd local start-alb with --lb-port 80=8080, asserts the boot banner + the front-door listening banner, hits http://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 leftover cdkd-local-* containers / networks). The local-start-service refactor 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 HostedZoneFeatures on AWS::Route53::HostedZone, which Route53Provider previously silent-dropped on write. HostedZoneFeatures is { 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 — CreateHostedZone does NOT accept the feature; it requires a follow-up UpdateHostedZoneFeaturesCommand({ HostedZoneId, EnableAcceleratedRecovery: boolean }). The backfill follows the post-create control-plane pattern established in PR #719 (Lambda::Function:RecursiveLoop): create() issues UpdateHostedZoneFeatures AFTER CreateHostedZone succeeds when the template requested 'ENABLED' (calling with false is skipped — AWS default is DISABLED, so the explicit-toggle hop is unnecessary); on failure issues DeleteHostedZone as delete-on-post-create-failure atomicity rollback before throwing (the next deploy retry sees no orphan zone). update() is extended with the missing previousProperties parameter and gates UpdateHostedZoneFeatures on prev !== next — a removal (prev: ENABLED, next: undefined) is treated as DISABLED (the AWS default state, matching CFn's omit-default convention). delete() gains a pre-delete guard — AWS rejects DeleteHostedZone while AcceleratedRecovery is anything other than DISABLED (Cannot delete a hosted zone with accelerated recovery enabled. Please disable first.), so deleteHostedZone probes the current status, issues UpdateHostedZoneFeatures(false) if needed, and polls until the AWS-side state settles to DISABLED (default 10-min timeout / 15s interval; env-overridable via CDKD_R53_ACCEL_RECOVERY_POLL_TIMEOUT_MS / CDKD_R53_ACCEL_RECOVERY_POLL_INTERVAL_MS). Without this guard, ANY zone deployed with HostedZoneFeatures.AcceleratedRecoveryStatus: 'ENABLED' would be physically un-destroyable via cdkd (the create path opts in, the destroy path's DeleteHostedZone is then rejected indefinitely until manual aws route53 update-hosted-zone-features --no-enable-accelerated-recovery recovery). 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_LOCKED transients are waited through (see the 2026-06-02 fix below). readHostedZone surfaces it back from GetHostedZone.HostedZone.Features.AcceleratedRecoveryStatus emit-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, the AWS::Route53::HostedZone type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json). New unit tests in route53-provider.test.ts (create with ENABLED triggers post-create UpdateHostedZoneFeatures(true); absent omits; explicit DISABLED also omits — AWS default; failed UHF rolls back via DeleteHostedZone + 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) and route53-provider-readcurrentstate.test.ts (readback emits when GetHostedZone.HostedZone.Features.AcceleratedRecoveryStatus is present; omits when AWS returns no Features block). Real-AWS verified via the existing tests/integration/route53/ fixture — the route53.HostedZone L2 gains addPropertyOverride('HostedZoneFeatures.AcceleratedRecoveryStatus', 'ENABLED') since CDK L2 does not expose the property; verify.sh is extended (same style as the existing GeoProximityLocation / CidrRoutingConfig assertions) with aws route53 get-hosted-zone --query 'HostedZone.Features.AcceleratedRecoveryStatus' asserting 'ENABLED' reached AWS, then destroys clean.

  • Property-coverage backfill (issue #609): wired ServiceConnectDefaults on AWS::ECS::Cluster, which ECSProvider previously silent-dropped on write. ServiceConnectDefaults is 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 in updateCluster explicitly deferred this slice ("ServiceConnectDefaults is also accepted by UpdateClusterCommand but is intentionally NOT applied here — create() and readCurrentState() do not surface it either"). It rides DIRECTLY on CreateCluster / UpdateCluster (the single SDK calls the provider already makes for AWS::ECS::Cluster) — there is NO separate control-plane API. CFn { Namespace } maps 1:1 to the SDK's serviceConnectDefaults: { namespace } (casing flip only). createCluster forwards properties['ServiceConnectDefaults'] when present (omit-when-absent). updateCluster adds it to the existing settingsChanged || configChanged JSON-stringify diff gate alongside ClusterSettings / Configuration so a ServiceConnectDefaults-only change triggers a single UpdateClusterCommand; the removal case sends the AWS-documented namespace: '' sentinel (per ClusterServiceConnectDefaultsRequest.namespace docs — "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. readCurrentStateCluster reads it back from DescribeClusters.serviceConnectDefaults.namespace emit-when-present (gated on !== undefined, NOT a default-when-absent placeholder — a cluster that never set a default Service Connect namespace returns no serviceConnectDefaults from AWS, so a phantom { Namespace: '' } would force guaranteed drift on every clean run). With this slice, the AWS::ECS::Cluster type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json — ServiceConnectDefaults was the only outstanding entry). ServiceConnectDefaults moves from silentDrop to handled in property-coverage.generated.ts (regenerated via vp run gen:property-coverage — the raw codegen formatting artifact is normalized by vp check --fix). New unit tests in ecs-provider.test.ts (create forwards ServiceConnectDefaults: { Namespace: '<arn>' } into CreateClusterCommand; omit-when-absent), ecs-provider-roundtrip.test.ts (update emits UpdateClusterCommand with serviceConnectDefaults: { namespace: '<arn>' } on add; emits { namespace: '' } clear-sentinel on removal; not present in input when only an unrelated field — ClusterSettings — changed), and ecs-provider-readcurrentstate.test.ts (readback emits ServiceConnectDefaults when AWS returns it; omits for the typical cluster that did not configure a default namespace). Real-AWS verified by extending tests/integration/ecs-fargate/verify.sh — the existing new ecs.Cluster({ defaultCloudMapNamespace: { name: 'cdkd-test.local' } }) synthesizes an AWS::ECS::Cluster whose ServiceConnectDefaults.Namespace carries the auto-created AWS::ServiceDiscovery::PrivateDnsNamespace's Arn; the verify.sh extension asserts via aws ecs describe-clusters --query 'clusters[0].serviceConnectDefaults.namespace' that the namespace ARN reached AWS (with a sanity check on the arn:*:servicediscovery:*:namespace/* shape), then destroys clean.

  • Property-coverage backfill (issue #609): wired Type on AWS::SecretsManager::Secret, which SecretsManagerSecretProvider previously silent-dropped on write. Type is 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 on CreateSecretRequest.Type). It rides DIRECTLY on CreateSecret / 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() passes properties['Type'] to createParams.Type truthy-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). readCurrentState reads it back from DescribeSecret's Type emit-when-present (gated on !== undefined, NOT a default-when-absent placeholder — the typical secret is non-partner-managed and AWS returns no Type, so an '' placeholder would force guaranteed drift on every clean run). With this slice, the AWS::SecretsManager::Secret type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json — Type was the only outstanding entry). Type moves from silentDrop to handled in property-coverage.generated.ts (regenerated via vp run gen:property-coverage — the raw codegen formatting artifact is normalized by vp check --fix). New unit tests extend secretsmanager-secret-provider-roundtrip.test.ts (create sends Type: 'urn:partner:example' into CreateSecretCommand; omit-when-absent; update sends Type: 'urn:partner:v2' into UpdateSecretCommand on diff; omit-when-absent on update) and secretsmanager-secret-provider-readcurrentstate.test.ts (readback emits Type when AWS returns a partner identifier; omits for the typical non-partner-managed secret). Integ verified via /run-integ composite-stack (the existing AWS::SecretsManager::Secret row in the fixture — no Type set, so the integ exercises the omit-when-absent path end-to-end and confirms the handledProperties addition does not regress the existing secret deploy). The Type wire 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's cdkl invoke-agentcore, ported into cdkd's command tree as 8 new shim files under src/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 new src/local/target-picker.ts shim + an expanded src/local/cognito-jwt.ts shim (adds verifyJwtViaDiscovery to the re-export list for inbound JWT auth) + the ~1650-line src/cli/commands/local-invoke-agentcore.ts command file. The command supports the container artifact (fromContainerAsset / fromEcr) and the CodeConfiguration managed-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, default linux/arm64 per AgentCore's required arch), state-source flags (--from-state / --from-cfn-stack mirroring cdkd local invoke), role-assumption flags (--assume-role auto-resolves the runtime's RoleArn from 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) and resolveSingleTarget (interactive picker for omitted target) — so cdkd's shim consumer pattern works without inlining 250+ lines of helpers. resolveExecutionRoleArnFromState in src/cli/commands/local-invoke.ts was extended with an optional roleProperty parameter (defaulting to 'Role') so the agentcore command can reuse it with 'RoleArn' (the field name on AWS::BedrockAgentCore::Runtime). The cdkd local-state-source.ts shim adds resolveCfnFallbackRegion and ExtraStateProviders to its re-export list. Cross-cutting src/local/docker-runner.ts extension for the new command's protocol diversity + secret-handling needs: adds optional containerPort?: 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 -p flag publishes the right port) and optional sensitiveEnvKeys?: ReadonlySet<string> (always unioned with the new SENSITIVE_ENV_KEYS constant covering the AWS credential set, so decrypted SecureString SSM values + AWS creds are routed through docker's value-from-process-env form -e KEY rather than -e KEY=value — the values never appear on the docker run argv / ps / /proc/<pid>/cmdline / verbose debug logs). New unit test in tests/unit/cli/local-invoke-auto-assume-role.test.ts covers the 3rd-arg roleProperty extension's 'RoleArn' case. Integ fixture tests/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 existing cdkd deploy path for AWS::BedrockAgentCore::Runtime (that uses src/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 LogConfig on AWS::Events::EventBus, which EventBridgeBusProvider previously silent-dropped on write. LogConfig is a nested object { IncludeDetail?: 'NONE' | 'FULL', Level?: 'OFF' | 'ERROR' | 'INFO' | 'TRACE' } that controls EventBridge's per-bus log emission to CloudWatch Logs / S3 / Firehose (separate AWS::Events::LogStream resources route the output). It rides DIRECTLY on CreateEventBus / UpdateEventBus (the single SDK calls the provider already makes) — NO separate control-plane API. create() forwards properties['LogConfig'] to the SDK input when present (omit-when-absent); update() adds it to the existing JSON-stringify diff gate alongside Description / KmsKeyIdentifier / DeadLetterConfig (so a LogConfig-only change triggers a single UpdateEventBus); readCurrentState surfaces it back from DescribeEventBus.LogConfig emit-when-present (NOT the always-emit-placeholder pattern that the sibling DeadLetterConfig uses — AWS only returns LogConfig when 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 !== undefined individually, so partial AWS responses surface only the user-controllable fields. LogConfig moves from silentDrop to handled in property-coverage.generated.ts (regenerated via vp run gen:property-coverage — the raw codegen formatting artifact is normalized by vp check --fix); AWS::Events::EventBus's silentDrop becomes EMPTY (only entry was LogConfig) and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json. New unit tests in eventbridge-bus-provider-roundtrip.test.ts (create forwards LogConfig: { Level: 'INFO', IncludeDetail: 'FULL' } into CreateEventBusCommand; create omits when absent; update emits a single UpdateEventBusCommand on diff; update-no-op produces zero UpdateEventBus calls) and eventbridge-bus-provider-readcurrentstate.test.ts (readback emits LogConfig when AWS returns it; omits when undefined). Real-AWS verified via a NEW tests/integration/eventbridge/verify.sh that deploys the existing EventBridgeStack (now with logConfig: { level: events.Level.INFO, includeDetail: events.IncludeDetail.FULL } on the L2 events.EventBus), asserts via aws events describe-event-bus that both sub-fields reached AWS, then destroys clean.

  • NestedStackProvider.delete marks its two UNHEALABLE deterministic refusals non-retryable, so a user-controlled child stack name can no longer make them look transient (issue #1849, the src/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 no markRetryable to assert the healable reading with, and marking it would make the healable case terminal for everyone, so the residual is accepted rather than fixed. nestedStackChildFailureMessage interpolates <parent>~<childLogicalId> into its text and retryable-errors.ts classifies by SUBSTRING, so a nested stack named e.g. MyDependencyViolationSub matched DependencyViolation — the only whitespace-free entry in RETRYABLE_ERROR_MESSAGE_PATTERNS. The fix is markNonRetryable at the THROW SITE (not in the constructor and not in nested-stack-messages.ts, which is required to stay import-free), applied per arm rather than to the class: the errorCount > 0 throw 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, because draining in destroy-runner.ts is 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~MyDependencyViolationSub classifies retryable there while Parent~PlainSub does not: that arm keeps the name-dependence, which is the residual described above rather than a second fix. The provider's other deterministic refusal — the withNestedStackContext wiring check — is marked too, since AsyncLocalStorage is read inside the same async context on every attempt. Both marks are LATENT today (disableOuterRetry = true makes destroy-runner.ts compute maxAttempts = 0, short-circuits DeployEngine.withRetry, and rollback-executor.ts does 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 in tests/unit/provisioning/nested-stack-provider.test.ts pin 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 four CloudControlProvider.enrichResourceAttributes sites (KMS Key Arn, ECR Repository Arn, ECR RepositoryUri, Kinesis Stream Arn), the five src/utils/s3-endpoints.ts helpers, the synthetic AWS::StackId, and — widest — ONE fold at IntrinsicFunctionResolver.constructAttribute's destructure, which feeds every ARN / URI the resolver builds (DynamoDB, SNS, Logs, ECR, ECS, ...). All of them interpolated the region VERBATIM, so cdkd deploy --region US-EAST-1 recorded arn:aws:appsync:US-EAST-1:... as the resource's Fn::GetAtt answer. 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 into state.json, so it outlives the deploy and is what every later Fn::GetAtt / cdkd drift reads. Each site folds through canonicalizeRegion (src/utils/aws-partition.ts), mirroring the fix issue #1824 applied to SSMParameterProvider. The PARTITION segment was already correct — derivePartitionAndUrlSuffix canonicalizes 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 INSIDE s3-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 the Fn::GetAtt answer disagree with readCurrentState, the exact phantom drift the module exists to prevent (issue #1745). For WebsiteURL it 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. The RepositoryUri host matters for its own reason — the recorded URI is handed to docker and parsed back by parseEcrRegistryHost (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 --region keeps its old value in state (constructAttribute runs 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 from client.config.region() instead of accountInfo.region (issue #1881), and the AWS::Region pseudo-parameter still returned the raw spelling so a user's own Fn::Sub ARN 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 --region is 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 with SignatureDoesNotMatch: 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's cdkd-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 explicit overrideRegion, a library caller building AwsClients directly — 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), a cn-north-1 row per file so the partition and the region segment are proven to land together, one fully literal arn:aws-cn: expectation so a wrong-but-consistent partition cannot move with the bug, and the WebsiteURL legacy-dash branch flip in both directions.

  • cdkd export backfills a pre-#1761 AWS::EC2::SecurityGroupIngress rule 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 has attributes: {}, and export is all-or-nothing, so ONE such row made a whole stack un-exportable with no workaround (AWS returns the sgr-… id only from AuthorizeSecurityGroupIngress, so a no-op re-deploy heals nothing). CompositePhysicalIdIdentifier gains an optional async backfill reached ONLY after the state-only resolve threw — so a row that already records the Id issues no AWS call — and the AWS::EC2::SecurityGroupIngress entry supplies one: a paginated DescribeSecurityGroupRules filtered by the composite's groupId, matched on the composite's (ipProtocol, fromPort, toPort) tuple with the protocol canonicalized on BOTH sides (the #1643 fold, so a record packed from IpProtocol: 6 matches AWS's tcp) 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 an sgr-… 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 before Number('') can read it as port 0 and look up a real tuple (EC2Provider.cfnIngressPortValue reads the same blank as -1, so the two layers did not agree on what it meant); the lookup is wrapped in the standard throttle-only withRetry, so one RequestLimitExceeded no longer aborts an all-or-nothing export and a persistent throttle is reported as a throttle rather than as a missing ec2:DescribeSecurityGroupRules; the walk is memoized per GROUP for the lifetime of ONE buildImportPlan call (never module-global, which would be shared across the stacks a concurrent run exports), caching the OUTCOME rather than an Error so each row's refusal still names ITSELF; and the ambiguity refusal names the SECOND cause too — two DISTINCT AWS::EC2::SecurityGroupIngress resources differing only by source have byte-identical composite ids, so "split it per source" is not their remedy (repair attributes.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-create SetUserPoolMfaConfig call, and because that API is a full-replace — an omitted MfaConfiguration resets the pool to OFF and drops the factor blocks being enabled in the same call — cdkd defaulted the field to OPTIONAL whenever 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 (passkeyRelyingPartyId with no mfa, whose CloudFormation default for MfaConfiguration is OFF) had AWS reject the call with Invalid 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, so cdkd deploy failed on a template the CDK CLI deploys fine. Fix: when the template omits MfaConfiguration, default to OPTIONAL only if the call enables a factor, else to OFF — CloudFormation's own default. An explicit template value still wins in every case. The factor test is deliberately TWO-part (EnabledMfas non-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 sent OFF, 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 !Ref to a String parameter fails Array.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) — in hasMfaConfigProps too, 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 template MfaConfiguration: OFF really 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 existing OPTIONAL default: EmailMfaConfiguration is emitted for a bare EmailAuthenticationMessage / Subject customization too, and whether AWS accepts that block under OFF is 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 through applyMfaConfig). 23 new unit tests pin both polarities of the default (WebAuthn-only and an empty EnabledMfas -> OFF, on create AND update; a real factor, alone or alongside WebAuthn, and the SMS arm -> OPTIONAL), both polarities of the override (an explicit OFF beats the OPTIONAL default, an explicit ON beats the OFF one), 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 off CreateUserPool), null / '' as absence rather than intent, and both polarities of the warn; the WebAuthn-only test now pins the WHOLE request via toEqual, since the original blind spot was precisely a field nobody asserted. The cognito integ fixture gained both arms: a PasskeyOnlyUserPool (WebAuthn, no MfaConfiguration) asserting OFF with its WebAuthn config intact, and a FactorDefaultUserPool (a factor, no MfaConfiguration) asserting OPTIONAL — the inverse regression, where the fix would silently disable MFA on a pool that asked for it. BackfillUserPool moved from OPTIONAL to an explicit ON and its assertion was pinned exactly, because OPTIONAL is 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): delete gained a skip channel in #1752; update had 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 inside update() 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 a logger.warn and 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 throws ResourceInUseException and lands in exactly that swallowing catch. Fix: ResourceUpdateResult is now a base intersected with a discriminated outcome union — omitted / 'updated', or 'partial' with a REQUIRED reason, mirroring ResourceDeleteResult so 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 emitting RESOURCE_SKIPPED for the row, which would have put the events store at odds with its own contract. The engine instead emits RESOURCE_SUCCEEDED for the updated row AND a RESOURCE_SKIPPED naming 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 follows delete-outcome.ts exactly: a leaf update-outcome.ts (no imports beyond the type, because the engine, drift --revert and the rollback executor all consume it and already sit on a dense import ring), a counter kept apart from both updated and deleteSkipped, a partial (<reason>) status line at warn level, a summary row shown only when non-zero, and the shared run-level skipped counter cdkd events renders as ⚠N. All FOUR update() call sites consume it — the deploy engine, drift --revert, and BOTH rollback-executor arms (replayRollback and the --revert-failed replayFailedOperations, the latter found only in review) — so no entry point silently drops what another reports. sns-subscription is 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.

Last updated: