cdkd changelog (extracted from CLAUDE.md)
Detailed per-PR notes split out from the project's main CLAUDE.md so that file fits within Claude Code's recommended ≤200-line CLAUDE.md 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.
The CLAUDE.md ## Known Limitations section retains the load-bearing summary
("NOT recommended for production use"); the per-PR detail moved here.
Recently Implemented (2026-09-03):
cdkd synth's YAML now parses, and parses back to the template -- the hand-rolled emitter is replaced by theyamlpackage (issue #2421) --src/utils/yaml.ts,src/cli/commands/{synth,list}.ts,docs/cli-reference.md, plustests/unit/utils/yaml.test.ts(rewritten around a round-trip table) and new / re-baselined cases intests/unit/cli/{synth-stdout-stream,list,list-json-stream}.test.ts.toYamldecided quoting with a predicate covering four cases -- an embedded newline, a leading{/[/", and the literals containing#/ empty /true/false/null-- so every other YAML indicator went out bare.AllowedOrigins: ['*']therefore rendered as- *, an alias node, and a parser rejected the document: measured ontests/integration/basic, the repo's MINIMAL fixture (one S3 bucket, no IAM policy involved),node dist/cli.js synth | yaml.parsefailed withYAMLParseError: Alias cannot be an empty string ... code: BAD_ALIAS. Issue #2410 had made the STREAM clean; the DOCUMENT on it was still invalid, which is whycdkd synth | yqdid not work after it. The fix is a change of INSTRUMENT, not a wider predicate: correct YAML quoting has an unbounded number of spellings to get wrong one at a time (the "spelling treadmill"), theyamlpackage was already a runtime dependency, and the AWS CDK CLI does this exact job withyaml.stringify(obj, { schema: 'yaml-1.1' })at fold width 0 (@aws-cdk/toolkit-lib/lib/util/yaml-cfn.ts), so "CDK CLI compatible output" is now PRODUCED by the library CDK CLI uses rather than approximated.aliasDuplicateObjects: falseis passed for the reasonsrc/cli/yaml-cfn.tsalready passes it -- CFn does not understand YAML anchors, and a template holding one object twice by identity would otherwise emit the very alias syntax this issue is about, from the other side. Neither schema resolves a superset of the other -- 1.1 addsyes/no/on/offand timestamps, 1.2 core adds0ooctal -- which is why the emitter ends up asking BOTH rather than picking the wider one, and why the paragraph below exists. Measured blast radius, whole-file diff ontests/integration/basic: of 182 lines, only the defect and its mirror move.- *becomes- "*"; numbers stop being emitted as strings (ExpirationInDays: 90, not"90"); numeric STRINGS start being quoted (schemaVersion: "2.2", the bootstrap-version list- "1"); and the document no longer opens with a blank line. Indentation, key order and sequence style are byte-identical. The type-fidelity pair is the same root cause in both directions -- the old emitter rendered every number as"N"and every all-digit string bare, so a template'sMaxAge: 3600came back a string while its IAMVersion: '2012-10-17'came back a Date -- and the visible consequence forcdkd list --longis that an account id is nowaccount: "123456789012", agreeing with what--jsonalways returned. The leading-newline question the issue carried as its third item is decided here too, INSIDE the renderer rather than per consumer:toYamlreturns a document starting at column 0, solist.ts's.replace(/^\n/, '')is gone andsynthno longer opens with a blank line -- one serializer, one contract. The 3-axis review round then found the delegation was not sufficient on its own, and the fix for that is the shape of the whole change in miniature. Emitting under 1.1 leaves two classes un-quoted: YAML 1.1 is not a SUPERSET of 1.2 core, so'0o17'-- 1.2 added0ooctal and 1.1 has no resolver for it -- goes out bare and the DEFAULT reader hands back the number 15; and'<<'as a map KEY emits<<: v, which a 1.1 reader resolves as the MERGE key and rejects withMerge sources must be maps. Both were found by review, neither by enumeration. The remedy is deliberately NOT two more entries on a must-quote list -- that is the same artifact with the same failure mode -- but a ROUND-TRIP ORACLE:toYamlbuilds aDocument, and for every string scalar asks the library whether the plain rendering survives a parse under BOTH readers (yaml-1.1andcore), forcingQUOTE_DOUBLEwhere it does not. Two oracles, because POSITION decides the question --<<is special as a key and ordinary as a value -- and memoized, since templates repeat their scalars. Review round 2 then found the<<half of that fix was INERT, and it is the sharpest lesson in the PR. The oracle correctly returnedfalsefor the key and the visitor correctly setQUOTE_DOUBLE-- and the assignment did nothing, because under theyaml-1.1emit schema the library's MERGE tag owns<<and itsstringifyreturns the literal<<while ignoring the node's style. So{'<<': 'v'}still emitted<<: v, which a 1.1 reader rejects. The suite could not see it either: the four-position round-trip loop parses under 1.2, where<<:is an ordinary key, and the 1.1 arm covered only the map-VALUE position -- the one position that differs was the one position not asserted, under the one reader that resolves it. Both were fixed: the emitter now emits undercore(no merge tag, so the forced quotes survive and the output is"<<": v), and the 1.1 arm runs all four positions, which reds on the old schema. Parity is preserved in fact rather than by the option name, and measured rather than argued: with the oracle in place BOTH of this repo's real templates render byte-identically under the two schemas, and every probe agrees except<<itself -- what changes is only which layer does the quoting (undercorethe library emitsyesbare and the oracle quotes it; under 1.1 the library quoted it directly). The live arm now parses under both readers too, since a default-reader-only arm could not have caught this. Review round 3 then found the schema swap had a side effect the byte-identical measurement could not see, because that measurement compared STDOUT only. The oracle PARSES, and a parse can WARN as well as throw: undercorea date-shaped map KEY emits bare, so probing it under the 1.1 reader resolves aDateandyamlcallsprocess.emitWarning-- 254 bytes on real stderr, during a GREEN unit run (against this repo's "a green run must print nothing" rule) and fromcdkd synthfor any template carrying such a key. The probe reads are nowlogLevel: 'silent'; the oracle only ever reads the parsed value and has no use for the diagnostics. The same round independently falsified a UNIVERSAL claim the previous one had written -- "the schema differs and the output does not" -- by finding the one class it does change: JS types 1.1 has exclusive tags for (Date,Map,Set,Uint8Array). That is a style change rather than a regression (all four rendered{}under the pre-PR emitter,core'sDateoutput is the safer of the two, and neither consumer can produce one), but the sentence was wrong as written and is now scoped. A 2,454-case sweep for a SECOND<<-shaped hole -- a tag whosestringifydiscards a forced style -- found none undercore, and exactly<<under 1.1. The per-branch probe table is recorded in the source rather than left implied: the KEY oracle'scatchis load-bearing (it is the path<<takes; flipping it reds), the VALUE oracle's identical-lookingcatchis defensive and unreachable (0 hits over 461 strings, and 0 again over 91,033 afterlogLevel: 'silent'narrowed what can reach it), and thekey === 'key'visitor guard reds nothing -- each said so in a comment instead of a reader having to assume.
The same round found the option probes were not all live. Removing lineWidth: 0 reddened NOTHING, because the fixture's long value was a single 200-character token and yaml folds only at whitespace -- so the option was documented, believed, and unfenced; the fixture now carries spaces and the probe reds. PLAIN_SCALARS had the mirror problem: its round-trip assertions are vacuous for OVER-quoting (a quoted scalar round-trips), and the one live assertion covered the TOP-LEVEL position only, so an emitter quoting every sequence item passed 0 red; it now asserts all four positions. The anchor test's /[&*]a\d/ was bound to the library's default anchorPrefix and went green under anchorPrefix: 'x', so it asserts the characters themselves. And the live arm's own glob guard was DEAD CODE -- under set -euo pipefail a glob miss killed the script at the assignment while 2>/dev/null ate the diagnostic, the opposite of what the guard was added for (probed both ways: rc=1 with no message before, rc=3 with the message after).
The fence is a ROUND-TRIP table, not a list of expected literals, because a literal table can only pin the spellings somebody thought of, which is the failure mode that produced the bug: 50 hostile scalars (one per hazard class -- every leading indicator, : and # in-value, leading / trailing whitespace, the implicit-null / boolean / number / date resolutions, multi-line content) plus 7 that must stay unquoted, each asserted at all four positions the old emitter reached by different code paths -- top-level scalar, map VALUE, sequence ITEM and map KEY, the last having had its own separate and also-incomplete rule (key.includes(' ')). Asserted under the default 1.2 reader AND under a 1.1 one, since 1.2 does not resolve yes / on / 2026-09-03 implicitly and would leave them unfenced. Mutation-probed: with src/utils/yaml.ts restored from origin/main, 50 of the 70 cases go red at an unchanged test count (so no load error is being read as discrimination); restored byte-exact from a copy, 70/70 green. Re-measured on the SHIPPING tree rather than carried forward -- the pre-review table was 48 of 67, and both numbers moved when the review round added cases. A command-level case was added to synth-stdout-stream.test.ts as well -- that suite already parsed stdout and still could not see this defect, because its fixture holds no indicator character and no number, a green parse over a fixture that cannot fail. Live-tested end to end on tests/integration/basic after the fix: cdkd synth stdout parses under both readers AND is deep-equal to the per-stack template JSON in cdk.out, and cdkd list --long parses and starts at column 0. A live arm was added to tests/integration/local-invoke/verify.sh, which already synthesizes and until now DECLINED to assert that the template parses, naming this issue as the reason. It now parses cdkd synth stdout and asserts DEEP EQUALITY against the assembly's own cdk.out/*.template.json -- parsing alone is the weaker half, satisfied by output whose scalars changed type on the way out. Probed in both directions with the binary rebuilt from origin/main's emitter: rc=1 before, rc=0 after. The pre-fix failure there is NOT the one the issue was filed for, which is the finding: YAMLParseError: Nested mappings are not allowed in compact mappings (BLOCK_AS_IMPLICIT_KEY) at the InlineHandler Code.ZipFile line, whose body ({ inlineEcho: event }) carries a colon-space that the old predicate never quoted -- reached with no wildcard anywhere in the fixture. src/cli/yaml-cfn.ts is untouched and remains a different job -- it emits CFn intrinsics as shorthand tags (!Ref) for cdkd export / import, while cdk synth and this renderer print the long form.
- The cdkd.dev documentation site ships (this PR) --
vite.docs.config.ts(Ox Content SSG overdocs/— hand-authorednavigationgroups so the site's IA is independent of the flat file layout;generateOgImage+docs-site/og-template.tsrenders a 1200×630 OG image per page;siteMapsemits sitemap.xml / robots.txt / llms.txt;markdownSourcepublishes a raw-Markdown companion per page for AI agents),docs-site/(brand SVGs, OG template, noop client entry),.github/workflows/docs-deploy.yml(build on PRs, build+deploy to GitHub Pages on main),vp run docs:dev|build|previewtasks, eleven new user-facing pages underdocs/(index / introduction / getting-started / ai-agents / concepts / wait-modes / ci-per-pr / rollback / drift / export / mixed-estates — content lifted from the README, which a follow-up PR slims down to point at the site), frontmatter (title/description) on the existing user-facing docs pages, andunlisted: trueon internal ones (design docs, plans, this changelog). The client build gets an emptydocs-site/noop-entry.tsentry because Ox Content emits every page incloseBundleand Vite refuses to build the client environment without an entry module. cdkd events prunerefuses a non-interactive stdin with exit 1 like the other nine, and its helper — the last copy — is folded away (issue #2454) --src/cli/commands/events.ts,tests/unit/cli/{non-interactive-confirm-guards,readline-prompt-population}.test.ts,docs/cli-reference.md. It was the TENTH mutating confirmation prompt and the one issue #2275 left alone, because it was already guarded and so never hung. What it did instead was refuse by logging a line and RETURNING, i.e. exit 0 with noNON_INTERACTIVE_CONFIRMcode -- so a CI job branching on exit 1 could not see it, and could not tell "cdkd refused" from "cdkd pruned nothing". The guard also sat at the CALLER rather than in a helper, which is whyevents.tsstill carried its own byte-identical copy of the prompt helper: the tenth, and the last. It now delegates toconfirmOrRefuselike the nine, which fixes both at once -- the exit-code contract becomes uniform and the population fence drops from 11 sites across 10 files to 10 across 9. BREAKING for this command only: a non-interactivecdkd events prunewithout--yesused to exit 0 and now exits 1; nothing was pruned in either case, so the remedy is-y/--yes. Deferred from the go-to-k/cdkd#2275 lane on the ground that it must not share that PR -- a reason the retro's promotion check found EXPIRED the moment that PR merged, which is what brought it back into the same session.- A custom-resource handler can now keep its generated secret OUT of
state.json, by declaring the responseNoEcho: true(issue #2274) --src/deployment/secret-redaction.ts(the MASK-ONLY needle class:recordMaskOnlyValue/recordMaskOnlyValuesIn/carriesSecretMask/wholeStringLeavesOf/substringNeedlesOf/recordedExpressionsOf, plus the in-run recovery storerecordRecoverableMaskedOutput/recoverMaskedOutput/clearRecoverableMaskedOutputs),src/types/resource.ts(newNoEchoAttributesResult, mixed into both provider result types),src/provisioning/providers/custom-resource-provider.ts(carryNoEchoacross the simple-handler envelope synthesis; relay it on create / update),src/deployment/deploy-engine.ts(noEchoAttributeResources,registerNoEchoAttributes,refuseRedactedAttributeReads),src/deployment/intrinsic-function-resolver.ts(noteAttributeSecrecyon both attribute reads, the cross-stack twin insidereresolveCrossStackValue, and the two newResolverContextfields),src/cli/commands/drift.ts(a masked BASELINE is secret-bearing;preserveLiveValuesAtMaskedLeavesplus the--revertrefusal),src/deployment/rollback-executor.ts(refuseMaskedReplayBaselineon the three written-side replay arms),src/cli/commands/export.ts(a record whose properties hold the mask joins the per-resourceblockedlist),src/provisioning/providers/nested-stack-provider.ts(recover a child output THIS RUN masked, and name it per attribute),docs/state-management.md,docs/provider-development.md,docs/cross-stack-references.md,docs/cli-reference.md, plustests/unit/deployment/secret-redaction-mask-only.test.ts,tests/unit/deployment/deploy-engine-noecho-custom-resource.test.ts,tests/unit/deployment/rollback-executor-masked-baseline.test.ts,tests/unit/provisioning/custom-resource-noecho.test.ts(all new), new blocks intests/unit/cli/drift-secret-redaction.test.tsandtests/unit/deployment/intrinsic-import-value-index.test.ts, and a newNoEchoarm intests/integration/custom-resource-getatt-data/. THE PROBLEM. A Lambda-backed custom resource'sDatais persisted verbatim into the resource'sattributesand into the resolvedpropertiesof everything that consumed it, so a handler that MINTS a value (a generated password, an issued API key) put that value instate.jsonin the clear. The response envelope has carriedNoEchoall along -- cdkd declared the field and read it NOWHERE. WHY NOT MASK AT CAPTURE, which is the obvious fix and is wrong: it was measured against real CloudFormation (a probe stack in us-east-1, on the issue thread) that CFn delivers aNoEchocustom resource'sDatato a dependent resource as PLAINTEXT -- the AWS docs' "masked with asterisks" sentence describes the display channel, the way aNoEchotemplate PARAMETER behaves. So masking at resolution would make a template feeding that value intoAWS::SecretsManager::Secret.SecretStringstore the literal***AS the secret, which is worse than the leak.Fn::GetAtttherefore keeps resolving to the real value and only the PERSIST path changes. THE MECHANISM. A third needle class beside the expression-bearing ones: the plaintext is recorded asplaintext -> SECRET_MASKin the sameRecordedSecretValuesbag, so every existing persistence reader (scrubResourceRecord's three fields, the rollback journal, the outputs bag,maskSecretsInText) is covered by code that already walks it, with the SENTINEL VALUE as the marker -- there is no side table (an earlier revision'sWeakMapwas removed after a mutation probe showed its extra conjunct unfenceable and wrong-pointing). A mask-only needle must clearMIN_NEEDLE_LENGTH, unlike an expression-bearing one: an expression pair came from a POSITION cdkd resolved, while a bare plaintext has none, so a handler answeringData: { Ready: "true" }would otherwise mask every leaf whose whole value is"true". For the same reason the registration EXCLUDES whole string leaves of the resource's own resolved template properties -- a handler echoingevent.ResourcePropertiesintoDatawould otherwise mask the resource's ownServiceTokenin the recordCustomResourceProvider.deletereads it back from. Scope is the pass's own bag -- the same answer PR #2415 was forced to after review found a process-wide positive store is itself a cross-stack disclosure (residual #2425). Registration happens in the CONSUMER's resolution pass, becauseperResourceSecretsis keyed by LOGICAL ID: a needle recorded under the custom resource's own id is not in the bag its DEPENDENT's record is scrubbed with, so masking only the producer would leave the SSM parameter that consumed it holding the plaintext -- a line that cannot be explained to someone who setNoEcho. WHOLE-LEAF ONLY, and that bound is the design. The mask class is withheld from the substring arm, mirroring the restricted blast radius PR #2415 established for itsinferredneedles. An EXPRESSION substituted into a longer leaf is lossless; a MASK is not -- an inline***is indistinguishable from a literal***a user wrote, so nothing downstream could recognise it anddrift --revert/resolveReplayPropswould push the corrupted string to AWS.maskSecretsInTextis deliberately NOT narrowed the same way: its output is a log line or an event, which nothing reads back as a value, so a partial mask there is free. The residual (an embedded value keeps its plaintext in state) is filed as #2453. THE COST IS GUARDED RATHER THAN HIDDEN, and this is the half the issue thread's own design had not accounted for. Once the mask is in state,Fn::GetAtton a LATER deploy that does not re-invoke the handler reads back***, and a dependent that has to be written would push it to AWS -- the #1498 / #1501 data-corruption class. So the resolver RECORDS every read that served a mask (ResolverContext.redactedAttributeReads, written forFn::GetAttand, throughreresolveCrossStackValue, forFn::ImportValue/Fn::GetStackOutput/ a nested stack'sOutputs.<Key>) and the deploy engine REFUSES to provision a resource whose resolution recorded one, naming the read and the remedy. It records rather than throws on purpose: the DIFF pass resolves the same leaf, and a throw there would make every later deploy of such a stack fail, where recording leaves***compared against***-- a clean NO_CHANGE -- so an untouched stack still deploys and only a resource that genuinely has to be written is refused. The same reasoning givescdkd driftits three arms (mask the live value in the report, refuse--accept, preserve the live value on--revertand refuse the resource when AWS has nothing to preserve) and the rollback replay its refusal on the written side only. THE CROSS-STACK REGRESSION, and why the refusal alone was not shippable. Every cross-stack route reads the PRODUCER's persistedstate.outputs-- a nested stack'sOutputs.<Key>(viaNestedStackProvider),Fn::ImportValue(index or state scan) andFn::GetStackOutput-- so masking a child's output made the FIRST deploy of a parent refuse a template that deployed before this change. Where the producer was deployed by THIS process in THIS run the plaintext is still in memory, and it is now threaded rather than refused:DeployEngineremembersstack + region + output key -> plaintextfor any output its redaction just masked, and the three read sites recover from that coordinate and re-register the value as a MASK-ONLY needle in the CONSUMER's own bag, so the wire value is correct while the consumer's record still persists***. The key is a COORDINATE, never a bare plaintext, for the reason PR #2415 had to withdrawprovenPublicExpressions(residual #2425): keyed on a value alone, one stack's answer is served to another stack's identically spelled read.NestedStackProviderreports the recovered outputs PER ATTRIBUTE (ResourceCreateResult.noEchoAttributeNames), never as the whole bag -- a child typically exports one sensitive output among several ordinary ones, and a whole-bag declaration would mask every unrelated output into the parent's record and into any parent resource that reads one. WHAT STAYS REFUSED, and the messages say so accurately. A producer deployed by an EARLIER run has no plaintext anywhere cdkd can read, so the consumer is refused. "Re-deploy the producer stack first" would not help -- the producer re-masks on the way into its own state -- so the deploy refusal now names the two remedies that work (force the custom resource to update so its handler runs again IN THIS RUN, or stop settingNoEcho) and states that a cross-stack value needs producer and consumer in ONE run. Thecdkd exportblocker says the reverse for the same reason: forcing an update does NOT clear it, because export reads state and state is where the mask lives. Andcdkd driftreports a permanent, unresolvable comparison at such a leaf (both sides render***), documented indocs/cli-reference.mdrather than silently dropped. Both real-AWS runs are on record indocs/_generated/integ-last-run.tsv:custom-resource-getatt-data(2026-09-02T17:40:08Z, PASS, 150s), which carries the newNoEchoarm, andlambda(2026-09-02T17:40:08Z, PASS, 190s) for theinteg-broadgate thatdeploy-engine.tsandintrinsic-function-resolver.tsactivate -- 10 and 9 resources deleted, 0 errors, 0 orphans on either. TheNoEchoarm asserted BOTH directions against real AWS in one run: the dependent SSM parameter held the real token on AWS while the custom resource'sattributes, the dependent'sproperties,state.outputsand the whole state blob held none of it, and the echoedServiceTokensurvived as an addressable Lambda ARN. The existing fixture asserts the OPPOSITE and keeps doing so:custom-resource-getatt-datarequires the non-NoEchocustom resource's value in CLEARTEXT on AWS and instate.outputs, which is the negative case. The new arm adds a second custom resource returningNoEcho: truethrough the SIMPLE-HANDLER shape (noStatusfield) -- the delivery path whose envelope synthesis DROPPED the flag, so the arm would have been inert without that half of the fix -- and asserts both directions at once: the dependent SSM parameter holds the real token on AWS, while the custom resource'sattributes, the dependent'sproperties,state.outputsand the whole state blob hold none of it. Deliberately NOT included: persisting the flag onResourceState, which is a v9 -> v10 schema bump with its own gate and migration integ -- #2449, whose body's stated consequence this change corrects (nothing re-leaks; a specific deploy shape is REFUSED instead). - The nine remaining confirmation prompts now REFUSE a non-interactive stdin instead of hanging forever, and all nine share ONE guarded helper (issue #2275) --
src/cli/commands/confirm-prompt.ts(the shared helper),src/cli/commands/{rollback,state,orphan,import,export,drift,retire-cfn-stack,state-migrate}.ts(folded),docs/cli-reference.md,.claude/rules/cli-internals.md, plustests/unit/cli/{non-interactive-confirm-guards,readline-prompt-population}.test.ts(new), a rewrittentests/unit/cli/commands/confirm-prompt.test.ts, and new cases intests/unit/cli/{state-orphan,orphan,import,state-migrate,retire-cfn-stack,export-nested-loop,drift-json-stream,state-refresh-observed}.test.ts.rl.questionnever settles once stdin is at EOF, and EOF delivers no signal, so a command that prompted without a non-TTY guard parked FOREVER in CI rather than failing -- burning the job's whole timeout budget. Issue #2259 closed that for the destroy prompts; these nine were the rest of the class:cdkd rollback,cdkd state orphan,cdkd state refresh-observed,cdkd orphan,cdkd import,cdkd export(three call sites through one helper),cdkd drift --accept/--revert(two), the CloudFormation stack retirement reached fromcdkd import --migrate-from-cloudformation/cdkd migrate --retire-cfn-stack, andcdkd state migrate. Each now throwsCdkdErrorwith the codeNON_INTERACTIVE_CONFIRMand exits 1, with a message naming the command AND the flag that avoids the prompt (--forceonrollback;-y/--yeseverywhere, plus-f/--forceon the two orphan commands). REFUSE, never auto-confirm, uniformly: the issue body worried about "deciding refuse-vs-auto-confirm for the read-only commands", and there are none -- every one of the nine guards a MUTATION (a rollback replay, a state-record removal, an observed-property refresh, an orphan, an import, an export-then-delete-state, a drift accept/revert, a stack retirement, a state-bucket migration).deploy.ts's asset-storage auto-create stays the one deliberate exception. The DUPLICATION was the root cause, so the fix removes it rather than adding a tenth copy of the guard: six of the nine helpers were byte-identical,drift's differed only by taking an output stream, androllback/state orphandiffered only in prompt suffix; issue #2259 guarded ONE copy and nine survived. All nine now callconfirmOrRefuse(prompt, { refusal, suffix?, output? }), which carries the guard BEFOREreadline.createInterface. Its position is correct for free -- every call site sits inside its command's own--yes/--forceshort-circuit, so a flagged run never consults stdin at all. No user-visible output changed: each site keeps its exact prompt string (thesuffixparameter preserves(y/N):onrollback/state orphanagainst[y/N]on the other seven) and supplies its own refusal message; the two answer-parsing spellings (/^y(es)?$/ion seven sites,t === 'y' || t === 'yes'on two) were equivalent and converged on the regex.promptYesNostays in the same module as the deliberate default-YES, UNGUARDED carve-out -- its only caller short-circuits on a non-TTY before reaching it.destroy-runner.tsandstate.ts'sstate destroy --allprompt deliberately keep their own inline guards (a default-YES/default-NO pair, and an abortsignalfor the #2117 Ctrl-C handling; foldingdestroy-runner.tswould also drag a pure refactor into theinteg-destroy/integ-broadgate scopes).tests/unit/cli/readline-prompt-population.test.tsis what stops copy #10: everyreadline.createInterfaceinsrc/must be listed there with a reason, counts included, so a second prompt added inside an already-listed file is visible too -- the exact blind spot that letstate.tshold one guarded and two unguarded interfaces at once. Measured live against the built CLI:cdkd state orphan <stack> < /dev/nullexited 1 in 6 s naming-y / --yesand-f / --forcewith the state record untouched (pre-fix: hangs indefinitely), while--forcestill bypassed the prompt and removed the record. A four-reviewer round (spec / code / test / security) then found one real resource leak and three coverage gaps, all fixed here. The LEAK:retire-cfn-stack.ts's confirmation gate drains its transientcdkd-migrate-tmp/child-template uploads in the DECLINE branch, and the new guard THROWS past it -- past the recursive walk'scatch(already left) and past the post-UpdateStackfinally(still below) -- so a non-TTYcdkd import --migrate-from-cloudformationon a nested stack whose children exceed the 51,200-byte inline limit left those bodies in the state bucket with no command that ever reaps them. Fixed by hanging ONE drain off afinallykeyed on whether the operator proceeded, so the decline and the refusal share it rather than the refusal getting a second copy of the loop -- and the four drain sites in that file (recursivecatch, the gate, the no-modification fast path, the post-UpdateStackfinally) now share onedrainTemplateUploadshelper, since four copies of a loop is how the gate's exit came to have none. Its refusal message also gained the two things it lacked: it is the only site reachable from TWO commands, so it now NAMES both (cdkd import --migrate-from-cloudformationandcdkd migrate --retire-cfn-stack), and it is the only one of the nine that fires AFTER its command's state write, so it says cdkd state has already been written -- a refusal there leaves cdkd claiming the resources while the CloudFormation stack is still live.docs/cli-reference.md's claim that "nothing is written, deleted or locked on the refusing path" was measured and WRONG on both counts: a lock IS held at the prompt on four commands (orphan.ts,import.ts,export.ts,rollback.ts-- every one releasing in afinally, so nothing leaks), and the retirement writes before its prompt; the corrected text states the guarantee that IS true, that no partial mutation survives the refusal.state.ts's two prompts now route theirtargetListthroughdisplaySafe(..., { asciiOnly: true }), matching what the lock error sixty lines above already does to the SAME S3-key-segment values (issue #2170 round 4) -- a forged line inside a CONFIRMATION is worse than one inside an error, since it is the sentence the operator answersyto. Coverage:rollback.ts's prompt had NO end-to-end routing case at all (both rollback suites hardcodeforce: true, so the guard could have been deleted and both stayed green),drift.ts's--revertcall site had none where--acceptdid, andexport.ts's two in-exportCommandprompts were reported unclosable becauseexportCommandis not exported --createExportCommand()is, so a newtests/unit/cli/export-non-interactive-confirm.test.tsdrives both through argv. The population fence's regex was widened from an assignment-shaped pattern to a call-shaped one with a comment-line exclusion, catching five more spellings a probe confirmed the old one missed (rl = ...re-assignment,return ...,this.rl = ...,readline.promises.createInterface(, a ternary arm and an object-literal value) at identical counts (19 pre-fix / 11 post-fix), and its comment now states the one hole it genuinely has -- an ALIASED import puts no occurrence ofcreateInterfaceat the call site and evades any needle spelled on that name. ThesetStdinIsTtystub that had been copy-pasted into ELEVEN suites is now one sharedtests/stdin-tty.ts, whose restore DELETES the property rather than definingundefinedonto it (measured:'isTTY' in process.stdinisfalsewith stdin at/dev/null, so writingundefinedback CREATED an own property that was genuinely absent). Deliberately NOT changed:cdkd events pruneis a TENTH mutating prompt and already carries its own non-TTY guard, so it does not hang -- but it refuses with alogger.infoand exit 0, which a CI job branching on exit 1 cannot see. Aligning it is a breaking exit-code change on a command neither issue covers, so the docs now name it as the one exception and state its actual behaviour, and the alignment is filed as #2454. cdkd state listreserves stdout for its payload UNCONDITIONALLY (issue #2435) --src/cli/commands/state.ts,docs/cli-reference.md,.claude/rules/cli-internals.md, plus new flagless cases intests/unit/cli/state-json-stream.test.ts.state.tscallsreserveStdoutForPayload()four times and every one sat underif (options.json), which is why issue #2410'sgit grepread the file as covered and its body excluded this site.stateListCommand's DEFAULT mode writes oneStack (region)reference per line -- the same record-set shape ascdkd list's default mode, which #2410 had just made unconditional -- so on a flagless run the logger's prose shared the stream with the references and awhile read -r refconsumer read log lines as stack names. Measured on the pre-fix build:cdkd state list --state-bucket <nonexistent> --verboseput 1,094 bytes of DEBUG stack trace on stdout; after the fix the same command puts 0 bytes there and 1,357 on stderr, and a--verboserun against a real bucket leaves stdout the 30-byte reference line alone. THE DISCRIMINATOR IS THE OUTPUT'S SHAPE, NOT THE FLAG, and it is written into the code comment rather than only into the issue: a line-oriented RECORD SET is a payload; a formatted human VIEW (aligned columns, a rendered tree, a metadata block) is not. Sostate resources(three columns padded to data-derived widths),state show(renderTreeWithChildren/renderStateBlockblocks) andstate info(a metadata block) keep their--jsongate deliberately, and a case in the suite fences that -- without it, "make everystatereservation unconditional" would have passed.state list --long/--treewithout--jsonARE formatted views and are swept along, because the reservation is taken before the mode is known; that is deliberate and costs nothing (both still write their view to stdout, so only interleaved prose moves to stderr) and the alternative -- a mode-aware condition -- is the flag-shaped gating the issue exists to remove.
Recently Implemented (2026-09-02):
cdkd synth/cdkd list/cdkd local invoke/cdkd local invoke-agentcorenow reserve stdout for their payload UNCONDITIONALLY, closing the flagless half of the issue-#2230 class (issue #2410) --src/cli/commands/{synth,list,local-invoke,local-invoke-agentcore}.ts,src/utils/logger.ts(doc comment only),docs/cli-reference.md,docs/local-emulation.md,tests/integration/local-invoke-{container,layers}/verify.sh(comments only), plustests/unit/cli/{synth-stdout-stream,local-invoke-stdout-stream,local-invoke-agentcore-stdout-stream}.test.ts(new) and new cases intests/unit/cli/list-json-stream.test.ts. Issue #2280 keyed the reservation onoptions.json, but a--jsonflag was never what makes a stream a payload stream -- it picks the ENCODING. These four write a machine-consumable document to stdout with NO flag:synththe CloudFormation template,lista listing in EVERY mode (one display id per line by default, YAML under--long/--show-dependencies, JSON under--json),local invokethe function response,local invoke-agentcorethe agent response across four terminal emitters (emitResult/emitMcpResult/emitA2aResult/emitWsResult, the last writing only the stream terminator) plus the SSE /--wschunk sinks. Each now callsreserveStdoutForPayload()at command ENTRY, before the synth whoseapp-executor.tsre-emits the CDK app's stderr at INFO. The DEFAULT human output contract therefore moves, deliberately and per command: onsynththe accepted consequence is that a MULTI-stack app writes nothing at all to stdout (the template is emitted only for a single stack) and the wholeSynthesis complete!summary block goes to stderr -- stdout onsynthis the template or it is empty, matchingcdk synth. Lines are MOVED, never suppressed. Explicit non-goals, so a reader does not read them as gaps:cdkd deployand all six long-runninglocalservers (start-api,run-task,start-service,start-agentcore,start-alb,start-cloudfront) keep their human stdout (banner, route table, prefixed container logs);diff(demotes towarn),events,driftand the fourstate {list,resources,show,info}payload writes were already correct under--json-- and that qualifier is load-bearing, because all fourstatereservations are--json-gated, socdkd state list's DEFAULT one-ref-per-line mode is an uncovered site of this same root cause, filed as #2435 and taken by the next lane rather than deferred.ConsoleLogger.emit'srunStackBufferedshort-circuit stays a documented latent gap -- none of the four runs work inside a buffer (deploy.ts'srunStackBufferedcall is the only opener; the bare line number this entry used to carry was removed from the source comment in the same commit, for the reason that it goes stale with nothing noticing) -- with its JSDoc updated to say the trigger condition is now "ANY reserving command", not "a--jsoncommand". Live A/B ontests/integration/basic, the repo's minimal fixture (one S3 bucket), running this branch's binary against the same binary rebuilt with all four reservations disabled:cdkd list --long --verboseput 15 prose lines on stdout above the YAML (20 lines / 1678 B) and theyamlparser rejected the capture withImplicit keys need to be on a single line at line 1, while with the reservation stdout is the 5-line / 119 B payload alone and PARSES, the 15 lines / 1559 B having moved to stderr;cdkd synth --verbosewent from 208 lines / 7496 B on stdout with 0 B on stderr to 183 lines of template on stdout with all 25 prose lines / 1 KB on stderr.cdkd list --long --jsonandcdkd list --show-dependencieslikewise parse off stdout after the change. Mutation-probed as one unit, both directions, and the two red sets UNION to the whole suite: across the 28 cases in the four suites, disabling all four reservations reds 24, and the complementary probe -- rewriting everyprocess.stdout.writein the four commands toprocess.stderr.write-- reds 26. The two survivor sets are disjoint by construction (the 4 that survive the first all assert the payload IS on stdout while reserved, which the second reds; the 2 that survive the second assert stdout is EMPTY, which the first reds), so the union is 28 and no case is inert. The per-fix probes: reverting BOTHpromptOnStdoutsites reds 2 of 15 and either site alone reds 1 -- before the review round the same probe read 1 of 11, because only the plain--wssite was driven and the--watchone was reverted-and-green; over-tightening the SIBLING half of that split gate (frameSource = promptOnStdout ? ...) went from 0 of 11, i.e. wholly unfenced, to 2 of 15; and dropping thespawnForegroundfd-1 redirect reds 1 of 8, as does making it unconditional. Three defects in the OUTPUT-ROUTING class surfaced during this work and are filed rather than fixed here, each a distinct root cause (the lane filed two more outside that class -- go-to-k/cdkd#2435 and go-to-k/cdkd#2440):streamLogspipes the CONTAINER's stdout into ours on the twolocalcommands, which the reservation cannot reach (#2419; measured against a realpublic.ecr.aws/lambda/nodejs:20RIE container, whoseSTART/END/REPORTlines AND bothconsole.logandconsole.errorland on container STDOUT while only therapidinternals go to stderr),toYamlleft YAML indicator characters unquoted socdkd synthemitted a bare- *that a parser rejects (#2421) -- so this PR claimed only that nothing but the template reaches stdout, never that it parses; #2421 is FIXED as of the entry above, and the pipe works now because both halves landed -- and, found by the code-review round, cdk-local ships a SECONDConsoleLoggerwith its own module state and no reservation hook (CdkLocalEmbedConfigcarries no logger field), so thesrc/local/*.tsshims re-exporting itsbuildContainerImage/buildAgentCoreCodeImage/downloadAndExtractS3Bundlestill printBuilding container image (platform=...)on stdout for a container-image Lambda (#2429). Because of the two that remain -- the container pipe and cdk-local's logger; the foregrounddocker pullone is closed by this PR, so the lists say TWO and name the closed third so it is not re-filed -- the docs and both command comments state explicitly thatcdkd local invokestdout is NOT yet payload-only and prescribe| tail -1; the unconditional-reservation claim is scoped to cdkd's OWN logger. The 4-reviewer round then found two more stdout writers on those same commands and BOTH are fixed here rather than documented as residuals, because each is a diagnostic stream rather than an ambiguous one:spawnStreaming(src/utils/docker-cmd.ts) mirrors a child process's stdout live wheneverstreamLiveis on (which defaults to--verbose), andsrc/local/ecr-puller.ts'sdocker loginanddocker image inspectboth reach it, socdkd local invoke Stack/ImageFn --no-pull --verbose > out.jsonwrote a multi-hundred-line inspect array -- the image's baked-inConfig.Envincluded -- into the payload; it now JOINS the logger on stderr while a reservation is held, fixed in the shared helper rather than at the two call sites so the nextrunDockerStreamingcaller cannot rediscover it, and byte-identical for any command that reserves nothing. Round 2 then found the SIBLING helper leaking the same class more reachably:spawnForegroundpassesstdio: 'inherit', andecr-puller.tsrunsrunDockerForeground(['pull', ...])UNCONDITIONALLY, socdkd local invoke Stack/ImageFn > out.jsonputdocker pullprogress in the payload with no--verboseand no flag at all; under a reservation its fd 1 is now redirected to fd 2 (a descriptor rather than a pipe, which is what keeps docker's progress bars animating -- though only while stderr is itself a terminal; under2> filethey degrade to plain lines, the correct trade against corrupting the payload).streamLogsindocker-runner.tsis the one member of that class deliberately left alone -- a CONTAINER's stdout may legitimately be what the user wants on that stream, which needs a per-caller contract decision and a real-Docker round (#2419) -- andisStdoutReservedForPayload's JSDoc now says so, rather than leaving the class reading as closed. And the--wsREPL prompt was gated onprocess.stdin.isTTYalone while writing>to STDOUT, socdkd local invoke-agentcore Agent --ws > frames.txtfrom a terminal appended a prompt after every frame and the| tail -1these very docs prescribe returned>-- the workaround broken by the thing it works around; the two questions are now separate (interactivestill drives reading stdin lines, the newpromptOnStdoutadditionally requiresprocess.stdout.isTTY), so a redirected stdout keeps the raw WS-protocol-faithful shape while a terminal stdin still gets its REPL.A pre-flight safety guard that could NOT reach a verdict now leaves a DURABLE trace, not just a
logger.warn(issue #2301 item 3) --src/types/resource.ts(newIndeterminateGuard, plus an optionalindeterminateGuardsfield on BOTH arms ofResourceDeleteResult),src/types/deployment-events.ts(newRESOURCE_GUARD_INDETERMINATEevent type + aguardfield;reasonwidened pastRESOURCE_SKIPPED),src/deployment/delete-outcome.ts(withIndeterminateGuard/deleteIndeterminateGuards),src/provisioning/cloud-control-provider.ts(confirmDeleteTargetIdentityreturns its verdict;CC_DELETE_REGION_IDENTITY_GUARD),src/cli/commands/destroy-runner.ts(emission +DestroyRunnerResult.guardIndeterminateCount+ the summary suffix and aggregate warning),src/cli/commands/events.ts(yellow arm +guard=column),src/index.ts,docs/deployment-events.md,.claude/rules/layout-deployment.md, plustests/unit/deployment/delete-outcome-indeterminate-guards.test.ts,tests/unit/cli/destroy-runner-guard-indeterminate.test.ts,tests/unit/cli/events-guard-indeterminate-render.test.ts(all new), 8 cases appended totests/unit/provisioning/cloud-control-s3-delete-identity-2283.test.ts, and a newPhase 0c-IDarm intests/integration/s3-lifecycle/verify.sh. Issue #2283's bucket-identity probe has three outcomes, and the third -- the probe CANNOT answer -- proceeds by design (refusing would strand a least-privilege destroy). It used to proceed leaving nothing but console output, so after the run a destroy that had NOT confirmed its target was indistinguishable from one that had -- while the motivating attack is exactly ans3:GetBucketLocationDenyin the target's own bucket policy, settable by anyone holdings3:PutBucketPolicyon it. Two design calls are recorded rather than implied. The verdict rides on the EXISTING'deleted'arm as an optional field rather than as a thirdoutcomemember, because a suppressed guard is orthogonal to whether the resource was addressed and becauseundefinedalready means'deleted'for the ~80 providers that returnvoid-- so every existing consumer reads "not'skipped'" as deleted and a new member would need per-site handling. And the event is emitted IN ADDITION toRESOURCE_SUCCEEDED, not instead of it (the shape issue #1819's partial-UPDATE skip already established): instead-of would leave theRESOURCE_STARTEDrow with no terminal partner and would contradictRUN_FINISHED'scounts.deleted, which this deliberately does not move. The counter likewise never touchesdeletedCount/skippedCount/errorCount, never forces state preservation and never changes the exit code -- an observability addition must not turn a completed destroy into an exit 2. A thirdreasontext was also separated out: the SDK-region-chain-rejected arm falls THROUGH into the no-region warn, so a client that was asked and errored used to be reported as one that was never asked; console and durable record now say the same thing. The new integ arm plants a bucket, deniess3:GetBucketLocationon it, proves the deny took effect, then asserts the delete PROCEEDS and that the event lands in thedeployments/*.jsonlOBJECT (not in console text). Its stack holds TWO cc-api-routed buckets with only one denied, and the assertion is the guard rows' exact MEMBERSHIP rather than their count: the contract is "a row for the resource whose probe was denied, and for no other", and a one-resource stack yields exactly one row whether the event is conditional on the verdict or emitted on every delete -- it would have read as fenced while discriminating nothing. The second bucket is the in-run control (same command, same route, same guard, answering probe), and it covers a producer-side failure the runner unit tests structurally cannot see, since they feed the runner its delete results directly. That the delete still succeeds under the deny is a claim about AWS and was measured, not assumed:cloudformation describe-type --type-name AWS::S3::Bucket(us-east-1, 2026-09-02) listss3:GetBucketLocationin none of the five handlers, anddeleteneeds onlys3:DeleteBucket+s3:ListBucket. Four mutation probes were run against the SHIPPING tree (not carried forward from an earlier revision; the later fixture-only edits and the rebase onto issue #2430 touch nosrc/file and no unit test the probes read, so the four results still hold) and all four went red (drop the summary suffix: 2/10 failed; makewithIndeterminateGuarda no-op: 7/49; remove theevents.tscolorize arm andguard=column: 2/3; delete the destroy runner's emission loop: 8/10). A four-reviewer round (spec / code / test / security) then found one BLOCKER, confirmed independently by two of them: thereasoninterpolated AWS's own message, and S3 words theAccessDeniedfor this exact population asUser: arn:aws:sts::<account>:assumed-role/<role>/<session> is not authorized to perform: .... Pre-change that text was an ephemerallogger.warn; persisting it would have written the destroying principal's account id, role and session name into a durable artifactcdkd destroydoes not sweep -- at a moment the ATTACKER picks, since they set the deny policy. Fixed with issue #2302'sdescribeAwsFailuresplit (the error CLASS into the persistedreasonand the warn, AWS's wording atdebug), which is the same answer the SDK-routed twin of this guard already reached ins3-bucket-provider.ts'sprobeFailedCause. The fence asserts the ABSENCE of each identity fragment rather than only the expected string, and finding it required making the test double AWS-authored:wireBucketLocationErrorbuilt a bareError, anddescribeAwsFailurekeys the redaction on the$metadata/$faultmarkers every@aws-sdk/*error carries, so the unrealistic double made a correct fix look like a product bug. The same round also gated the aggregate warning'scdkd eventspointer on a recorder actually existing (cdkd state destroythreads none, so it was directing that caller to an empty command), switchedwithIndeterminateGuard's field-by-field rebuild to a spread so a future arm field cannot be dropped, corrected a doc claim that the row can accompanyRESOURCE_FAILED(it cannot -- a guard rides the delete's RETURN value, so a throwing delete carries none), and added the missing coverage: the partially-destroyed summary arm, and live positive evidence that the control bucket's probe actually RAN rather than being skipped. Both real-AWS runs are on record indocs/_generated/integ-last-run.tsv:s3-lifecycle(2026-09-02T12:34:35Z, PASS, 290s), which is the new arm plus the phase 0c arms it re-runs, andlambda(2026-09-02T17:40:08Z, PASS, 190s) for theinteg-broadgate thatdestroy-runner.tsactivates -- that row was re-taken by the issue #2274 lane in the same session, which is the ledger's one-row-per-test invariant doing its job rather than a second run of this change -- 9 deleted, 0 errors, no orphans on either. The live run also caught a defect no unit test had: the redacted cause and the sentence after it producedfor AWS's own message.. S3 bucket names, because the helper's two summary shapes disagree about trailing punctuation. Deliberately NOT included: the deploy engine's fiveprovider.deletesites and the rollback executor's arms still discard the verdict -- filed as #2422. The same audit also found thatcdkd state destroythreads no event recorder at ALL, so both copies of theRESOURCE_SKIPPEDdoc claiming it is "Emitted bycdkd destroy/cdkd state destroy" were FALSE; the prose is corrected here (main must not carry a claim its shipped code contradicts) and the missing capability is filed as #2423.The last two readback shapes that kept a resolved secret in plaintext are closed (issue #2012) --
src/deployment/secret-redaction.ts,docs/cli-reference.md,.claude/rules/layout-deployment-secrets.md, plustests/unit/deployment/secret-redaction-derived-needles.test.ts(new), flipped residual assertions intests/unit/cli/state-refresh-observed.test.ts/secret-redaction-array-identity.test.ts/secret-redaction-anchor-pairing.test.ts, and two new phases plus two env vars intests/integration/secrets-dynamic-ref/(verify.sh,lib/secrets-dynamic-ref-stack.ts,README.md). THE PROBLEM. On the readback paths the secrets map is EMPTY by construction --cdkd state refresh-observedresolves nothing, and a plaincdkd deployreaches the persist choke point the same way for an UNCHANGED resource -- so the value scan has no needles and POSITION is the only mechanism. Two shapes have no position to argue from and kept their DECRYPTED value instate.json: an UNPAIRED array element beside a paired one, and an observed KEY the source does not carry. #2012: DERIVED NEEDLES. The two surviving rows — an UNPAIRED array element beside a paired one, and an observed KEY the source does not carry — are exactly the two positions whereredactByPathALREADY delegates to the value scan. They never lacked a needle in principle: the same plaintext almost always sits at a position the pass DOES certify, and certifying it IS the assertion that AWS's value there is that expression's resolved form.deriveReadbackNeedlesruns the refusal walk once in LEARN mode over the same two bags — the SAME function, so the two passes can never disagree about what "certified" means — collects the(plaintext -> expression)pairs, and hands them toredactByPathas the secrets map. Nothing is resolved and nothing is fetched. The issue's own proposed direction was to resolve the record's expressions, which would have madecdkd state refresh-observedand every deploy's observed capture FETCH secrets: a new IAM requirement, a new failure mode, and a new place plaintext lives in memory. AWS had already handed us the plaintext, in the very bag being redacted. FIVE BOUNDS ON WHAT MAY BECOME A NEEDLE, because a needle is a rewrite with a blast radius rather than a per-leaf decision. Only the readback-projected rules (a cross-generation or template source describes a different generation of the resource). Only when the map is EMPTY —crossStackAssociations/nestedStackParameterExpressionsareWeakMaps keyed by the RecordedSecretValues INSTANCE, so substituting a derived Map would silently lose every association the pass recorded; with an empty map there are none to lose. Only from an expression naming a SECRET-BEARING service and not PROVEN PUBLIC (expressionMaySeedANeedle):isSingleDynamicReferenceTokenaccepts any{{resolve:<anything>}}spelling and the resolver's unsupported-service arm WARNS and returns the LITERAL, so{{resolve:notaservice:/x}}'s readback is ordinary data —cdkd driftpins the same rule for its own registration path, and without this the two commands answer differently about one expression. Only at or aboveMIN_NEEDLE_LENGTH, since the whole-value arm matches at ANY length and a two-character needle would rewrite every leaf equal to it. And a plaintext learned twice under DIFFERENT expressions is POISONED rather than collapsed — the issue #1910 wrong-reference class, on a pathcdkd drift --revertandresolveReplayPropsre-resolve against the live resource. A MIXED leaf yields a needle too, which matters because this module calls that shape DOMINANT for CDK (Fn::JoinaroundsecretValueFromJson). Guarded to EXACTLY ONE span whose literal prefix and suffix both bracket the bag, anchored at the ENDS rather than searched (a secret repeating the suffix —abc@hinsidepostgres://u:abc@h@h— still slices correctly). The pair maps the extracted plaintext to the TOKEN, never to the whole leaf: mapping it to the leaf writes a whole connection string over a field AWS reported as a bare password, which is fabricated baseline content that--revertthen pushes. That was caught by its own test before review, not reasoned about. ONE ANCHOR-GATE RELAXATION, in the direction that never guarded anything.anchorsCorroboratePairingcompared object key COUNTS, so an AWS-addedArn/LastModifiedinside an unkeyed-array element refused the whole element and left the secret in it as plaintext. It now requires CONTAINMENT — every SOURCE key present and corroborating — while a source key MISSING from the bag still refuses, which is the fabrication direction that stopped[{Value:'x'}]becoming[{Name:'db', Value:<expr>}]. The corroboration argument is unchanged: an extra bag key is not an anchor (anchors are the SOURCE's non-reference positions) and not part ofanchorSignature(computed on SOURCE elements), so neither rule 1 nor rule 3 moves. THE TWO PLACES THIS COULD HAVE BECOME A DISCLOSURE, both invisible in the diff. First, HOW THE SCAN COMPOSES WITH THE POSITION PASSES -- three attempts, each wrong in its own direction, each measured by a review round. Scanning FIRST (the original revision, needles fed intoredactByPath) lets the scan rewrite a frame LITERAL that happens to embed a learned plaintext -- a coinciding anchor, exactly this issue's population -- after whichunkeyedArrayPairsByAnchors, re-run against the SCANNED bag, no longer matches, the WHOLE array refuses, and a sibling MIXED leaf the SHIPPED code redacts by POSITION persists in full plaintext (UNDER-redaction). Scanning LAST over their OUTPUT is the mirror: the scan then sees leaves whose content came from the SOURCE, and a needle in such a leaf's literal frame turnspostgres://appuser:{{resolve:secretsmanager:...}}@h/dbintopostgres://{{resolve:ssm:/app/db-user}}:{{...}}@h/db-- a FABRICATED baseline thatcdkd drift --revertre-resolves and pushes to the live resource. Merging on "the passes did not CHANGE this leaf" (refused === bag) STILL fabricates, because value equality cannot tell an UNDECIDED position from one decided IN FAVOUR of the value already there -- thessm-secure:unsupported-service arm makes AWS echo the token literally, so source EQUALS bag. What ships asks the pass itself:refuseUncertifiedReadbackPositionsruns a second time inmarkmode, returning a POSITION_DECIDED sentinel wherever it decides, andpreferPositionDecisionstakes the scan's answer only at a STRING leaf that mark tree does not claim. Re-running the REAL pass rather than mirroring its navigation keeps the pairing rules single-sourced. EVIDENCE STRENGTH BOUNDS THE BLAST RADIUS, not admission.secretsmanager:andssm-secure:say what they are, and a RECORDED verdict is a realGetParameteranswer; a bare{{resolve:ssm:token with no verdict is accepted on the #1901 premise (a publicStringis persisted RESOLVED, so a token SURVIVING in a state bag is aSecureString) -- sound for the leaf ITSELF, and not a licence to rewrite every other leaf that merely CONTAINS the value. Measured: a baressmtoken whose value isproductionturnedmy-production-logsintomy-{{resolve:ssm:/app/env}}-logs, which is the failureexpressionMaySeedANeedle's own doc names -- and if the parameter is in fact public,cdkd drift --revertre-resolves that and renames the live bucket the day the parameter changes. So an INFERRED needle rewrites a leaf only WHOLE, while a CERTAIN one keeps the substring arm -- and CERTAIN means SPELLING alone. A RECORDEDSecureStringverdict deliberately does NOT promote a baressmtoken, becauserecordedSecretExpressionsis keyed on the bare expression and lives for the whole process: on acdkd deploy --alla verdict pinned where the parameter is aSecureStringis inherited where it is a plainString, and theskipDynamicReferencesdiff path skips the lookup on atrueverdict so the second region never retracts it. That is the same region blindness this PR withdrew issue #2036's store for. Inheriting a secret-direction verdict is safe for ADMISSION (it can only over-redact the leaf itself) and unsafe for BLAST RADIUS. The spelled-secret prefixes are an explicit ALLOWLIST rather than the admission list minus one entry, so the NEXT prefix added defaults to the narrower radius instead of the wider one. Issue #2012's own two rows are whole-value positions, so the closure is untouched; the security review that found this proposed dropping baressmfrom the seeding set instead, which would have failed exactly oncdkd state refresh-observed-- the process the issue is reported against. Its object arm is guarded onhasPlainPrototypebecauseisPlainObjectadmits aDateandObject.entries(new Date())is[]-- without it the merge itself rebuilt a readbackLastModifiedas{}, newly extending issue #2427's flattening to the empty-map path. Second, the MAP: the derived map goes to neither position pass, and in particular NOT torefuseUncertifiedReadbackPositions.mixedLeafMayCarryPublicReferenceSPLITS on whether a map exists, and a derived map satisfiessize > 0while proving nothing was resolved — handing it over would read every{{resolve:ssm:mixed leaf as public and persist the decryptedSecureString, i.e. the regression thesecrets-dynamic-refinteg caught before #1926 shipped, arriving through a new door. Pinned by a dedicated case. FIVE PINNED RESIDUAL ASSERTIONS FLIPPED ON PURPOSE, each with its old value named in place: the threeRESIDUAL (issue #2012)cases (two instate-refresh-observed.test.ts, one insecret-redaction-array-identity.test.ts) and the two anchor-gate key-count cases. A sixth kept its assertion and was re-documented — an AWS-added element holding an UNRELATED literal is still untouched, which is the discrimination that separates a value-keyed needle from the blanket source-subtree rewrite the #1915 fences rejected. THE INTEG FIXTURE'S PUBLIC-LEAF ARMS RESTED ON A FALSE PREMISE and were rebuilt (tests/integration/secrets-dynamic-ref/verify.sh, its README). Phase 1f asserted thatcdkd state refresh-observedleavesPUBLIC_URLas the EXPRESSION; that arm failed against fixed code AND againstmainon the pre-merge run (rc=1 at exactly that line), and Phase 1g's CONTROL 3 -- claimed as issue-#2036 coverage -- passed identically onmain, i.e. discriminated nothing. Both had the same upstream cause:propertiesholds a public ssmStringRESOLVED by construction (#1901), so the POSITION SOURCE carries no reference and the mixed-leaf arm is never consulted. A new Phase 1f3 stamps thecdkd importwarn-path shape intoproperties(the same S3 write/restore idiom Phases 1f / 1f2 already use) and pins the needle's BLAST RADIUS -- oncePUBLIC_URL's source carries the reference, the needle learned from it also rewritesSSM_VALUE, which holds the same resolved value of the same parameter. That is the arm that DISCRIMINATES;mainleavesSSM_VALUEresolved. The residual assertion beside it is labelled a PIN rather than coverage, and Phase 1g's CONTROL 3 keeps its assertion and loses its claim. ISSUE #2036 WAS IN THIS LANE AND WAS WITHDRAWN, which is worth recording because the withdrawal is the finding. Its over-redaction (a genuinely PUBLIC ssm reference inside a MIXED leaf, refused on the empty-map paths) was to be closed by a POSITIVEprovenPublicExpressionsstore, written from the resolver's ownpinSecretVerdictretraction so no lookup was added. PR #2415's security review measured the cost: the store is keyed on the bare expression and lives for the whole process, so on acdkd deploy --allspanning regions a verdict recorded where the parameter is a plainStringun-redacts aSecureStringof the same name in another region --Server=db;Password={{resolve:ssm:/app/token}};becameServer=db;Password=<decrypted>;. That is the UN-redacting direction, i.e. worse than the over-redaction it fixes, and it falsified the store's own claim that a cross-region verdict "can at worst store a public reference as an expression". The whole half was reverted rather than patched: keying the verdict by SCOPE (region + account) has to happen at the READ side, which threads a parameter throughredactSecretsForStateand its callers -- a cross-cutting change that belongs in its own PR with its own review. #2036 stays open, with the constraint recorded on the issue and inmixedLeafMayCarryPublicReference's own doc. Issue #2179's verification row, answered rather than deferred: that issue asks whoever closes #2012 to check the destroy-events sink, on the grounds that a state record holding plaintext becomes durably published the moment a delete fails. Confirmed on this tree —destroy-runner.ts's twoRESOURCE_FAILEDrecords still persist an UNMASKED message, and the coupling is bounded by what a delete actually sends:provider.deletereceivesresource.propertiesand neverobservedProperties(which is read only bycountProtectedResources, for two protection flags).propertiesis redacted against the TEMPLATE source, soisReadbackProjectedFromStateis false for it and #2012's rows never applied there. #2179 stays open on its own terms (the sink has no masker); no plaintext reaches it through #2012's residuals, and after this change those residuals are closed inobservedPropertiestoo.lock.json's noncurrent versions are now purged wherever cdkd deletes the lock, closing issue #2346 site 5 and correcting the docs that argued against it --src/state/lock-manager.ts,src/state/s3-noncurrent-version-purge.ts,docs/state-management.md,tests/unit/state/{lock-noncurrent-version-purge,lock-manager,s3-noncurrent-version-purge}.test.ts,tests/integration/{rollback-command,gc-custom-asset-names}/verify.sh.deleteLockwas a bareDeleteObject, so on the versioned state bucket every priorlock.jsonversion stayed readable: renewal writes one version every two minutes at the default 30-minute TTL,deleteStatenever sweeps the lock key, and nothing purged it -- the chain was monotonic in stacks EVER deployed (452 versions on one measured key, invisible toaws s3 lsand still paged through by everyListObjectVersionsthe other purge sites issue). The sharedpurgeNoncurrentKeyVersionsnow runs at each of the four sites wheredeleteLocklands a delete marker:releaseLock's conditional delete and its unconditional escape hatch (one key-scoped purge in afinallycovers both),forceReleaseLock(cdkd force-unlock, andcdkd state orphan), andacquireLock's expired-lock takeover -- the last after the re-acquisition PUT rather than between the delete and the retry, so the delete-then-reacquire contention window is not widened. The takeover site is also what reclaims a CRASHED run's chain, which no per-process scheme could: the purge is scoped to the KEY, not to versions this process minted.A per-
VersionIddesign was built first and rejected on adversarial review, and the rejection is the interesting half. Threading each PUT'sVersionIdthroughHeldLockand issuing oneDeleteObjectson release costs one call instead of two and one IAM action instead of two -- but every hazard it carried was created by the mechanism: deleting one's own delete marker RESURRECTS a stale lock wheneverDeleteObjectspartially fails (a phantom holder with a liveexpiresAt), a per-id delete has noIsLatestguard so thetry/finallyshape sites 4 and 6 use would fire onreleaseLock's refusal branches and delete our own CURRENT version, a"null"version id under suspended versioning is a legal delete target, and the 1000-entry cap forces re-implementing the shared helper's batching. The shared helper'sIsLatestfilter removes all of them at once -- it can never touch what is current -- which is why the simple uniform form is also the SAFE one, including on the refusal arms where our own lock is still live.The one genuine regression is UX, and it is handled rather than accepted. A release runs at the tail of every mutating command, so inheriting the four secret-bearing sites' WARN would give a principal on the pre-#2340 four-action policy -- who gets a silent clean deploy today -- a warning after every single command, about a heartbeat record with no secret in it. Release-path purge failures go to
debug; the two rare reap paths still warn, which is the cost profiledocs/state-management.mdalready means by "only the cleanup paths that need them". A warn-once-per-process dedupe was rejected for the reason the helper's own JSDoc gives: it needs module-global state, which this repo has been bitten by under--stack-concurrency > 1. Separately, a per-keyNoSuchVersionfromDeleteObjectsnow counts as SUCCESS at EVERY call site: it says the version is already gone, which is the outcome the purge wants, and on the lock key two actors legitimately purge concurrently (a reaper and the waking original owner), so without it the never-throw warning tells a blameless user to grant IAM they already hold.NoSuchKeyis deliberately NOT tolerated -- that says the listing and the delete disagree about the key.Two of the three claims the shipped docs made against this change were already wrong, independently of it.
docs/state-management.mdsaid purging on release "would makes3:ListBucketVersions/s3:DeleteObjectVersionrequired for ordinary use" -- contradicted by the same document 470 lines later ("Without the two grants, nothing fails -- and that is the point to understand.") and by the helper's never-throw contract. And it said "the remedy is a bucket lifecycle rule on noncurrent versions rather than a code change" -- not expressible:getLockKeyyieldscdkd/{stackName}/{region}/lock.json, solock.jsonis a key SUFFIX interleaved withstate.jsonunder one prefix, and S3 lifecycle filters support onlyPrefix/Tag/ObjectSize; the one expressible prefix rule (cdkd/) would expirestate.json's history too, doing in bucket configuration exactly what sites 1-3 are held open to prevent. Only the round-trip cost claim survives, and site 4 already pays it on a strictly broader population --deleteRollbackJournalBestEffortfires from BOTH deploy-success arms, so aListObjectVersionsgoes out on every successful deploy including the common case where no journal ever existed. Sites 1-3 (state.jsonand the v1 -> v2 migration delete) remain deliberately unpurged: those noncurrent versions ARE the recovery capability.The two integ NEGATIVE CONTROLS had to be re-pointed in the same PR, and that is the subtle part.
rollback-commandstep 4b2 andgc-custom-asset-namesPhase 4b both assertedlock.jsonkept at least one noncurrent version "as site 5 intends" -- the control proving the other sites' purges are not purge-everything bugs. Under this change that assertion stops discriminating, and worse, it would keep PASSING on accumulated delete markers while its comment became false: a fixture that is green and meaningless. The scoped-purge control role moves tostate.json, documented in three places as deliberately never purged and therefore the key most likely to break LOUDLY if a future purge over-reaches. Each fixture now ALSO asserts the new invariant on the lock key (after a clean release: exactly one row, the current delete marker, andnoncurrent == 0), so the same two arms fence both directions. A sweep found a THIRD copy the issue had not named:tests/integration/migrate-from-cfn/run.shcarried the same control in prefix form, and re-pointing it exposed that its premise was gone --cdkd importcallssaveStateonce, which is one CURRENT version and zero noncurrent, so after site 5 anoncurrent >= 1floor there could only pass on the previous run's residue. It now asserts NOTHING at that point, and the reasoning is worth keeping: weakening the floor toall >= 1was tried and rejected, becauseassert_state_presenthas already run ahead-objectonstate.jsona few lines above, soall >= 1is IMPLIED by an assertion that already passed -- a floor that cannot be the first thing to fail is not a control. The discriminating assertion moves instead to the first moment the run genuinely guarantees a noncurrent row: a post-destroy check thatstate.jsonkeeps one, which fences sites 1-3's deliberate non-purge directly and which a purge that widened its prefix and lost itsIsLatestfilter would fail.Tests:
tests/unit/state/lock-noncurrent-version-purge.test.ts(35 cases) drives all four call sites through a recording S3 double and asserts the command SEQUENCE, the version ids handed toDeleteObjects(the current delete marker must survive -- taking it would undo the release), the key-scopedPrefixwith a<lock key>.baksibling row in the listing so thewantedmembership filter is actually fenced,ExpectedBucketOwneron every call, thefinallybehaviour on a refused and on a throwing delete, the debug-vs-warn split at BOTH the helper's warning and the wrapper's could-not-start arm, the caller's ownobjectDescription, the legacy region-less key, a current-version BODY (the shape the foreign-lock arm really has, where theIsLatestfilter is all that stands between the purge and someone else's live lock), a region re-resolution when the delete's own resolution failed, a THROWING log sink, and the ABSENCE of any purge on a pre-delete refusal. It closes with an AST fence that parseslock-manager.tsand fails if a member callingthis.deleteLockdoes not also callthis.purgeLockVersions(and vice versa), with the member set pinned as a literal so a query that silently stopped matching cannot satisfy itself. Four cases ins3-noncurrent-version-purge.test.tscover theNoSuchVersioncarve-out, including that a real failure alongside one -- on the same key and on a different key in the same batch -- is still reported, and thatNoSuchKeyis not swallowed.Three things review caught that are worth recording, because each was invisible to the round before it. The purge's never-throw claim was FALSE where it mattered most:
reportis handed to the shared helper as itswarnSINK and the helper calls that sink outside anytry, whilelogger.debugreachesconsole.debugon stdout -- which this repo has measured throwing EPIPE synchronously under--verbose | head. Since every call site is afinally, that throw would have REPLACED theLockErrorthe release was raising;reportis now total. The AST fence attributed calls only throughMethodDeclaration, so a class-property arrow or a constructor callingthis.deleteLockwas invisible -- the fence failed OPEN on exactly the change it exists to catch, and its own guard-the-guard never called the function it was guarding, so deleting the parse-failure throw left the suite green. And the fake S3 client arm added tolock-manager.test.tswas NOT added to its siblinglock-renewal.test.ts, whose ~40 releases were therefore running the purge's error path on every one, green only because release-path failures go todebug. The population of suites that construct a realLockManageris three, and all three now carry the arm.A removed Cognito
Policiessub-key is now ANNOUNCED instead of deploying as a silent no-op (issue #1979) --src/provisioning/providers/cognito-provider.ts,tests/unit/provisioning/cognito-provider.test.ts,tests/integration/cognito/{lib/cognito-stack.ts,verify.sh}. DeletingPolicies.SignInPolicy(orPasswordPolicy, or the wholePoliciescontainer) from a deployed pool's template used to deploy green while AWS kept the old policy:UpdateUserPoolpreserves an omitted sub-key (measured us-east-1 2026-08-19, issue #1968) andtoSdkUserPoolPoliciesforwards only what the template declares, so nothing was sent and nothing changed -- the dangerous direction, since removing a passwordless first-auth factor is a TIGHTENING that never lands. The issue also predicted a permanentcdkd driftdifference; MEASURED live (us-east-1, 2026-09-02, stackCdkd1979LiveVerify) there is none, and the prediction is withdrawn rather than shipped --update()refreshesobservedPropertiesfrom the post-update readback, so the retained sub-key lands in the drift baseline too, the announcing deploy CONVERGES, andcdkd drift/cdkd diff/ the next deploy all report nothing. That makes the warn the ONLY signal an operator ever gets, which is precisely why the silence was the whole defect. The design question the issue deferred -- reset to AWS defaults, or warn -- was settled by a real CloudFormation A/B rather than assumed (us-east-1, 2026-09-02, transcript in thetoSdkUserPoolPoliciesdocstring and the PR body): a CFn stack whose pool declared both sub-policies reached UPDATE_COMPLETE on all three removal edits (SignInPolicy alone, PasswordPolicy alone, the whole container) with every live value intact -- CloudFormation performs the SAME silent no-op, so a cdkd-side reset would be a DIVERGENCE from its stated template compatibility, and the only wrong option under both A/B answers was the silence. What changes is therefore the silence alone:update()warns once per removed sub-key (warnOnUnremovablePoliciesSubKeys), naming the sub-key, stating the live value is unchanged (with both measurements cited), and giving the one remedy that exists on the wire -- declare the sub-key explicitly with the intended (e.g. default) configuration. The removal detection MIRRORS the wire's own truthiness gates (sendsPoliciesSubKey, the config-shape.ts share-the-predicate rule applied to a truthiness gate) so it fires exactly when the wire stops carrying a sub-key it previously carried; pinned by an 11-shape agreement table driven through the PUBLICupdate()(warn iff not sent). The announcement reaches everyupdate()caller truthfully:cdkd deploywarns on the deploy carrying the removal (later deploys are honest NO_CHANGEs, matching CFn's converged stack -- both measured live),cdkd drift --revertreaches it only in one narrow shape -- the revert's desired side starts from the LIVE bag (buildRevertNewProperties) and a record with noobservedPropertiesmerges AWS's untemplated sub-keys back in, so what actually fires is a record WITHobservedPropertiesplus a sub-key added out-of-band after that capture -- and the rollback executor's two arms differ from each other, therevertarm warning when rolling back to a record that lacked the sub-key whilerevert-failed-updatepasses the FAILED attempt's bag and so can name a sub-key that never reached AWS. Each call site's exact condition is documented atwarnOnUnremovablePoliciesSubKeys, because the obvious reading of two of them is wrong. Six new unit cases, mutation-proven two ways against the real provider: deleting the call site reds 4, and anin-based presence predicate (disagreeing with the wire on a declarednull) reds the agreement table by name. The cognito integ gains aPolicyRemovalUserPoolUPDATE arm asserting the announcement (bound to the pool id and to the exact producer wording the unit suite also pins), RETENTION of the live sign-in policy (the CFn-parity pin -- a future reset change fails it and must be deliberate), the still-declaredPasswordPolicykept forwarding, and a fired-call companion (AutoVerifiedAttributesadded in the same update) so retention cannot pass vacuously when no call went out. This is the Cognito instance of the #1225 sub-field-removal class, resolved as announced-parity rather than reset-to-default because that is what CloudFormation measurably does for this type.A retried 500 no longer duplicates an EFS access point, and the
ClientTokenrationale that said the SDK handled it is corrected (issue #2080, Plan A) --src/provisioning/providers/efs-provider.ts,src/provisioning/property-coverage.generated.ts,docs/provider-development.md, plustests/unit/provisioning/efs-access-point-idempotency-token.test.ts(new).CreateAccessPointsent noClientToken, so a 500 whose request had actually SUCCEEDED server-side re-enteredcreate()through the engine's outer retry and minted a SECOND access point with no state entry -- invisible tocdkd destroy, billing until found by hand. The field was not simply absent from the wire: EFS models it with Smithy'sidempotencyTokentrait, so the SDK auto-filled a FRESH uuid per request, which is the regenerated-per-attempt shape #2080 calls worse than no token at all. Measured against@aws-sdk/client-efs3.1018.0 by capturing the serialized body of two identical sends:846a541f-...thend84cafec-..., while a caller-supplied value went out verbatim. That measurement also falsifies theunhandledByDesignrationale this file has carried forClientToken("AWS SDK manages this idempotency token internally ...; no user-supplied value is honored"), which is rewritten in the same change;ClientTokenSTAYS a silent drop, because cdkd now overrides it with its own value and a template-supplied one really would be discarded -- only now that is true by cdkd's construction rather than by a false claim about the SDK. The token comes fromacquireIdempotencyToken(per-process nonce, memoised for one create, released on success) rather than the deterministic sha256-of-immutable-inputs this same provider uses for a file system'sCreationToken. The two are not interchangeable, and the argument deliberately does NOT rest on a retirement window: EFS publishes no retirement period for aCreateAccessPointClientToken(the "one minute" in the EFS User Guide's "Creation token and idempotency" section is about file-system CREATION tokens, and theCreateAccessPointreference describes the repeat unconditionally), so the duration is treated as unknown. The process-scoped derivation is correct under both readings: if the token retires quickly, stability across runs buys nothing while it would put a--replacedelete-then-create inside the replay window of the access point it just deleted; if it instead binds for the access point's lifetime, a deterministic hash is worse still, since any re-create with unchanged inputs would collide with the live access point rather than create one. When EFS refuses the repeat withAccessPointAlreadyExists, the first attempt's access point is live AND orphaned with a failed deploy on top, socreateOrAdoptAccessPointadopts it -- the same shaperoute53-provider.tsuses for the same issue family -- after confirming viaDescribeAccessPointsBOTH that itsClientTokenis the one cdkd minted and that it belongs to the requested file system. The confirmation read itself is retried, on a transient 5xx OR a throttle (isRetryable: (_text, err) => isThrottlingError(err) || isTransientServerError(err)) -- a modeled@throwslist is not exhaustive, and an unmodeled throttle on the read-back strands the same orphan a 5xx would: declining there is the worst decline in the method, because the first attempt's access point is already live, so a blip on the CONFIRMATION would fail the deploy AND leave exactly the orphan the token exists to prevent -- and the outer engine retry cannot cover it, sinceisTransientServerErrorwalks.cause, which holds the 409 conflict rather than the 5xx. AnAccessDeniedis NOT retried: that grant is absent rather than propagating. That retry is interrupt-threaded with a per-WAITstartInterruptWatchdisposed in afinally(never onthis-- the provider is a singleton serving concurrent resources, so a shared watch would let one resource's Ctrl-C abort another's retry);scripts/check-withretry-interrupt.tsenforces this and caught the omission, since an un-threadedwithRetryleaves Ctrl-C dead for the whole backoff schedule and, on the destroy path, keeps issuing writes behind a run the user was told had ended. Threading the interrupt then CREATED a second hazard that two independent reviewers caught:withRetrythrowsInterruptedWaitErrorout of its backoff, which lands in the samecatchas a lookup failure, anddeclinesetscauseto the CreateAccessPoint conflict -- ERASING the interrupt from the chainisInterruptedWaitErrorwalks, sodeploy-enginewould miss it and automatically roll the whole stack back on Ctrl-C, the exact outcomeinterrupt-watch.tsexists to prevent. The catch now rethrows an interrupt first, matchingdynamodb-globaltable-provider.tsandelbv2-provider.ts. The decline is additionallymarkNonRetryabled rather than trusting its wording, because the message interpolates a user-chosen logical id andRETRYABLE_ERROR_MESSAGE_PATTERNSholds whitespace-free entries that such an id could contain. Every way the confirmation can still fail DECLINES: it warns, and rethrows aProvisioningErrornaming the token collision and carrying the AWS error ascause. AWS-authored text is withheld from that thrown message viadescribeAwsFailure(summary names the failure CLASS,detailgoes todebug), for two reasons that happen to coincide: the message reaches the durabledeployments/{runId}.jsonlstore, and the headline population of this arm is a missingelasticfilesystem:DescribeAccessPointswhose AWS wording names the account id, the deploy role and the session; that same wording containsnot authorized to perform, anIAM_PROPAGATION_ERROR_MESSAGE_PATTERNSentry, so interpolating it made a permanently missing grant retry on the dense IAM cadence -- 27 attempts re-issuingCreateAccessPoint. The decline is now terminal, which is correct for an absent grant. Adoption also refuses an access point reported asdeleting/deleted, and the decline path no longer double-wraps (if (error instanceof ProvisioningError) throw error, matchingroute53-provider.tsandfsx-filesystem-provider.ts). The wrap is load-bearing rather than cosmetic --DeployEngine's replacement path classifies withisNameCollisionError, which matches the raw conflict'salready existswording, so a bare rethrow would make the engine read a TOKEN collision as a physical-NAME collision and, under--replace, delete the OLD access point and re-create with the same still-unreleased token. Tests: 30 cases counting RESOURCES rather than calls, with the clock advanced inside the retry's sleep per #2080 acceptance item 3 -- and the fixture FENCES its own precondition (createTimes[1] > createTimes[0]), because ifsetSystemTimeever no-ops both attempts land in the same millisecond, aDate.now()-derived token matches by coincidence, and the probe written to catch that implementation passes. The redaction fixture is SDK-SHAPED ($fault/$metadata), which is load-bearing twice over:describeAwsFailurekeys on exactly those marker fields, so a redaction assertion written against a barenew Error(...)holds vacuously -- it did, until the assertion caught it -- and smithy'sServiceException[Symbol.hasInstance]matches on$fault && $metadata && name === this.namerather than prototype identity, so thename ===fallback besideinstanceofis reached only by a conflict carrying NO marker fields, which is what its own case now supplies. Fifteen mutation probes, all RED against a 30-passing baseline: no token sent 28/30; a clock-derived token regenerated per attempt 26/30; a bare rethrow instead of the decline 13/30; the no-double-wrap guard removed 9/30; adoption not confirmed against the token 4/30; the confirmation retry disabled 2/30; and 1/30 each for the file-system confirmation, the withheld AWS text, the ARN guard, the interrupt rethrow, the throttle clause in the retry predicate,markNonRetryable, thename ===fallback, and thedeletedanderrorlifecycle clauses. A sibling sweep of the CREATE-shaped SDK command constructions undersrc/provisioning/providers/, each resolved to its request model, finds 8 call sites now taking the shared helper (ACM, CloudFront, EFS, Route 53, and four in EC2) plus the two deliberately hash-derived tokens (EFSCreationToken, FSxClientRequestToken); the remaining token-carrying models are non-defects (S3'sTokenonPutBucketReplication/PutObjectLockConfigurationis an Object-Lock confirmation token; the Scheduler / Secrets Manager creates are NAMED, so a replay collides rather than duplicating; andcloudfront-oai-provider.ts/agentcore-runtime-provider.tsderive or pass through a caller reference already). That closes #2080's tier 2; its tier-1 and tier-3 entries -- the creates with no token member at all -- remain open as Plans B and C.The six remaining
--jsonsurfaces now put the payload and nothing else on stdout, closing the issue-#2230 class (issue #2280) --src/cli/commands/state.ts(the fourstate {list,resources,show,info}subcommands),src/cli/commands/list.ts,src/cli/commands/events.ts,src/utils/logger.ts(doc comment only),docs/cli-reference.md, plustests/unit/cli/{state,list,events}-json-stream.test.ts(new). Each command now calls the module-levelreserveStdoutForPayload()the #2230 lane added, once, when--jsonis in effect and BEFORE any collaborator can print -- ahead ofapplyRoleArnIfSet(whoseAssumed role ...INFO line fires on any--role-arn/CDKD_ROLE_ARNrun) and, forcdkd list, ahead of the synth (whoseapp-executor.tsre-emits the CDK app's stderr at INFO on a DEFAULT run -- the worst of the six, corruptingcdkd list --long --json | jqwith no--verboseinvolved). No edits inrole-arn.ts/s3-state-backend.ts/app-executor.tswere needed: their prose flows throughchild()loggers, and the reservation is module-level precisely so those are covered. The per-command suppress-vs-move decision the issue called out is answered MOVE for all six, matchingdrift(cdkd diffkeeps its pre-existing demote-to-warnsuppression; the re-derived population confirmed the other three ofstate.ts's seven un-gatedsetLevel('debug')sites sit on non---jsonsubcommands and are untouched). The three new suites mirrordrift-json-stream.test.ts-- real logger, console spies into an ordered fd-1/fd-2 transcript, asserting BOTH that stdout parses as a single JSON document AND that each human line ARRIVED on stderr -- and were mutation-probed as one unit: disabling all six reservations reds 10 of 13 cases while the three no---jsoncontrols stay green. Live before/after against a stderr-chattering fake CDK app: released 0.284.85cdkd list --long --jsonputs the chatter on stdout andjson.loadrejects it; this branch's binary parses, chatter on stderr. The same defect class on stdout payloads with NO--jsonflag (cdkd synth's template,cdkd list --long's YAML mode,cdkd local invoke's response payload) is deliberately out of scope -- filed with the two regression-fence rows carried from this issue as #2410.AWS SDK calls route through
HTTPS_PROXY/HTTP_PROXY, honouringNO_PROXY— cdkd now works on machines whose only egress is a corporate proxy (issue #2388, PR #2398, external contribution) --src/utils/aws-client-defaults.ts(new),src/utils/proxy-routing-agent.ts(new),src/utils/aws-clients.ts,src/cli/commands/bootstrap.ts,src/cli/config-loader.ts,src/utils/{aws-region-resolver,bucket-region-client,expected-bucket-owner}.ts,src/assets/asset-storage.ts,scripts/check-aws-client-defaults.ts(new),docs/troubleshooting.md,.claude/rules/proxy-support.md(new). The SDK for JavaScript v3 does not read the proxy variables, so every command previously failed at credential resolution behind a proxy.awsClientDefaults({ profile })returns{}when no proxy variable is set (the unproxied path is byte-identical), and otherwise supplies a per-client routing agent (anagent-basesubclass evaluatingNO_PROXYper request viaproxy-from-env, inner agents cached per instance,destroy()forwarded) plus an injecteddefaultProvidercredential chain so the SSO portal / SSO-OIDC hops route through the proxy too (the STS hops already inherit the client's handler). Fresh agent per call becauseNodeHttpHandler.destroy()destroys shared agents' ACTIVE sockets. Migrated in this PR: the client factory plus the bootstrap / state-write paths; the remaining 61 files are allow-listed intests/aws-client-defaults-allowlist.json(shrink-only, enforced by the new AST checker +audit:aws-client-defaults:checkCI step) and empty out in the follow-up PR.NO_PROXYsemantics (exact hostname unless the entry starts with.or*; CIDR silently ignored),NODE_EXTRA_CA_CERTSagainst TLS-terminating proxies, and the Docker-daemon egress caveat are documented indocs/troubleshooting.md. SDK-contract fences (aws-client-defaults-sdk-contract.test.ts) pin the two behaviours the design rests on against SDK bumps. First.ts-spelled relative import undersrc/(theaws-clients.tsliteral-resolution closure; carve-out in CLAUDE.md). New deps:https-proxy-agent/http-proxy-agent/proxy-from-env/agent-base/@aws-sdk/credential-provider-node(deliberately NOTproxy-agent— its PAC support dragsvminto the bundle). Verified by the contributor's live A/B on a corporate network plus five real-AWS integ arms on the unproxied path.The
docker-image-assetinteg fixture waits for the image function to leavePendingbefore invoking it (issue #2403) --tests/integration/docker-image-asset/verify.sh. cdkd deliberately does not wait forState=ActiveafterCreateFunction, and a container-image function takes 10-60 s to activate, so the fixture's immediate invoke raced activation and failed withResourceConflictException(two consecutive live failures on 2026-09-01). A boundedaws lambda wait function-active-v2between deploy and invoke removes the race without changing what the fixture proves.
Recently Implemented (2026-08-28):
A parameter's DEFAULT is now bound by its declared
Type, like a user-supplied value has always been -- so a defaultedCommaDelimitedListreaches consumers as a LIST, and anAWS::SSM::Parameter::Value<List<String>>does too (issue #2367) --src/deployment/intrinsic-function-resolver.ts,src/utils/parameter-types.ts,src/synthesis/macro-expander.ts,tests/unit/deployment/resolve-parameters-default-coercion.test.ts(new),docs/changelog-cdkd.md. THE COUNT WAS THE BUG.resolveParameterswritesparameters[name]at THREE sites and only ONE -- the user-supplied value -- asked the type coercion anything. The other two were the LITERALDefaultand the SSM-resolved default, so a parameter declaredType: CommaDelimitedListwithDefault: "a,b,c"and no CLI override reached every consumer as the raw string. This did not need issue #2347's widening to bite: it hitsCommaDelimitedListandList<Number>, the two list types theswitchrecognised all along, which makes it the older and more reachable half.Fn::Selectover such a parameter threwFn::Select: list must be an array, got stringand the deploy hard-failed;Fn::Joinrefused it the same way; a bareRefwas the SILENT arm, handing a provider a comma-joined scalar where the resource schema declares a list. CloudFormation's own worked example inparameters-section-structure.htmlis exactly this pairing --VpcAzs: {Type: CommaDelimitedList, Default: "us-west-2a, us-west-2b, us-west-2c"}read withFn::Select. ONLY A STRING DEFAULT IS COERCED, and the shapes were MEASURED rather than assumed.ParameterDefinition.Defaultis typedunknownbecause it is whatever the template parser produced, and both parsers cdkd feeds this from pass the literal through:aws-cdk-lib'sCfnParameter._toCloudFormationemitsDefault: this.defaultwith no conversion, so{type:'Number',default:42}synthesizes the JSON NUMBER42and a list-typed default may synthesize a JSON ARRAY;parseCfnTemplate(src/cli/yaml-cfn.ts, the import / export path) yields a number, a string, an array or a boolean per the YAML scalar. A non-string default is ALREADY in the shape its declared type calls for, so coercing it could only damage it --String(['a,b','c'])is'a,b,c', which the split then shreds into THREE elements. Hence the guard, not aString()normalization. THE SSM SITE NEEDED THE INNER TYPE, NOT THE DECLARED ONE, which is why it is not the same one-line edit.isListParameterTypedeliberately answersfalsefor everyAWS::SSM::Parameter::Value<...>spelling -- right, because the value SUPPLIED for such a parameter is a Parameter Store KEY and splitting a key on,would shred it -- so coercing the resolved value against the declared outer type is a silent NO-OP that fixes nothing. The peel moved into the SHAREDsrc/utils/parameter-types.tsasssmResolvedValueType, besideisListParameterType, andsrc/synthesis/macro-expander.ts-- which had the only other peel, written inline -- now calls it too. Writing a second one in the deployment layer was the first cut and it reproduced the EXACT shape issue #2347 had just deleted from this same module pair: one question, two spellings, and they disagreed (the resolver's required a closing>and a non-empty inner shape; the synthesis one required neither). The shared peel is the stricter of the two, and the synthesis site keeps its own handling of a malformed spelling -- the scalar placeholder, not the generic warn -- so nothing about macro expansion changes. Two AWS-published contracts bear on the coercion itself:cloudformation-supplied-parameter-types.htmldefinesValue<List<String>>/Value<CommaDelimitedList>as "a Systems Manager parameter whose value is a list of strings ... corresponds to theStringListparameter type in Parameter Store" and describes the supplied value in the SINGULAR throughout ("you must specify a Parameter Store key", "you must provide the parameter name"), which READS as one Parameter Store key per parameter -- see the caveat below before relying on that; the SSM service model typesParameter.Valueasstringand states "if type isStringList, the system returns a comma-separated string with no spaces between commas", so a split is the ONLY route to a list and the coercion's space-trim is a no-op against that wire shape rather than a divergence from CloudFormation. The scalarValue<...>forms peel to a scalar inner type and coerce to themselves, soValue<String>is untouched. The change sits STRICTLY AFTER the issue #1002referencedNamesskip, whichcontinues, so it cannot make an unreferenced parameter resolvable again -- pinned by its own case. Off the documented type space,Value<Number>newly coerces toNumber(resolved)--NaNfor a non-numeric Parameter Store value -- where it used to pass through; Parameter Store has no numeric type, so no valid template reaches it. WHAT THE SUPPLIED VALUE MEANS IS LEFT OPEN, AND THE DISAGREEMENT IS RECORDED RATHER THAN DECIDED. The reading above -- one key per parameter, whichaws-cdk-lib's ownStringListParameter.fromListParameterAttributesalso emits (default: attrs.parameterName, a single name, for aValue<List<String>>parameter) -- CONTRADICTS a comment already in this repo.src/synthesis/macro-expander.tsstates the supplied value forValue<List<*>>/Value<CommaDelimitedList>forms is a comma-delimited list of SSM parameter NAMES, and it states it from a LIVE CloudFormation observation: a single-string placeholder against such a type "would reject the changeset withParameter ... must be a list" (the CR-MJ3 fix). A live observation outranks a documentation read, and this change measured neither, so that comment is left exactly as it stands and is NOT overwritten. The two may well both be true of different things -- CloudFormation's pre-macro changeset VALIDATOR demanding a list-shaped literal while the runtime resolves one key -- but that reconciliation is plausible and UNMEASURED, so it is written down as unresolved inssmResolvedValueType's docblock. Nothing in this change depends on the answer: the synthesis site is choosing a placeholder for a validator, and the deployment site is coercing a valueGetParameterhas ALREADY returned, which is downstream of whatever the supplied key meant.resolveSSMParameter's existing singleGetParameteris untouched either way. 24 unit cases walking the WHOLE declared type space, not only the list types, asserted through the CONSUMERS (Fn::Select/Fn::Join/Fn::Split/ bareRef) because a test that only checksresolveParametersreturned something passes on the broken code. Four mutations, each restored and re-verified bygrep -c: reverting theDefaultsite reds 9 cases including the literalFn::Select: list must be an array, got string; reverting the SSM site reds 5, a DISJOINT set, which is the evidence the two arms are independently fenced; replacing the string guard withString(defaultValue)reds exactly the non-string case, onexpected [ 'a', 'b', 'c' ] to deeply equal [ 'a,b', 'c' ]-- the shredding the guard exists to prevent; and leaking the SSM inner peel into the USER-SUPPLIED site reds exactly the carve-out case, onexpected [ '/app/a', '/app/b' ] to be '/app/a,/app/b', which is a Parameter Store KEY shredded on a comma.Fn::Splitover such a parameter is NOW REFUSED, and that is a behaviour change worth stating. A template that wroteFn::Splitover a defaulted list-typed parameter was working around this very bug and SUCCEEDED before, because the parameter WAS a string; the parameter is now already a list, soresolveSplit'sALREADY a listarm refuses it -- matching CloudFormation, which rejectsFn::Splitover a list too, and matching what a user-supplied value has always done. A case pins the refusal AND itssource.kind === 'ref'remedy text, since that text is the only thing telling such a user what to do instead. TWO CASES ARE LABELLED AS NON-DISCRIMINATING RATHER THAN COUNTED AS COVERAGE, because a sentence asserting a fence that cannot fail is worse than no sentence. An earlier draft of this entry claimedNumber"carries both polarities"; it does not. The NUMERIC-default case is green under every mutation by construction (Number(String(42)) === 42), so it documents the shape CDK emits rather than fencing anything -- the discriminatingNumberpolarity is the STRING default, and what fences the guard is the comma-bearing array. The malformed-Value<...>-spelling case is the same:ValuefailsstartsWithwhatever else changes, andValue<>peels to'', which is not list-shaped, so dropping the non-empty check reaches the same passthrough. Both are kept as regression guards and both say so in place. UPGRADE CONSEQUENCE -- THE FIRST DEPLOY AFTER UPGRADING CAN REPLACE A RESOURCE, and it is not merely a cosmetic diff. A state record written by an older binary holds the STRING for a defaulted list parameter, while the new desired side is an ARRAY, soResourceState.propertiesfor any resource fed by such a parameter changes shape. That is a real change, not a display artifact: the nextcdkd deployissues an UPDATE, and where the parameter feeds a create-only / immutable property it is a REPLACEMENT --deploy-engine.tssetsneedsReplacementfromchange.propertyChanges?.some((pc) => pc.requiresReplacement), and a string-to-array change on such a property satisfies it. For a STATEFUL type the stateful guard on that path still applies, so the destructive case is refused rather than executed silently, but the general case is a resource being replaced on an upgrade deploy. Reviewcdkd diffbefore the first deploy after upgrading if any template declares a defaultedCommaDelimitedList/List<...>/AWS::SSM::Parameter::Value<List<...>>parameter. The convergence is one-time -- once the array is persisted the two sides agree -- but calling that "self-healing" would understate it, and an earlier draft of this entry did. A pre-existing divergence is deliberately NOT changed and is recorded instead: CloudFormation documents aRefto aList<Number>as returning a list of STRINGS ("80,20"->["80","20"]), whilecoerceParameterTypedValuereturns numbers. That is the coercion's own long-standing behaviour on the path that was already correct, it is pinned by an existing test, and changing it belongs with its own issue rather than smuggled into a call-site fix.A
List<AWS::EC2::Subnet::Id>(or any otherList<...>) nested-stack child parameter resolves to a LIST instead of a comma-joined string, because the two contradictory answers to "is this ParameterTypea list" are now one shared predicate (issue #2347) --src/utils/parameter-types.ts(new),src/deployment/intrinsic-function-resolver.ts,src/synthesis/macro-expander.ts,src/provisioning/providers/nested-stack-provider.ts,src/cli/commands/diff-recursive.ts(comments only),tests/unit/deployment/parameter-type-list-coercion.test.ts(new),tests/unit/deployment/intrinsic-typed-list-parameter.test.ts(new),tests/unit/deployment/intrinsic-resolver-inherited-parameter-secrets.test.ts,tests/unit/provisioning/nested-stack-provider.test.ts,tests/unit/cli/diff-recursive-nested-stack-secret.test.ts,tests/unit/synthesis/macro-expander.test.ts,docs/cli-reference.md. cdkd held TWO views.coerceParameterTypedValuenamed exactly two list types in aswitch(List<Number>,CommaDelimitedList), so the nineList<AWS::...>types CloudFormation defines fell todefaultand came back as the raw comma-joined STRING;stringifyParamDefaultinsrc/synthesis/macro-expander.tstestedinner.startsWith('List<') || inner === 'CommaDelimitedList', the wider and correct view.aws-cdk-lib's ownCfnParameter.isListTypeagrees with the second, so a CDK app could legally declareList<AWS::EC2::Subnet::Id>, callvalueAsList(), and have cdkd hand the child a string. Both sites now callisListParameterType. THE CONSEQUENCE WAS NOT COSMETIC.Fn::Selectover such a parameter -- the pattern AWS's own Parameters documentation gives for list types -- failed the deploy withFn::Select: list must be an array, got string, andFn::Joinrefused it the same way. A bareRefused as a property value was the silent arm: a provider whose schema declaresSubnetIdsa list received one scalar string.Fn::Splitover one moves the OTHER way and is now REFUSED as "ALREADY a list", matching CloudFormation and the existingCommaDelimitedListbehaviour; a template that split such a parameter was working around this bug. The AWS enumeration was measured, not inferred from the CDK.parameters-section-structure.htmllists the baseTypevalues asString,Number,List<Number>,CommaDelimitedListplus the AWS-specific and Systems Manager families -- a bareList<String>is not among them, and appears only as the inner shape ofAWS::SSM::Parameter::Value<List<String>>. It is nevertheless coerced here, because the CDK accepts it and cdkd deploys such a template without CloudFormation ever seeing it. The predicate isstartsWith('List<') && endsWith('>')rather thanaws-cdk-lib'sindexOf, which would also match the Systems Manager OUTER formAWS::SSM::Parameter::Value<List<String>>-- and that must NOT coerce, since its value is a Parameter Store KEY rather than the resolved list.NestedStackProvider.extractParametershad to move with it, and this was the widening's one BREAKING consequence. It accepted only string / number / boolean and threw "Parameters must be scalars" otherwise, blaming the resolver -- so a parent passingParameters: { SubnetIds: {Ref: SubnetIds} }for a list-typed parameter went from deploying to dying. It now joins an ARRAY with,, copying the shaperesolveChildImportParametersinsrc/cli/commands/export.tsalready used for the same wire question, down toString()-ing each element so aList<Number>array survives. The ELEMENTS are checked, which is the one place it deliberately does NOT copyexport.ts: an uncheckedString(e)turns a nested unresolved intrinsic into the literal[object Object]and ships it to the child deploy -- the exact outcome the top-level refusal exists to prevent, arriving one level down. A non-scalar element is refused by the SAME throw, naming the INDEX, so the top-level arm and the element arm cannot drift apart.export.tskeeps the unchecked form because a failure there degrades the parameter tostillSkippedand warns rather than promising loudness; a comment on the provider says so, to stop the next reader "restoring parity" by copying the hole back. The EMPTY array is an asymmetry, recorded rather than fixed:[]joins to''and the child re-splits it to['']-- one empty string, not an empty list.''is nonetheless the right wire value, because a CloudFormation Parameter is a string with no spelling for "no elements", and CFn's ownCommaDelimitedListhas the same hole. So the round-trip is lossless for a NON-EMPTY array whose elements carry no comma. Two further behaviour changes are asserted rather than suppressed. A secret dynamic reference bound to a list-typed child parameter is now MEASURED byrefuseCoercedInheritedSecretlikeCommaDelimitedListalready was, so a comma-bearing secret in one is refused by name while a comma-free one still resolves with the secret element intact. AndFn::Equalsover such a parameter changes answer --{Ref: Envs}against'prod'was'prod' === 'prod', and is now["prod"]vs"prod"-- which is the CORRECT answer for a list-typedRef; the cost is thatfilterResourcesByConditionprunes a condition-gated resource state still holds, so the next deploy deletes it, bounded bycdkd diffpreviewing that delete. The predicate lives insrc/utils/, not beside either consumer: hosting it insrc/deployment/gave the tree its firstsrc/synthesis/** -> src/deployment/**import, inverting the documented layer order.src/utils/ip-protocol.tsis the precedent, hosted there for the same two-consumers-in-different-layers reason and saying so in the same place. Nine prose sites that enumerated types instead of naming the family were corrected with it, counted on the final tree rather than estimated. SIX insrc/deployment/intrinsic-function-resolver.tsnamed "CommaDelimitedList / List" as the list-source SET -- three of them user-facing message text ( Fn::Join's refusal, and bothFn::Splitremedies, the second of which a user NOW hits with aList<AWS::EC2::Subnet::Id>) and three comments or docblocks; a seventh docblock (inheritedSecretsCarriedBy) said "aCommaDelimitedListparameter arrives as an array" and was widened in the same pass. TWO more --docs/cli-reference.mdandtokenValueForComparison's docblock insrc/cli/commands/diff-recursive.ts-- were written DURING this change and were already incomplete on arrival: they spelled out "CommaDelimitedList, the nineList<AWS::...>types andList<String>", which omitsList<AWS::EC2::KeyPair::KeyName>and the nested spellings that this change's own tests assert do coerce. An enumeration in prose is what went stale the first time, so every one of the nine now names the FAMILY (any List<...> type or CommaDelimitedList), restatingisListParameterType's own definition; where examples appear they are marked as examples, not as the population. A DIFFERENTIAL WALK fences the classifier, since hand-picked cases cannot: 67 parameter types (the full AWS enumeration plus malformed and nested spellings) x 5 probe values against a verbatim transcription of the pre-fix coercion, each differing cell classified by RECOMPUTING the expected value rather than shape-checking it, with ABSOLUTE floors. Both weaker forms were probed and found green before being fixed -- floors derived from the arrays they guarded survived emptying those arrays, and a shape-only class survived mutating the widened arm toreturn [type]. NOT closed here: the SSM default path atresolveParametersassigned the resolved value without calling the coercion at all, so anAWS::SSM::Parameter::Value<List<String>>parameter still resolved to a string. Different root cause, filed as issue #2367 -- which found the LITERALDefaultpath uncoerced too, and closed both (see that entry). The count was the point:resolveParameterswritesparameters[name]at THREE sites and this change touched the ONE that already called the coercion. No live coverage either: no integ fixture declares anyList<...>parameter type (measured), so the widened family has no real-AWS arm and authoring one is new-fixture work.Three more state-bucket delete sites no longer leave the object body readable as a NONCURRENT VERSION (issue #2346, sites 4, 6 and 7 of seven) --
src/state/s3-state-backend.ts,src/cli/commands/bootstrap-destroy.ts,src/cli/upload-cfn-template.ts,docs/state-management.md,docs/import.md,tests/unit/state/rollback-journal-version-purge.test.ts(new),tests/unit/cli/upload-cfn-template-cleanup-destroy.test.ts(new),tests/unit/cli/upload-cfn-template.test.ts,tests/unit/cli/bootstrap-destroy.test.ts,tests/unit/cli/export-templateurl-upload.test.ts,tests/unit/cli/retire-cfn-stack.test.ts,tests/integration/rollback-command/verify.sh,tests/integration/gc-custom-asset-names/verify.sh,src/state/s3-noncurrent-version-purge.ts,src/provisioning/providers/custom-resource-provider.ts,src/cli/commands/gc.ts,tests/unit/synthesis/macro-expander.test.ts,tests/unit/state/purge-object-description-per-caller.test.ts(new).deleteRollbackJournalis the site that mattered, and the only one with a MEASURED disclosure.cdkd bootstrapturns versioning ON for the state bucket, so its bareDeleteObjectwrote a delete marker and left every earlier journal body readable throughGetObjectwith aVersionId.failedOperations[].attemptedPropertiesis the properties of the FAILED write verbatim: measured 2026-08-20 onCdkdDeletionPolicySnapshotHeavyExampleas four surviving versions each carrying a literal"MasterUserPassword": "Cdkdcf2f..."after cdkd reported the state deleted. It now rides the sharedpurgeNoncurrentKeyVersionsthroughS3StateBackend.purgeNoncurrentVersions, on all three of its delete paths (successful deploy, cleancdkd rollback,cdkd destroy/cdkd state destroy).The purge runs even when the delete FAILED, and that is deliberate. The pre-fix code returned early from the
NoSuchKeyarm, which would have skipped it. A not-found CURRENT object says nothing about the key's history -- on a versioned bucket an earlier delete leaves a marker as current with every body still readable -- and the helper'sIsLatestfilter makes the outright-failure case safe too: a key whose delete failed keeps its current version and loses only its history.state.jsonwas deliberately NOT touched (sites 1-3). Its noncurrent versions ARE the state-recovery capability versioning is enabled for; purging them would close a disclosure path and destroy a recovery path in the same edit. That trade-off is unsettled and stays open on the issue. Note thatstate-migrate.ts, which the umbrella lists alongside them, is NOT an unfixed disclosure site at all: it already deletes every version of the keys it removes, so calling it "out of scope" understated it.The journal purge is not free, and the site-5 argument cuts both ways.
deleteRollbackJournalruns on every SUCCESSFUL deploy, so cdkd now issues aListObjectVersionsper deploy that it did not before, and a role still on the pre-#2340 four-action policy gets one warning per deploy until it adds the two grants. That is a version of the cost argument used below to excludelock.json, and it is stated rather than presented as decisive: what separates them is DISCLOSURE. The journal's measured content is a plaintext master password, so a per-deploy list is worth paying;lock.jsonholdsowner/timestamp/expiresAt/operationand nothing else, so the same cost buys only bucket tidiness.lock-manager.deleteLock(site 5) was assessed and deliberately left alone. SUPERSEDED on 2026-09-02 — see the site-5 entry at the top of this file; two of the three claims below were wrong.lock.jsoncarries no secret (owner/timestamp/expiresAt/operation), so the case there is storage cost, not disclosure -- and it is the fastest-accumulating key in the bucket (452 versions on one measured key), on the hot path of every cdkd command. Purging there would add aListObjectVersions+DeleteObjectsround trip to every lock release and requires3:ListBucketVersions/s3:DeleteObjectVersionfor ordinary use rather than only for the destroy and cleanup paths. The issue itself proposes a bucket lifecycle rule on noncurrent versions as the better answer; that call is not settled here.Two sibling suites were asserting the PRE-FIX wire sequence and would have gone green either way.
export-templateurl-upload.test.tsandretire-cfn-stack.test.tsmock@aws-sdk/client-s3with onlyPutObjectCommand/DeleteObjectCommand; the shared helper catches its own construction failure and warns, so the purge would have silently no-opped there whiletoEqual(['PutObject', 'DeleteObject'])kept passing. Both mocks now carryListObjectVersionsCommand/DeleteObjectsCommandand both assertions name the full four-command sequence.The shared purge's warning now names the CALLER's object, because it had become FALSE at three of its four sites. Its parenthetical was hard-coded to "a custom-resource response object that is the handler's full response body, including
Data" -- true while the sidecar was the only caller, and wrong the moment the rollback journal, the bootstrap marker and the transient CFn template joined: a user chasing a warning about a bootstrap marker was being sent to look for a handler response body that does not exist on that path.NoncurrentVersionPurgeOptionsgained an optionalobjectDescription, threaded throughS3StateBackend.purgeNoncurrentVersions(whose own could-not-start warning carries it too) and passed by all five call sites -- one phrase per OBJECT, so the provider's cleanup andcdkd gcshare the sidecar's. Widening the sentence into something vague enough to cover all four was the alternative and is worse: the caller knows what it just failed to purge. The ACTIONABLE half -- the two IAM grants and the by-hand remedy -- is correct everywhere and is deliberately NOT parameterised, which is pinned.tests/unit/state/purge-object-description-per-caller.test.tsdrives two REAL call sites and asserts the parentheticals DIFFER; with the option wired to a constant (the shape the defect had) three of its four tests go red, while a test that merely asserted "a warning was emitted" would pass.The real-AWS arms had to be BUILT, because every existing probe passes with the fix reverted.
rollback-command's step 4b andgc-custom-asset-names's Phase 4b both prove the object is gone withhead-object, which on a versioned bucket answers 404 the moment a delete marker exists -- the exact blindness this issue is about. Both fixtures now assert the key is down toall == 1, noncurrent == 0: exactly one surviving row, which can only be the CURRENT delete marker cdkd's own delete wrote, since the purge filters onIsLatestand can never remove it. That is a POSITIVE marker rather than an absence, so it also fails for a run that died before writing the object and for one whose teardown sweep had already run. Each arm proves its own PREMISE in the phase before it (the object existed with at least one version row) so0 versions and 0 leakscannot read as a pass, and each carries a NEGATIVE CONTROL onlock.json, whose history must SURVIVE -- without it every positive assertion would be equally satisfied by a purge that fired on everything. Measured by executing the helper's JMESPath under the aws-cli's own jmespath against four fixture shapes: fixedall=1 noncurrent=0(passes), revertedall=3 noncurrent=2(fails), died-earlyall=2 noncurrent=1(fails), already-sweptall=0(fails).rollback-commandalso gained the success-path version sweep it never had. Sourcings3-versions.shput it in the population oftests/unit/scripts/integ-s3-versions-harness.test.ts, which immediately failed it for disarming its EXIT trap without a followings3_assert_versions_swept-- correctly, because its step 8 removed the sidecars withaws s3 rmand proved it withaws s3 ls, the vacuous shape issue #2096 was raised about. It now purgesalland asserts zero for both of its stacks after the disarm.Site 7's live arm is in
migrate-from-cfn, whose LARGE variant exists for exactly this path. An earlier revision of this entry claimed no fixture touched the transient upload at all; that was FALSE and is recorded here rather than quietly dropped, because it was derived by greppingverify.shfiles while this fixture drives fromrun.sh.bin/app.tsnames "the large (>51,200B TemplateURL) path" andlib/migrate-large-stack.tsputs the template at "about 67-69 KB in practice". Its existingassert_migrate_tmp_emptyusesaws s3 ls, which reports the prefix empty as soon as a DELETE MARKER exists — the version-blind shape this issue is about — so it passes with the purge reverted. The new arm measures DELTAS around the migrate (the prefix is not cleaned bypreflight_clean, so absolute counts would be measuring older runs) and assertsdelete markers +1 or more, noncurrent +0. The positive marker is delete markers rather than all rows because onlycleanup()'s ownDeleteObjectcan create one: measured against four fixture shapes, an all-rows test PASSES on a run that uploaded and then died before deleting, and the delete-marker test fails it. Theexportfixture was checked and is NOT a second candidate — ~15 resources with a ~1.6 KB inline Lambda payload, nowhere near the ceiling.Why the arm uses a local counter instead of
s3-versions.sh. The shareds3_count_versionscannot answer this question: its_s3v_check_prefixguard hard-requires acdkd/<stack>/<region>/shape and refusescdkd-migrate-tmp/, which lives outside the state prefix by design. Its key-scoped twin needs an exact key, and this key is minted at run time and already delete-markered by the time the assertion runs. Sourcing the helper for the negative control alone would additionally enrol this fixture in the harness fence's caller population and move three more committed counts for no coverage gained.A connection-pool leak was introduced and fixed inside this change, and is now pinned. The first revision of
uploadCfnTemplate'scleanup()placeds3.destroy()after the purge'scatcharm rather than in afinallyunder it, so a throw raised inside that catch skipped the destroy -- reachable only when something had already failed.tests/unit/cli/upload-cfn-template-cleanup-destroy.test.tsreproduces it by making both the version listing and the logger'swarnthrow.CloudControlProviderconfirms the state record's region before EVERY mutation, not only on theNotFoundbranch (issue #2301) --src/provisioning/cloud-control-provider.ts,src/provisioning/region-check.ts,src/types/resource.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/drift.ts,src/provisioning/providers/s3-bucket-provider.ts(comment),tests/unit/provisioning/cloud-control-region-guard-2301.test.ts(new),tests/unit/provisioning/region-check.test.ts,tests/unit/provisioning/cloud-control-s3-delete-identity-2283.test.ts,tests/unit/cli/destroy-runner-interrupt-not-found.test.ts,tests/unit/deployment/deploy-engine-delete-interrupt-not-found.test.ts,tests/unit/deployment/rollback-executor.test.ts,tests/unit/deployment/deploy-engine-provider-secret-masker.test.ts,tests/unit/cli/drift-cross-region-secret.test.ts,docs/provider-development.md,docs/architecture.md. Issue #2283 added a per-type bucket-identity probe todelete(), keyed onAWS::S3::Bucket. The GENERAL check underneath it --assertRegionMatch, comparing the CLIENT's region against the STATE record's -- still ran in ONE place: theNotFoundcatch arm. A wrong-region call usually never REACHES that arm, because a Cloud ControlIdentifieris normally a NAME and the same name commonly exists in the client's region too (the same stack deployed to two regions; cdkd's ownresource-name.tsderiving an identical name from an identical stack plus logical id), so the call SUCCEEDS against the wrong resource instead of erroring.delete()andupdate()now run that comparison unconditionally, for every Cloud-Control-routed type, ahead of every mutating step -- the--remove-protectionflips, the SDK delegations, the #2283 probe, and theDescribeTypebehind the update path's write-only-property lookup -- and refuse non-retryably.update()gained thecontext?: UpdateContextparameter it never declared (the interface has carried it since issue #1732), andUpdateContextgainedexpectedRegion, threaded by the deploy engine, both rollback replay arms anddrift --revertfrom the same values those callers already pass to their delete sites. User-visible change oncdkd drift --revert, and it is deliberate. That command pins its AWS clients ONCE and then loops over stacks in whatever regions state holds, so a revert for a stack outside the ambient region previously issued its write through the ambient region's client -- addressed by a physical id read out of the record, in the wrong region. A Cloud-Control-routed resource in that position now REFUSES with a message naming both regions. Same-region reverts, which is every ordinary run, are unchanged. The result on a cross-region--all --revertis MIXED: only CC-routed resources refuse, while SDK-routed ones still write where the ambient clients point (issue #1981, unchanged here), and the SDK providers' ownupdate()region hole is issue #2245. The refusal is fenced against being read as "already deleted", which is the third member of a family. Bothdestroy-runner.tsand the deploy engine's template-removal DELETE decide a failed delete means the resource is already gone by SUBSTRING-matching the error MESSAGE. This refusal interpolates the LOGICAL ID, so a construct namedHandleNotFoundException-- or a CFn logical id carried in by--migrate-from-cloudformation-- would have made the guard's own throw read as success, dropping the state row over a LIVE foreign-region resource. Both classifiers now checkisMarkedNonRetryablefirst, alongside the existing typed guards for a final-snapshot failure (issue #1352) and a user abort (issues #2053 / #1952); the substring match cannot be made safe on its own, because any needle can appear in a user-chosen name. One comparison, one normalisation.assertRegionMatchgrew aRegionCheckPhasethat changes only the MESSAGE ('not-found'remains the default, so every existing provider call site is untouched), and all three uses inCloudControlProvider-- both pre-flights and theNotFoundarm -- now go through a single private helper that trims and case-folds both sides. Until they shared it, the arm compared RAW: a client region ofUS-EAST-1, reachable from a profile'sregion = US-EAST-1(whichfoldRegionOptiondoes not fold), passed the pre-flight and was then falsely refused by the arm below. Three-way behaviour on an unknown region, fenced in both directions: a state record with NO region (a pre-v2 record) or an empty / whitespace-only one PROCEEDS without even resolving the client's region, a matching region proceeds silently, and a mismatch or an unresolvable client region refuses. Item 3 of the issue is deliberately NOT included and the issue stays open. When the #2283 identity probe cannot answer -- the case a bucket policy denyings3:GetBucketLocationproduces -- cdkd still proceeds with alogger.warnand nothing durable. Routing that into thedeployments/events store needs a NEWDeploymentEventType, which is a persisted user contract with its owncdkd eventsrendering, and the issue settles neither the name nor whether it counts in the destroy summary.The custom-resource RESPONSE SIDECAR no longer survives its own cleanup as a readable noncurrent version, on EITHER of the two paths that delete one (issue #2340) --
src/state/s3-noncurrent-version-purge.ts(new),src/state/s3-state-backend.ts,src/provisioning/providers/custom-resource-provider.ts,src/cli/commands/gc.ts,tests/integration/s3-versions.sh(prose),tests/unit/state/s3-noncurrent-version-purge.test.ts(new),tests/unit/state/custom-resource-response-version-purge-sharing.test.ts(new),tests/unit/cli/gc-custom-resource-responses.test.ts,tests/unit/provisioning/custom-resource-response-version-purge.test.ts(new),tests/unit/provisioning/custom-resource-provider.test.ts,tests/unit/provisioning/custom-resource-provider-response-bucket-region.test.ts. A Lambda-backed custom resource replies through the pre-signed ResponseURL by PUTting its FULL cfn-response body --Dataincluded -- to<responsePrefix>/<requestId>.jsonin cdkd's state bucket.cleanupResponseObjectthen issued a bareDeleteObject.cdkd bootstrapenables VERSIONING on that bucket, so the delete writes a DELETE MARKER and every prior version stays readable throughGetObjectwith aVersionId: cdkd reported the response object cleaned up while a handler-minted secret (a generated password, an issued API key) remained retrievable by anyone holdings3:GetObjectVersion. Both paths that delete one of these objects now purge its NONCURRENT versions.cdkd gcis the SECOND path, and fixing only the first would have shipped the same over-stated invariant the issue is about.gccollects the ABANDONED placeholders -- the ones no run ever polled -- throughstateBackend.deleteRawObjects, which sendsDeleteObjectswith{ Key }and noVersionId. So a changelog line claiming cdkd purges the response object's noncurrent versions would have been FALSE for the sweeper whose whole job is collecting them.gcnow calls the purge after its delete. ONE implementation, called from both, and the fence over it PARSES rather than scanning text — with its blind spots ENUMERATED rather than claimed away.purgeNoncurrentKeyVersionsis a leaf module undersrc/state/: the provider calls it directly, andgcreaches it through the newS3StateBackend.purgeNoncurrentVersions, which supplies the bucket, the region-corrected client andExpectedBucketOwner. A leaf module rather than a method on the backend because the provider is one of the two callers andsrc/provisioning/**has no RUNTIME edge to the state backend today. That fence was defeated by review in four consecutive rounds, and what finally ended it was narrowing the CLAIM, not widening the query. Revision 1 was a substring scan a COMMENT could satisfy. Revision 2 added a hand-rolled comment stripper, which fell to a regex literal ending in//(measured: only theintrinsic-function-resolver.ts:4115shape breaks it —types/assembly.ts:165andappsync-provider.ts:1867carry the same\/\/without the failure), a char class/[/*]/, and a backtick inside a string inside a template. Revision 3 replaced the classifier with a real parse —typescript-v6, the sameparseDiagnosticsdiscipline asscripts/check-provider-error-cause.ts— since comments are trivia and absent from the AST. But revision 3 was STRICTLY WEAKER than the stripper on this repo's dominant idiom: it read only direct properties of the argument literal, so...(cond && { Prefix: p })— aSpreadAssignment, with no.name— was invisible, while the substring scan had seen it. That idiom is 960 sites across 119 files, two of them inside the purge module itself, counted by AST rather than by text (aSpreadAssignmentwhose parenthesis-unwrapped expression is aBinaryExpressionon&&with anObjectLiteralExpressionright operand, over the 331 filesgit ls-files 'src/*.ts' 'src/**/*.ts'returns). Three text rules bracket it at 925 / 936 / 967 by miscounting multi-line and nested forms, which is why the rule is published beside the number instead of the number alone. Verified in an isolated tree, both directions, on the same planted open-coded copy: revision 3 passes 13/13, revision 4 reds 2. Revision 4 walks the argument subtree and resolves a namespace-imported callee. The doc now enumerates what it cannot see — a generickeys: string[]deleter, a re-exported binding, a dynamic-import destructure, a local rebinding, and (the consequential one) an ALIASED delete command, which escapes the deleter set entirely so neither assertion runs. Each blind spot is pinned by an executable case, so a later widening flips a test and forces the list to be updated in the same edit. Bounding the risk, measured:src/contains ZEROimport * as X from '@aws-sdk/...'and ZEROexport { ... } from '@aws-sdk/...'. The header says plainly that the response to a further spelling is one more line in that list, not another query. It is deliberately NOT folded intodeleteRawObjects, whose six call sites (measured:grep -rn '\.deleteRawObjects(' src/) include four indeployment-events-store.ts;tests/integration/s3-versions.shrecords those objects as deliberately NOT delete-markered bycdkd destroy, surviving as CURRENT objects. A blanket purge there would change that behaviour and widen the IAM every caller needs. The purge is opt-in, andgc's response sweep is today's one opt-in. The purge is scoped to a KEY SET and to what is notIsLatest, and both halves are load-bearing.CUSTOM_RESOURCE_RESPONSE_PREFIXis a TOP-LEVEL prefix -- a SIBLING ofcdkd/, not a sidecar under a stack's owncdkd/<stack>/<region>/-- so every stack deploying into the region writes into it concurrently, and a prefix-wide purge would take another deploy's live response object.ListObjectVersionstakes aPrefix, not an exact key, so every returned entry is re-checked against the requested set before anything is deleted; that is what letsgcpass ONE covering prefix for thousands of candidate keys while still deleting only what it collected -- a trade rather than a strict win, since the shared prefix also returns every concurrent deploy's in-flight objects and on a busy account the walk can cost more requests than a handful of per-key lookups. An UNVERSIONED bucket answers with the live object carryingIsLatest: true, which the same filter drops, while a'null'VERSION id is deliberately NOT filtered out on its own -- a bucket whose versioning was SUSPENDED can carry a genuine noncurrent'null'version holding the response body. The purge is CONDITIONAL on two IAM grants, and the recommended policy did not carry them -- so the doc is part of the fix, not a follow-up. It needss3:ListBucketVersions(bucket-level) ands3:DeleteObjectVersion(object-level).docs/state-management.md's "Bucket Policy with Least Privilege" granted exactly four actions --s3:GetObject/s3:PutObject/s3:DeleteObject/s3:ListBucket-- and neither of the two. Because the purge fails SOFT by design (it warns and never aborts), a user on that policy would have gotten a warning and kept the readable secret, which would have made this entry's headline FALSE for the configuration cdkd itself recommends -- the same over-stated-invariant failure the issue is about, one level out. Both actions are now in that block, each with a line saying which capability it unlocks and which of the two ARNs already inResourcecovers it, plus a paragraph on what happens without them. The existing ARNs needed no change (verified by parsing the block: the bare bucket ARN covers the bucket-level action exactly as it already did fors3:ListBucket, and the/*ARN covers the object-level one exactly as fors3:DeleteObject). The same conditionality is recorded intests/integration/s3-versions.sh, where it matters for a different reason: a fixture asserting the purge on a CI role that lacks the grants would be VACUOUS, since the run goes green either way. Two other permission blocks were examined and deliberately left alone --docs/troubleshooting.md's asset-bucket policy (a different bucket the purge never touches) and itss3:*catch-all (already covers both) -- as weredocs/cross-stack-references.md's read-only cross-account consumer policy anddocs/testing.md's CDK fixture snippet. Nothing insrc/mints a GRANT: the only policy cdkd writes isbuildDenyExternalAccessPolicy, a pureDenyofs3:*to principals outside the owning account, which needs no change and is checked from the code rather than inferred from an empty grep. Never throwing is a property of the MECHANISM, not of each call site — and "did not throw" is not the same as "worked". The provider runs cleanup fromfinallyarms and from the timeout arm;gcruns the purge after a collection that has already succeeded, and its surrounding comment is explicit that a partial failure must not describe itself as total. So the helper collects failures and WARNS rather than propagating. Two things review had to force before that warning was worth anything. First,DeleteObjectsreports per-key failures inresponse.Errorsinstead of throwing, and withQuiet: truethat array is the only signal there is — an earlier revision discarded the response, so a principal holdings3:ListBucketVersionsbut NOTs3:DeleteObjectVersion(exactly the reader who adds one of the two doc bullets and not the other) saw✓ Deleted N placeholder(s), no warning, and every version still readable. That is this change's own defect, reintroduced with the warning suppressed; Object Lock, aDenySCP andSlowDownreach the same state. The repo already documented the trap one file over, ondeleteRawObjects. Second, the warning counted listing PREFIXES, not keys: 3000 unpurgedgckeys reported1 key(s)and named the prefix, and the docs quoted that sample as if it were per-key. Failures are now keyed by object key, named up to five at a time with an "(and N more)" tail, and both the docs and this entry quote the real shape. Thegccall also moved into afinally, becausedeleteRawObjectsthrows on ANY per-key failure — a partial delete used to skip the purge entirely and leave the successfully-deleted bodies readable with no version warning at all. The false half of the prose is corrected in the same change, and it was the more dangerous half.tests/integration/s3-versions.shasserted as settled fact thatcustom-resource-responses/<id>.json"is a sidecar under a stack's OWN prefix, so the prefix sweep DOES reach it", and that only one fixture in the swept set uses a custom resource and it carries no secret. Both sentences were false --s3_purge_prefix_versionscannot even be pointed at that prefix, since_s3v_check_prefixrefuses anything not shapedcdkd/<stack>/<region>/-- and an over-stated invariant stops the next reader looking. It is now a THIRD BLIND SPOT paragraph with the same shape as the exports-index one, and it deliberately claims NOTHING about which fixtures invoke a custom resource, since that was not audited. Sixty-five unit cases across five files, and THIRTY-ONE mutations, each applied one at a time with the marker grepped before the result was read and the restore verified after. The assertions are on the S3 COMMAND STREAM — which command, with whichBucket/Prefix/KeyMarker/ExpectedBucketOwner/Delete.Objects— never on "cleanup did not throw", which the pre-fix code satisfies equally well. Thirty of the thirty-one red; the one survivor is asymmetry rather than a gap, and the JSDoc says so: RAISINGDELETE_BATCH_SIZEis green becausestalecomes from a SINGLEListObjectVersionspage andMaxKeyscaps that at 1000, so nothing reaches a second chunk — while LOWERING it to 500 is RED. The constant is fenced from below and not from above. Two earlier claims about it (that the loop was reachable, on the strength of a 1001-entry single-page fixture S3 cannot return; and then that no test discriminated it at all) are both replaced by that measurement. One thing was deliberately left undone. The newsrc/state/module is not added to the.claude/rules/layout-misc.mdinventory: that corpus measures 899,880 B against a 900,000 B ceiling (tests/unit/scripts/rule-file-payload.test.ts), so any useful entry reds the suite and the only way to fit one is to cut text another lane added. Nothing enforces the inventory mechanically andlayout-misc.mdalready coverssrc/state/**at directory granularity, so what is lost is a granularity level, not a guarantee. The ceiling is issue #2310's. A wider sibling class is enumerated but NOT fixed here, and it is not one mechanical sweep — purging noncurrentstate.jsonversions would destroy the state-recovery capability versioning is enabled FOR. Seven sites remain, not the six an earlier draft counted:s3-state-backend.ts's legacy-key migration delete and bothdeleteStatearms,deleteRollbackJournal(whose journal storesattemptedPropertiesverbatim —s3-versions.shrecords a measured literal"MasterUserPassword": "Cdkdcf2f..."across four versions of one),lock-manager.ts'sdeleteLock,upload-cfn-template.ts's transient template body, and — the one the first count missed —bootstrap-destroy.ts:753, which deletes the bootstrap MARKER from the state bucket throughdeleteRawObjectswith no purge. That file does containListObjectVersionsCommand, but foremptyBucketAllVersionson the ASSET bucket, which is why a grep for the remedy skipped over it. The marker carries no secret, so its severity is low and the umbrella says so, to keep the next lane from over-prioritising it.A LIST-valued nested-stack child leaf keeps its OWN
{{resolve:...}}expression on BOTH the persist and the diff side, so twoCommaDelimitedListparameters resolving to one plaintext no longer collapse onto a single reference (issue #2327) --src/deployment/secret-redaction.ts,src/deployment/intrinsic-function-resolver.ts,src/cli/commands/diff-recursive.ts,tests/unit/deployment/secret-redaction-nested-parameter-list.test.ts(new),tests/unit/deployment/intrinsic-resolver-inherited-parameter-secrets.test.ts,tests/unit/cli/diff-recursive-nested-stack-secret.test.ts,tests/unit/deployment/deploy-engine-nested-stack-shared-parameter-plaintext.test.ts,tests/unit/deployment/secret-redaction-nested-parameter-source.test.ts,tests/integration/nested-stack-secret/**,docs/cli-reference.md. Issue #2291 closed this collapse for a child parameter declaredType: String.docs/cli-reference.mdnamesCommaDelimitedListas an equally ALLOWED spelling for a secret-bearing nested-stack parameter, andcoerceParameterTypedValueturns such a parameter into an ARRAY before any redaction runs -- so three string-only sites, not the two the issue named, answered wrongly for it. The persist side: the child's leaf reachedredactByPathas an array beside an intrinsic OBJECT ({Ref: <Param>}), a shape NO arm matched, so it fell to the plaintext-keyed value scan and BOTH members of a coinciding pair took the SURVIVOR's expression.resolveReplayPropsre-resolves what is persisted, so a rollback -- orcdkd drift --revertover a baseline projected from that record, whichlistStacksenumerates as an ordinary stack -- pushed the WRONG secret version to the live resource. SCOPED TO THE PATHS THAT CARRY A SOURCE, which is what this change closes: the deploy's persist choke point, the rollback replay underSTATE_DERIVED_RULES, and the diff. The paths with NOsourceProperties--cdkd state refresh-observedandcdkd drift --accept-- are NOT closed and are not this issue: with an empty bag a one-element list leaf has no identity key, the anchor pass refuses, and the leaf keeps the plaintext. Probed during review and identical before and after this change, so it is the standing issue #2012 / #2036 residual rather than a regression here, anddrift.ts'srevertBaseline = observedProperties ?? propertiesmeans such a baseline can still carry it. POSITION for a list element is NOT the INDEX, which is the question that killed the earlier attempt in issue #2012. There is no source ARRAY to align against -- a list leaf's source is ONE intrinsic standing for the whole list -- so an index pairing would have nothing on the other side and inventing one is exactly the fabrication that review refused. What certifies an element is its OWN VALUE: it must be a whole plaintext this pass recorded, and the association the recorder stored beside this leaf identity must name that same plaintext. Order is therefore irrelevant. Nothing is fabricated: the output array keeps the input's length and order, every element is either its own certified expression or the value-scan answer this module already produced for it, and no element is added, dropped, reordered or copied from the source -- so there is no baseline content here thatcdkd drift --revertcould push to AWS but AWS never reported. The three conditions now live in ONE place and both halves CALL it, rather than each spelling its own.certifiedExpressionForLeafowns the question;certifiedListForLeafapplies it element-wise;positionByCrossStackSource(persist, scalar),positionListByCrossStackSource(persist, list) andinheritedParameterExpression(diff, both) all delegate. Sharing is load-bearing rather than tidy: the two halves must produce the same expression for the same leaf or the desired side of the next diff never matches what was persisted, and two spellings agreeing on every case but one would reintroduce the perpetual UPDATE at that one case. The DIFF side had to move with it, and it is what this change would otherwise have REGRESSED.inheritedParameterExpressionnow returnsstring | unknown[] | undefinedand answers per element for a list. Its second caller changed with it:IntrinsicFunctionResolver.recordInheritedParameterSecretswrites aMap<string, string>keyed by PLAINTEXT, so it now asks per carried plaintext rather than per parameter VALUE. The old spelling gated the override onplaintext === value, which can never hold once the value is an ARRAY -- it typechecked but could not FIRE, so a list-typed parameter kept the collapsed survivor there too. The scalar answer is unchanged by the move, because the recorder's condition 2 already subsumes the old gate: the association's recorded plaintext IS the parameter's whole resolved value, so it can only equal a carried plaintext the old gate also accepted.cdkd diff --recursivewas wrong for this shape independently of all of the above, and its comment is why it stayed wrong:tokenParameterNamesrestored every token-valued child parameter to the RAW string, justified as "exactly what the child's state holds for such a parameter". That holds only for a SCALAR one -- aCommaDelimitedListparameter's state leaf is an ARRAY, so the raw token compared a string against a list and reported a phantom change on every run. A newtokenValueForComparisonbinds the token to the shape state holds, MEASURED from the real coercion rather than enumerated beside it (the disciplineparameterTypeMayLoseSecretIdentityadopted after a hand-kept type list was found wrong about one of its own three entries): an unchanged value, a non-array, or an array carrying a non-string all keep the token, soStringandNumber/List<Number>are untouched whileCommaDelimitedListsplits.List<String>was NOT among them at the time, and read as though it should be: measured againstcoerceParameterTypedValueAS IT THEN STOOD, itsswitchNAMED onlyNumber,List<Number>andCommaDelimitedList--Stringwas grouped withdefault, and every other type fell there too -- soList<String>returned the unchanged STRING, which was what state held for it, so the shim was right about it. Whether cdkd and CloudFormation AGREED on that was filed as a separate question, issue #2347, and has SINCE BEEN ANSWERED -- see that entry below: the coercion now splits the wholeList<...>family, soList<String>no longer returns a string andtokenValueForComparisonsplits it. This paragraph describes the state of the tree at #2327 and is left in the past tense for that reason. TheparameterTypeMayLoseSecretIdentitywarning's tail was corrected with it -- it claimed the diff "keeps the unresolved reference rather than coercing it", which is now false for exactly the type the warning most often names.recordNestedStackParameterExpressions's string-only refusal 1 was NOT widened, contrary to the issue's premise, and the measurement now lives beside it.NestedStackProvider.extractParametersrefuses a non-scalar parent-side parameter value outright ("Parameters must be scalars"), and it runs immediately after that recorder -- so an arrayresolvedValuethere can never reach a child engine. The parent always hands the child a STRING; the array is produced INSIDE the child by its ownTypecoercion. Widening the refusal would certify a shape production cannot deliver, which is why the finding is a comment on the guard rather than a line in a report. A REVIEW ROUND FOUND THE ARM REINTRODUCING THE DEFECT ONE SHAPE OVER, and the fix is a fourth refusal at WRITE time. Condition 3 reads the bag's VALUES throughplaintextIndexOf, and the two readers hold DIFFERENT bags -- the diff side the parent's map, the persist side the issue #2087-scoped per-resource child bag. So the same association could pass on one side and fail on the other, and did: withEXPR_Aalso recorded against a different plaintext (the issue #1933 two-regions shape), the persist side certifiedEXPR_Awhile the diff side refused and fell back to the survivor. Measured over a sweep of 16 bag configurations, of which 4 diverged. The consequence is #2327's own failure mode one shape over -- the child persists the ONE expression the pass has direct evidence resolves elsewhere, andresolveReplayPropsre-resolves it.recordNestedStackParameterExpressionsnow applies that same test at WRITE time, against the parent's map (the bag that WATCHED the resolution, and the only one that can answer the question), so both readers see one table and degrade together to the value scan. Re-measured over that same sweep: 1 divergent, and that row is a member of the BASELINE set -- the arm disabled AND refusal 4 absent also gives 4, but a DISJOINT four, which is what makes "removes three, introduces none" a fact about the sets rather than a coincidence of counts. The surviving row is the value-scan FALL-THROUGH reading a different bag on each side, which is older than this arm, unchanged by it, and now PINNED by a residual case rather than left to be rediscovered. It is reached by TWO shapes and issue #2349 was filed on the narrower description of one: an EMBEDDING element thatcrossStackSourceKeycan never key, AND a BARE element whose parameter refusal 4 refused while its SIBLING's association survived -- the second measured with no embedding anywhere in the bag, and a strict baseline member like the first. Both shapes are PINNED by their own case, the second asserting the divergence POSITIVELY (a change making both sides wrong together must not satisfy it) plus the property that makes it a baseline member: NEITHER side certified, so the divergence belongs to the fall-through alone. Nineteen new unit cases insecret-redaction-nested-parameter-list.test.ts, two inintrinsic-resolver-inherited-parameter-secrets.test.ts(25 total) and two indiff-recursive-nested-stack-secret.test.ts(10 total). The load-bearing ones are the two PARITY cases: both halves over one leaf, same expression per leaf, and the two leaves differing -- "the persist side is correct" is satisfied by a broken diff side too -- and the two-regions case above. Refusal 4 has TWO discriminating cases rather than one: dropping it reds both, while a variant comparing only STRINGS -- ignoringplaintextIndexOf'sCONFLICTING_PLAINTEXTsymbol -- reds only the second, which is what makes it discrimination rather than duplication. They feed the two halves the DIFFERENT bags production feeds them, built the wayrecordInheritedParameterSecretsbuilds them; the helper used to hand the persist sidenew Map(parent), a bag production never produces, and that is what hid the blocker. The KEYS-subset guarantee is stated for what it actually covers: it makes condition 1 answer identically on both sides and says nothing about condition 3 or the fall-through. Seventeen mutations, each applied alone with the marker grepped before the run and every restore verified bydiffagainst a pristine copy, all red. Three of this lane's own tests were found VACUOUS by those sweeps and repaired: the skeleton-refusal case (it refused for the skeleton's reason, not the arm's, and now registers the expression first); the unchanged-resource case (toEqualover an identity claim, nowtoBe); and the empty-string case, whose "premise" assertion was a secondundefined-- the same verdict as the absence it claimed to exclude, so deleting the recorder call left the file green -- now established by a POSITIVE observation on a sibling parameter recorded in the same call. The USER-FACING doc carried the same over-stated invariant one level out, and it said "deliberate".docs/cli-reference.md's--recursivesection told a reader the redacted token is left uncoerced rather than cast by the child parameter's declaredType, which after this change is false for a list type -- and the parenthetical namingType: Number/NaNwas the TRUE half that had to survive. It now states the NARROWING: the token is cast only where every part of it stays a string, soCommaDelimitedListsplits (matching the ARRAY state holds) whileList<String>,NumberandList<Number>stay raw, and the split falls there because cdkd's secret redaction is string-keyed end to end. The doc's paraphrase of thecdkd diffwarning was corrected with the warning itself -- it said deploy "refuses it outright", now true only when the coercion actually destroys the plaintext. The "Secret parameters must beType: String" subsection was UNDER-stating in the same direction: it namedCommaDelimitedListas allowed with no caveat, while a comma-bearing secret in one IS refused (fenced byintrinsic-resolver-inherited-parameter-secrets.test.ts's JSON-blob case), which is the dominant Secrets Manager shape. The integ arm is a LIST-typed pair ontests/integration/nested-stack-secret: ONEAWS::Events::Rulein the child whoseEventPattern.detailcarrieslistA/listB(twoCommaDelimitedListparameters, one plaintext under a newlistsecret key) pluslistPublicas a NEGATIVE CONTROL -- same declared type, same{Ref: <Param>}source, same resource, same walk, and a public value condition 1 must refuse. ONE resource rather than two is sharper here than it is for #2291: two resources would still discriminate TODAY, but only because the #2291 override could not fire for an array, and fixing that override in this same change makes a two-resource shape vacuous.
Recently Implemented (2026-08-27):
One required Parameter no longer discards the
Defaultvalues its SIBLINGS declare duringcdkd import, so aFn::Subbuilt from a defaulted parameter is resolved instead of persisted verbatim (issues #2321 and #2335) --src/cli/commands/import.ts,src/deployment/intrinsic-function-resolver.ts(comments only),tests/unit/cli/import.test.ts.resolveParametersis ALL-OR-NOTHING: it throws on the FIRST parameter declared with noDefaultand no supplied value, andcdkd importsupplies none and has no flag that could.resolveImportedPropertiescaught that throw and continued with an EMPTY bag, so a template mixingVpcId: {Type: String}withStage: {Type: String, Default: 'dev'}lostStage's default too and recorded{'Fn::Sub': 'app-${Stage}-topic'}into the imported resource'spropertiesas the literal STRINGapp-${Stage}-topic-- which then becomes the desired bag of the nextcdkd deployand the bagcdkd destroyhands a provider. (Note the parameter NAMES are reused across the two entries in opposite roles: hereStageis the DEFAULTED parameter andVpcIdthe required one, matching issue #2321's own repro, while the issue #2285 entry below usesStagefor the REQUIRED parameter, matching that change's tests. The names are the issues'; the roles are not the same.) Issue #2285's structuralFn::Subrefusal cannot close this and must not be widened to:StageHAS aDefault, soisUnboundTemplateParametercorrectly answersfalseand refusing there would newly reject a placeholder whose value the template itself declares. The remedy is in the CALLER, which is where the discarded information lives. On that catch,resolveImportedPropertiesnow RETRIESresolveParametersover aDefault-only VIEW of the Parameters section (newdefaultOnlyParameterTemplatehelper) -- every parameter carrying aDefault, nothing else. The retry cannot hit the same throw by construction, sinceisUnboundTemplateParameteris false for exactly the population that survives the filter; re-entering the SAME method rather than hand-binding eachDefaultkeeps the SSM-typed (AWS::SSM::Parameter::Value<...>) lookup and its unreferenced-parameter skip identical to the happy path instead of a paraphrase that can drift; and everything outsideParametersis carried through untouched becauseresolveParameterswalksResourcesto decide which SSM defaults are worth aGetParametercall. ADefault-only retry that fails on its own terms (an SSM default whoseGetParameteris rejected) falls back to the pre-fix empty bag rather than aborting an import that already succeeded against AWS -- the resources are adopted on the AWS side whatever happens here, so a throw would lose the state write for work that cannot be undone. That fallback is COARSER than it needs to be, and the residual is recorded rather than papered over: one unresolvable SSM-typed default discards the PLAIN defaults too, so this bug returns for that template. A third pass over the non-SSM defaults would close it; it is left because this arm is also the one place the resolver'sDefaultexclusion is still load-bearing (see below), so the two have to move together. Issue #2285's refusal and its warn arm are unaffected in BOTH directions, verified by reading rather than assumed.isUnboundTemplateParameteranswersfalsefor aDefault-carrying parameter BEFORE it consults the bound bag, so binding those defaults cannot move a parameter into or out of the unbound population:${VpcId}is still refused and still the only name in the warn'sdeclares parameter(s) with no 'Default' that an import cannot bind (...)clause, while${Stage}now resolves todev. Neither of the two call sites is affected either -- bothawait resolveImportedProperties(...)for itsvoidmutation ofstackState.resourcesand never observe the parameter bag. Seven unit cases beside the existing issue #2285 arms (the file goes 86 -> 93), and the five added in review are the ones that make the DESIGN claims falsifiable rather than merely stated. The first two: the two-parameter template recordsapp-dev-topic, and a second template pairs that with a resource built from${VpcId}as a NEGATIVE CONTROL, asserting the rawFn::Subobject still reaches state and parsing the warn clause toVpcIdexactly (a substring match would pass on a list that grew toVpcId, Stage). The four added:Default: ''must recordapp--topicand a PRESENTDefault: undefinedmust recordapp-undefined-topic, which together fence the'Default' in definitionSPELLING -- review measured that BOTHBoolean(d.Default)andd.Default !== undefinedpassed all 88 cases before them, and the first re-opens this very bug for the standard CFn optional-String idiom; anAWS::SSM::Parameter::Value<String>default must record the LOOKED-UP value withssmSendactually called, which fences the{ ...template, Parameters: defaulted }spread and with it the whole reason for re-enteringresolveParametersinstead of hand-binding; a rejectedGetParametermust still reachsaveState, which fences the inner catch; and a NESTED CHILD template carrying the same mixed parameter shape must recordchild-dev-bucket, covering the SECOND call site (every other case drives only the root one). Seven mutations, applied one at a time with each restore verified by digest against the fixed file: reverting the fix reds 6 (including the nested case);Boolean(d.Default)reds 2;d.Default !== undefinedreds exactly 1 -- theundefined-key case, which is what isolates it; dropping the spread reds 2, onexpected 'app-${ImageId}-topic' to be 'app-ami-0abc-topic'and on the fallback case, whose residual assertion depends on the lookup actually being ATTEMPTED;throw defaultsErrin place of the empty-bag fallback reds 1 withError: process.exit-mock, i.e. the import ABORTS and the state write for already-adopted AWS resources is lost; binding every declared parameter to a placeholder reds 8, including the pre-existing issue #2285 arm; and widening the warn clause's population reds ONLY the negative-control case, onexpected 'VpcId, Stage' to be 'VpcId'. Every one of the seven is reddened by at least one mutation. One is deliberately NOT reddened by the full revert, and saying so is the point: the fallback case asserts a VERBATIMapp-${Stage}-topic, which is also what the pre-fix code produces, so only the targeted mutations discriminate it -- a reader who assumed the revert covers everything would mis-read that case as unfenced. The shared predicate's own rationale is repaired here, because this change falsifies it (issue #2335) -- and the first repair was itself wrong. Two JSDoc paragraphs onisUnboundTemplateParameterdescribed the live caller in the present tense as one that "continues with an EMPTY bag", and the second was the stated JUSTIFICATION for excluding aDefault-carrying parameter from the unbound population: "the only way to reach the resolver with one unbound is to have discarded the whole bag". A reader was being told the exclusion is safe because of a caller behaviour this change removes. The obvious repair -- that the exclusion now "describes a population that no live path produces" -- is FALSE, and the counter-example is inside this same commit, which is why it is recorded rather than quietly replaced. What #2321 fixes is the retry's SUCCESS path. On its FAILURE path the caller falls back to an empty bag,import.tsomits theparameterskey entirely when the bag is empty, so the context arrives withparameters: undefined, and the predicate answers'Default' in definitionBEFORE it ever consults the bound bag -- so aDefault-carrying parameter is unbound, unrefused, and itsFn::Subis written verbatim. The exclusion is therefore PRESENT-TENSE LOAD-BEARING. What removing it would DO downstream is deliberately NOT asserted -- a review probe measured that the refusal is swallowed by the per-resource catch inimport.ts, which warns and persists the raw intrinsic rather than aborting, so the obvious "hard failure" wording was a third false claim in this same paragraph. The shipped text names the fallback arm as the surviving producer, and the residual is PINNED by the test above rather than left as prose. The THIRD site naming the same caller was ALSO corrected, after review found two of its assertions had gone stale with the others: it still named the caller--migrate-from-cloudformationand still carried the same unproved hard-fail consequence. Two further corrections went in with it: the caller iscdkd importin EVERY mode, not only--migrate-from-cloudformation(resolveImportedPropertiessits onimportCommand's unconditional flow, and the new tests reach it with no migrate flag), and the resolver JSDoc's example parameter is renamed${Tier}becauseimport.ts's #2321 comments use${Stage}for the OPPOSITE role -- the one that DOES carry aDefault-- which is a collision this commit created. This paragraph has now been wrong twice in two rounds, in adjacent sentences. It is the one artifact in the change with no compiler and no test behind it, and it is written last, under the momentum of having just fixed the thing; that is the shape to expect, not carelessness.The
CreateBucketLocationConstraintcast is SETTLED as a deliberate stale-enum widening, and all FOUR cast sites are now fenced by tests rather than only described (issue #2322) --src/provisioning/providers/s3-bucket-provider.ts(comment only),tests/unit/provisioning/s3-bucket-provider-location-constraint-case.test.ts,tests/unit/assets/asset-storage.test.ts,tests/unit/cli/bootstrap.test.ts,tests/unit/cli/state-migrate.test.ts.S3BucketProvider.createsendsLocationConstraint: canonicalRegion as BucketLocationConstraint(:6227), and theasasserts a membership the value need not have. No runtime behavior changes anywhere in this PR -- the only non-test edit is comments in the provider; the three sibling source files are untouched and only gain coverage. Both alternatives the issue proposed were MEASURED and both were rejected. (1) Take the SDK's newer type -- the SDK has NOT widened it: against this repo's@aws-sdk/client-s33.1018.0,CreateBucketConfigurationatdist-types/models/models_0.d.ts:1581still declaresLocationConstraint?: BucketLocationConstraint | undefined. (2) Widen the local parameter type tostringand drop the assertion -- this does not compile. With the local bag's field typedstringand theasremoved,tscfails atnew CreateBucketCommand(createParams)withTS2769("Type 'string' is not assignable to type 'BucketLocationConstraint | undefined'"), so the assertion does not disappear; it MOVES to the send site and widens from one FIELD to the whole parameter bag. The cast is therefore kept where it is narrowest. The failure the fence guards is a LOUD one, and an earlier revision of this entry got that wrong in three places at once. It said a membership filter would "silently omitCreateBucketConfiguration, which makes S3 create those buckets inus-east-1" -- a residency bug. That is FALSE, and the correction is recorded rather than quietly applied because the residency reading is the natural guess. The provider's client is REGION-BOUND (:1898this.s3Client = awsClients.s3;getRegion()readsthis.s3Client.config.region()), so aca-west-1create is issued against theca-west-1REGIONAL endpoint, where an omittedLocationConstraintanswersIllegalLocationConstraintException-- the deploy FAILS. Theus-east-1default is documented by the SDK as a property of the GLOBALs3.amazonaws.comendpoint (CreateBucketCommand.d.ts), which this path never uses;src/assets/asset-storage.ts:775already states the rule ("For regions other than us-east-1, LocationConstraint is required") and.claude/rules/asset-bucket-region.mdalready names the exception. Measured, closing the last escape hatch: a genuinely region-less client THROWS rather than resolving empty --new S3Client({}).config.region()withAWS_REGION/AWS_DEFAULT_REGION/AWS_PROFILEunset and both config files pointed at/dev/nullgivesError: Region is missing, sogetRegion()'s|| 'us-east-1'fallback (:1924) is unreachable by that route and the global-endpoint story has no instance. Noted because it is the same defect class this PR exists to fix, committed inside the PR that fixes it, in the same number of copies as the original "four": a spot-check written as a measurement. The fence is unchanged and still earns its place -- "a change that breaks deploys in thirteen regions" is worth pinning -- only the stated reason is corrected. The gap is THIRTEEN regions, not four, and the earlier number was a spot-check written as an enumeration. Issue #2282's entry above, its test file, and the provider comment each namedca-west-1,mx-central-1,ap-east-2andeusc-de-east-1; that list came from probing four names rather than from differencing the two tables. Cross-checking the WHOLE region list against the enum (aws-cdk-lib2.244.0's 46-regionRegionInfo.regionsminus the enum's 33 members) reports fourteen absent, of whichus-east-1is absent BY DESIGN. The other thirteen areap-east-2,ap-southeast-6,ap-southeast-7,ca-west-1,eusc-de-east-1,mx-central-1,eu-isoe-west-1,us-iso-east-1,us-iso-west-1,us-isob-east-1,us-isob-west-1,us-isof-east-1,us-isof-south-1. The one-line derivation is recorded beside the cast and in the test file so the next reader re-derives instead of trusting a list that both tables can move under. Only FIVE of the thirteen are commercial, and an earlier revision of this entry said six:RegionInfo.get('eusc-de-east-1').partitionisaws-eusc, its own partition, whichPARTITION_TABLEinsrc/utils/aws-partition.tslists beside theus-iso-/us-isob-/us-isof-/eu-isoe-prefixes. All thirteen are swept and the partition distinction is deleted rather than restated: the production gate is a singlecanonicalRegion !== 'us-east-1'with NO partition branch, so every one of them traverses byte-identical lines and a split would have been a classification to maintain that bought no coverage. What is measured is the TYPE claim -- no deploy was made to any of the thirteen, so nothing here asserts that S3 accepts each one, which an earlier revision of the provider comment over-claimed. The direction that needed fencing is not removal of the cast. Removing it fails to compile, as measured above, so it is self-fenced. What compiles is a future "soundness fix" that filters the region to enum members: everyus-east-1andeu-west-1case stays green whileCreateBucketConfigurationis omitted for the thirteen. Three of the four cast sites were effectively unfenced, which is why this PR is not provider-only.grep 'as BucketLocationConstraint'gives four: the provider (:6227),src/cli/commands/state-migrate.ts:311,src/cli/commands/bootstrap.ts:274,src/assets/asset-storage.ts:778. A contributor applying the membership filter and grepping those four would have had exactly one red. Measured per site with that filter applied one at a time:bootstrap.test.tsandstate-migrate.test.tshad NO row that reds at all -- bootstrap asserted only the NEGATIVE polarity (two rows requiring the field to be ABSENT, which between them cannot fail on a change that stops sending it), and state-migrate stubbedCreateBucketCommandwithout ever looking at its input.asset-storage.test.tsis the worst case because it looked covered: itspasses LocationConstraint for non-us-east-1 regionsrow pinsap-northeast-1, which IS an enum member, so it stays GREEN under the filter. It did red on one row --derives an INVALID bucket name and LocationConstraint from an upper-cased region-- but only INCIDENTALLY: that row exists to pin an upper-cased-region defect, and its value is enum-absent by accident of casing rather than by design, so it does not fence this class deliberately and nothing keeps it doing so. Each of the three now carries a row pinning an enum-ABSENT region (ca-west-1) on the wire, and state-migrate also gains theus-east-1negative control it lacked, so "always send the constraint" cannot pass either. The three sibling SOURCE files are not modified. Twenty new unit cases across four suites, counted by running them rather than by reading the source (it.eachexpands): the four files now report 25 / 112 / 23 / 13 (vp test run). In the provider suite, on top of issue #2282's nine: thirteen sweep rows requiring each enum-absent region to reach the wire verbatim, one requiring a mis-casedCA-West-1to arrive asca-west-1(the two issues meet there -- #2282's fold has to survive on a region #2322 says the enum omits), one asserting every pinned name is a REAL region, and one asserting the gap still EXISTS. The real-region row exists because the pinned list is otherwise a hand-written literal with nothing checking it: measured, substituting'totally-not-a-region'for'ap-east-2'left every row GREEN, because a bogus name still round-trips through the provider and is still absent from the enum -- so a typo would silently drop a real region's coverage while both its row and the floor kept passing. The gap row is a FLOOR (absent.length > 0), not an exact match, so the SDK catching up on one region does not red the file; it reds only once the enum has caught up on all of them, which is exactly when the cast and both comments should be re-derived. (Accepted limit, deliberate: if twelve of the thirteen joined the enum the floor would stay green while those rows stopped discriminating.) Both it and the real-region row carry a guard-the-guard pinning both polarities of their own lookup, without which an empty member set or an empty region table would satisfy them vacuously. The pre-existingus-east-1rows remain the negative control -- the field must still be OMITTED there.getRegion()'s|| 'us-east-1'fallback is documented as MEASURED DEAD rather than changed (:1924) -- a provider behaviour change wants its own PR. It is dead becauseconfig.region()rejects rather than resolving falsy; the comment records that, and records why it is still worth fencing: if a refactor ever caught that rejection, this line would place the bucket inus-east-1silently, which is the one outcome the gate below cannot catch. Eight mutations, applied one at a time and restored between, each restore verified by digest against a snapshot. Four are the membership filter applied at each cast site in turn, and this is the measurement that justifies the sweep: at the provider it reds 14 of 25 (all thirteen sweep rows plus the mis-cased row) while leaving every #2282 row green; atasset-storageit reds 2 of 112 (the new row, plus the incidental uppercase-region row described above); atbootstrap1 of 23; atstate-migrate1 of 13 -- where before this PR the last three were 1-incidental, 0 and 0. The other four pin the provider suite's own rows: sending the rawregioninstead ofcanonicalRegionreds 4 including the mis-cased row (which is how that row is shown to discriminate on its own axis rather than duplicating theeu-west-1rows); swapping the pinned list for regions that ARE in the enum reds the gap row; emptying its member set reds that row's guard-the-guard; and substituting a bogus region name reds the real-region row. Every new case is reddened by at least one mutation.Four THROWN error messages no longer write the caller's account, role and session into the persisted deployments store (issue #2302) --
src/utils/aws-failure-text.ts(new),src/assets/asset-storage.ts,src/provisioning/providers/s3-bucket-provider.ts,src/deployment/retryable-errors.ts,src/deployment/retry.ts,tests/unit/assets/asset-storage-thrown-identity.test.ts(new),tests/unit/provisioning/s3-bucket-provider-thrown-identity.test.ts(new).assertAssetBucketRegion'sASSET_STORAGE_FOREIGN_REGION_BUCKETrefusal andS3BucketProvider'screate/update/deletewraps each interpolated a raw AWS failure message into a THROWN error. That is a different surface from the terminal warns PR #2290 fixed:extractDeploymentEventErrorcaptures a thrown message intodeployments/{runId}.jsonl, which outlives the run and is explicitly restricted to error plus metadata. On the headline population -- a principal withouts3:GetBucketLocation/s3:CreateBucket, or a bucket policy thatDenys it, the latter settable by anyone holdings3:PutBucketPolicyon the target -- S3 words itsAccessDeniedasUser: arn:aws:sts::<account>:assumed-role/<role>/<session> is not authorized to perform: ..., so the caller's account id, role name and session name landed in that durable store. The split now happens BEFORE the throw, which is the one part of the sibling PR's answer that does not copy across: the CLASS goes in the thrown message with a--verbosepointer, and AWS's own text goes tologger.debug. Both halves are emitted, because a redaction that DISCARDS the message is its own defect -- AWS's wording is what separates a missing IAM grant from a bucket-policyDeny, and that distinction is the operator's next action. The redaction is NARROW, and the obvious wider polarity was measured to be a regression before it shipped.describeAwsFailurekeys on the smithy marker fields ($metadata/$fault/$response) -- the same AWS-shaped testextractDeploymentEventErroralready uses -- rather than on "cdkd did not author it, i.e. it is not aCdkdError". The four sites are BROADcatchblocks, ands3-bucket-provider.tsalone raises six cdkd-authored plainErrors inside them: theBucketEncryption/OwnershipControlsarray refusals, the EventBridge and inventoryEnabledrefusals, the destination-shape refusal, anddeleteBucketWithEmptyRetry's non-empty-bucket refusal, whose message IS the CloudFormation-parity remediationdocs/troubleshooting.mdquotes verbatim. Reducing those to the tokenErrorwould delete the remedy the refusal exists to deliver -- the same defect as dropping AWS's message, arriving from the other side. Two negative-control cases pin it, and a mutation widening the predicate to everyErrorreds exactly those two while leaving all ten identity assertions green. The redaction silently disarmed the RETRY classifiers, and that half is fixed here rather than deferred.withRetryclassifies by SUBSTRING over the TOP-LEVEL message, and the deploy engine uses the DEFAULT classifier -- so withholding AWS's wording withheld it from the retry decision too. Measured onS3BucketProvider.createbefore the fix:not authorized to performwent from retryable-on-the-DENSE-IAM-cadence to NON-retryable, and S3'sconflicting conditional operation(OperationAborted, a real race for cdkd's concurrent DAG) from retryable to non-retryable.retryClassificationTextjoins the message chain, and the classifiers read it instead of the top-levelmessage, which stays what thewarn/debuglines print -- the chain text is the union of exactly what the redaction withholds and must never be logged or thrown. This is the missing THIRD walk rather than a new idea:isMarkedNonRetryableandisThrottlingErroralready walk the same chain for the same stated reason, they now share oneMAX_CAUSE_CHAIN_DEPTHso text can never be read deeper than a marker is visible (it was 10 against the marker's 5, which left a refusal marked at depth 6-10 legible but unmarkable), and the marker check runs FIRST, so a cdkd refusal still cannot be resurrected into a retry. The join is OPT-IN, and the enumeration that forced that is the review's most load-bearing measurement. An earlier revision joined the chain unconditionally and justified it with the claim that every wrapper onmaincopies its cause's message. That claim is FALSE --custom-resource-provider.ts'sdescribeWaiterFailurealready withholds the raw waiter payload -- and the shape it was covering for is not rare. A sweep ofsrc/**for constructions that attach acausebut do NOT carry its text enumerates 18 sites, listed in full in the PR body; the decisive one isdeploy-engine.ts:3355, the outer per-resource wrap, whose whole message isFailed to <op> resource <id>and which is UNMARKED and sits on every resource failure in the tree. No denominator is published for that 18, deliberately. Two independently written counting rules -- a regex sweep over constructor argument lists, and the review's TypeScript-AST rule keyed on an object-literalcause:property or a constructor parameter namedcause-- agreed on the eighteen SITES and disagreed by eleven on how many cause-attaching constructions to divide them by. The sites are individually checkable and the ratio is not, so the ratio is withdrawn rather than picked; the load-bearing claim was never the fraction but the flip below. Measured on that wrap, an unconditional join flipsfalse -> truefor aDependencyViolationcause and for adoes not existcause -- a broad retry-semantics change with nothing to do with this redaction. So a redacting throw now STAMPS itself withmarkRedactedCause(the same non-enumerable-symbol idiom asmarkNonRetryable, chain-walked to the same depth) andretryClassificationTextreturns the top-level message for anything unstamped. The no-op is then a property of the mechanism rather than of a survey, and the two negative controls assert exactly that: an unstamped wrapper over a retryable cause stays terminal, and the SAME chain stamped does flip. A SECOND message classifier had to be converted, and it is not reached byretry.ts.destroy-runner.ts's delete-retry loop callsprovider.deleteDIRECTLY, so it classifies on its own and theretry.tsfix does not cover it. Both of its arms now read the chain text.OperationAbortedis HTTP 409 with a non-throttle name, soisThrottlingErrorandisTransientServerErrorboth miss it and the withheld substring was the only arm keeping it retryable; the'Too Many Requests'arm is worse still, since its own comment records that it exists for the case where the original 429$metadatais LOST across the wrap, leaving the message as its only carrier.dynamodb-delete-budget.ts'sisTerminalDeleteFailureis the third instance of the shape and deliberately stays on the top-level message, with the reason recorded there: it classifies only DynamoDB's own throws, none of which redact. 37 unit cases across three new files and two existing suites, counted by running them rather than by reading the source (it.eachexpands, so a source-levelit(count disagrees):vp test run <file>reports 9 + 6 + 12 fortests/unit/utils/aws-failure-text.test.ts,tests/unit/assets/asset-storage-thrown-identity.test.tsandtests/unit/provisioning/s3-bucket-provider-thrown-identity.test.ts, and the two existing suites add 7 and 3 undervp test run tests/unit/deployment/retryable-errors.test.ts -t retryClassificationTextandvp test run tests/unit/cli/destroy-runner-non-retryable.test.ts -t 'REDACTED|UNSTAMPED'. Every AWS fixture carries S3's REAL wording, identity and all: a fixture readingAccess Deniedcannot tell a message that prints the CLASS from one that prints the MESSAGE, which is how the sibling PR's fixture passed for the wrong reason. The account id, the role ARN, the role name and the session name are each asserted absent SEPARATELY, so a partial redaction cannot pass on the strength of the others;debugand the default levels (info/warn) are separate spies, so "logged somewhere" cannot stand in for "logged where it is not persisted"; and aThrottlingExceptionarm in both files stopsAccessDeniedfrom being satisfiable as a literal. Twenty mutations across two rounds, applied one at a time and restored between, each restore verified: un-redacting the AWS branch reds 6 of 12, widening the predicate to everyErrorreds the 2 negative controls, removing eitherdebugline reds 3 and 4, reverting the asset-storage throw reds 3, un-redacting the non-Errorbranch reds 1, revertingretryClassificationTextto the top-level message reds 2, revertingretry.ts's hand-off reds the wiring case, revertingdestroy-runner.ts's reds both of its arms, dropping eithermarkRedactedCausestamp reds 3 and 1, stamping unconditionally reds 1, making the join unconditional reds the 3 opt-in controls and making it never fire reds 8, raising the depth back to 10 reds the paired depth case, re-gating the non-Errorlink ondepth === 0reds 1, dropping the$fault/$responsearms reds one case each, and dropping the empty-namefallback reds 1. Two of those probes found defects in the tests themselves rather than confirming them, which is the reason each half is probed separately: revertingretry.ts's hand-off initially reddened NOTHING, because the classifier cases pinned the functions rather than the call site (awithRetrywiring case was added); and the'Too Many Requests'destroy-runner case first passed under its own revert, because its fixture carried a 429 on$metadataand was therefore satisfied byisThrottlingErrorrather than by the arm it claimed to pin -- the cause now carries no$metadataand a non-throttle name, so the withheld message is the only surviving signal. A third, theredactedgate on the asset-storagedebugline, was unfenced until a probe forcing it ON stayed green. Residue, measured rather than estimated. The shape-based sweep for thrown sites interpolating a failure message (.message/String(x)reaching athrow, comments and strings stripped first,logger.*excluded by construction) reports 448 sites tree-wide after this change, 415 of them in files that import@aws-sdk/*;s3-bucket-provider.tsis now at 0.src/provisioning/providers/s3-bucket-policy-provider.ts's threeFailed to {create,update,delete} S3 bucket policywraps are the nearest siblings and are deliberately out of this delta. The population is far too large for one PR, so the four named sites plus the shared helper land here and the sweep is left for an umbrella issue.CreateBucketnow compares AND sends the CANONICAL region, so a mis-cased profile region no longer kills the create (issue #2282) --src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-location-constraint-case.test.ts(new),tests/unit/provisioning/s3-bucket-provider-us-east-1-preflight.test.ts.S3BucketProvider.createdecided whether to attach aCreateBucketConfigurationwith a case-SENSITIVEregion !== 'us-east-1'over the valuegetRegion()reports, so both halves of the decision were wrong on an unfolded region. The issue reported the GATE: a mis-casedUS-EAST-1took the branch and sent aLocationConstraintfor the one region where S3 requires the field to be OMITTED. The SEND side carried the same defect for every OTHER region and is fixed by the same line --EU-WEST-1interpolated the raw spelling too, so it sentLocationConstraint: 'EU-WEST-1'. Neither is a valid region NAME, so real S3 rejects theCreateBucketoutright and the deploy dies there, before anything else on the path runs. The fix folds once and sends what it compared (canonicalizeRegion, already imported in this file and already used by the guard code beside it), which is why the issue's gate-only framing did not survive the audit: one sentence -- compare and send the canonical region, never the raw one -- describes both sites, and fixing only the reported one would leave every non-us-east-1 region broken in the identical way. The reachable path was MEASURED, and an earlier revision of this entry named the wrong one. It said--region US-EAST-1; that flag cannot reach the gate raw, being folded byfoldRegionOptionatsrc/cli/commands/deploy.ts:182before the client bag is built and folded AGAIN byAwsClients' constructor atsrc/utils/aws-clients.ts:105. The genuine door is that the second fold is CONDITIONAL (...(config.region !== undefined && { region: foldRegion(config.region) })) and every command builds a region-LESS bag when the user named no region (new AwsClients({ ...(options.region && { region }) }), the shapesrc/cli/region-options.ts:142already documents) -- so the SDK's own chain answers from the selected profile'sregion =line, and nothing on that chain folds. Measured against this repo's@aws-sdk/client-s3(3.1018.0), a profile holdingregion = US-EAST-1givesconfig.region() === 'US-EAST-1', which is exactly whatgetRegion()reads. So the population is a mis-cased~/.aws/configon a command that names no region, plus library callers constructingAwsClientswith no region at all -- not users passing--region.AWS_DEFAULT_REGIONis NOT a second door despitenamedCliRegiondeclining to read it: measured, the JS SDK v3 region chain does not read it either (AWS_DEFAULT_REGION=EU-WEST-1resolved to the ambient profile'sus-east-1, identical to the no-env baseline), so a mis-cased value there reaches nothing. Theas BucketLocationConstraintcast is untouched and still unsound, and canonicalization did not make it safe -- worth saying because "not a member of the enum" was this entry's earlier, wrong explanation of why S3 rejects the raw value. Measured: the enum has 33 members, excludesus-east-1by design, and ALSO excludes the real regionsca-west-1,mx-central-1,ap-east-2andeusc-de-east-1, every one of which the cast still sends today and S3 still accepts. The cast is a pre-existing stale-SDK-enum workaround; the reason S3 rejects'US-EAST-1'is that it is not a valid region NAME, which is a different test from enum membership. No consumer depends on the raw spelling reaching the wire:createParamsis acreate()-local literal built at one site, mutated twice and sent once (grep -n createParamsover the provider gives exactly four lines),LocationConstraintis not a CFn property ofAWS::S3::Bucketso it never enters state,effectiveProperties, drift or a readback, and the only test in the tree asserting an uppercaseLocationConstraint(tests/unit/assets/asset-storage.test.ts) pins the SIBLING defect insrc/assets/asset-storage.tsrather than this provider.regionis deliberately left RAW rather than reassigned, and that is the one non-obvious shape here: the issue #2241 pre-flight gate ~45 lines below folds it at its OWN site, and its unit case (recognises us-east-1 through a raw --region US-EAST-1 spelling) is what watches that fold -- canonicalizing the shared binding would have left the fold, and the case fencing it, unable to fail. Measured: removingcanonicalizeRegionfrom the pre-flight gate alone still reds that case and leaves the new file green, so the two folds are independently fenced. That case's own comment called itself HALF-REAL because production died atCreateBucketbefore reaching the steps it exercises; the caveat is now retired and replaced by an assertion that the rawUS-EAST-1create carries NOCreateBucketConfiguration, so the premise is pinned rather than asserted in prose. Nine unit cases, every one asserting the input the stubbedCreateBucketCommandACTUALLY RECEIVED rather than that a helper was called: three us-east-1 spellings (us-east-1/US-EAST-1/US-east-1) that must send noCreateBucketConfiguration, three eu-west-1 spellings that must all send the canonicaleu-west-1, a sweep over three further regions asserting no uppercase letter reaches the wire for any spelling -- which fails on a region the file does not name, unlike the six rows between them -- and two pinninggetRegion()'s|| 'us-east-1'fallback, which a reviewer found completely unfenced and which was re-measured here: withreturn region || 'us-east-1'replaced by a barereturn region, the WHOLE oftests/unit/provisioning/reds exactly one file and exactly two cases -- these two -- with the other 413 files green, so before them a future||->??would have shippedLocationConstraint: ''undetected. That last pair fences a CONTRACT rather than a live path, and the file says so:@smithy/config-resolver'scheckRegionthrows rather than resolving empty, so no real SDK client reaches the fallback today. The mockedconfig.region()is what makes an unfolded spelling expressible at all -- it stands in for the SDK chain resolving a mis-cased profile region, the only way this provider ever sees one. The two LOWERCASE rows are negative controls and are what refuse the obvious wrong fixes. The file primes no*Onceresponses at all: the us-east-1 rows issue a pre-flightGetBucketLocationthe eu-west-1 rows never do, so the mock routes by COMMAND and one arrangement is correct for every row. Seven mutations, applied one at a time to the real provider and restored between, with the restore verified by digest each time: reverting both halves reds 5 of 9 (and the pre-flight case), reverting the SEND alone reds 3, reverting the GATE alone reds 2 (and the pre-flight case), deleting the block outright reds 4 -- the negative-control direction -- inverting the comparison reds all 9, droppinggetRegion()'s fallback reds the 2 new fallback rows, and removing the pre-flight's own fold reds the pre-flight case with the new file untouched. Every case is reddened by at least one mutation and every mutation reds at least one case. Known siblings, and only ONE of the three is reachable -- an earlier revision of this entry listed all three as live, which was wrong. The rawregion !== 'us-east-1'PATTERN appears atsrc/assets/asset-storage.ts:736,src/cli/commands/bootstrap.ts:272andsrc/cli/commands/state-migrate.ts:309, but only asset-storage can be handed a raw value:bootstrap.ts:392passes itrawRegion ?? regiondeliberately, and its uppercase-region defect is already documented and pinned there as a reachability measurement (derives an INVALID bucket name and LocationConstraint from an upper-cased region) rather than as correct behaviour. The other two are already safe --bootstrap.ts's ownregioniseffective.regionfromresolveEffectiveRegion, which canonicalizes on every branch INCLUDING the profile one (region-options.ts:195), and its comment at:82-87names this exact defect class as the reason it scoped the raw spelling to that one argument;state-migrate.ts's comes fromnamedCliRegion(region-options.ts:102), which canonicalizes. Fixing asset-storage's constraint alone would still not make that flow work, because it derives the bucket NAME from the raw region too, which is issue #1820's lane rather than this provider's.An
Fn::Subover an UNBOUND template Parameter refuses instead of keeping the literal${Name}(issue #2285, the parameter residual of issue #2270) --src/deployment/intrinsic-function-resolver.ts,src/cli/commands/import.ts,tests/unit/deployment/intrinsic-sub-unbound-parameter.test.ts(new, 15 cases) and two cases intests/unit/cli/import.test.ts. #2270 stoppedresolveSublaundering a structural failure into literal text, but its predicate asked only about RESOURCES; a placeholder naming a template Parameter that is DECLARED, carries noDefault, and has no bound value still fell through to warn-and-keep, so${Stage}was written verbatim into the resolved properties of a live resource. Two premises of the issue were FALSE and are corrected here. The issue said "there is no upfront required-parameter validation anywhere in the CLI (grepped: none)" and proposed adding one as its shape 2.IntrinsicFunctionResolver.resolveParametershas always raisedParameter <name> is required but no value was provided and no default existsfor exactly this population, anddeploy-engine.tscalls it unguarded at its step 2.5 -- ahead of every resolver context the deploy builds. So a plaincdkd deploynever reachesresolveSubwith an unbound parameter at all: it has already failed, loudly, naming every parameter one at a time. Shape 2 is not a change to make; it is the behaviour that ships. The issue's second false premise follows from the first: it justified excluding aDefault-carrying parameter by "those deploys succeed today, and refusing them would be a hard-failure regression".resolveParametersmerges everyDefaultit sees, so no deploy reaches the resolver with aDefault-carrying parameter unbound either -- the exclusion is still right, but for a different reason (see below), not because deploys depend on it. What the change actually covers is the caller that CATCHES that error and resolves anyway.cdkd import --migrate-from-cloudformationis the live one:resolveImportedProperties(src/cli/commands/import.ts) logs the parameter-resolution failure at debug and continues with an EMPTY parameter bag, on a resolver context that is NOTbestEffort. EveryFn::Subover a template parameter then kept its placeholder, and the resulting${Stage}was PERSISTED into the imported resource'spropertiesinstate.json-- which is the desired bag a subsequentcdkd deploydiffs against and the bagcdkd destroyhands to a provider. That is the path by which the literal reaches AWS; it is two commands, not one, which is why the single-deploy repro the issue describes could not be reproduced. ONE predicate, shared verbatim, because the two sites ask the same question.isUnboundTemplateParameter(name, template, boundParameters)-- declared undertemplate.Parameters, noDefaultkey, and no bound value -- is now consulted by BOTHresolveParameters's required-parameter throw (hoisted above the branches that used to define the population implicitly) and by the renamedsubPlaceholderNamesADeclaredTemplateEntity, which is whatrethrowStructuralSubFailurecalls. Writing a second spelling was the alternative and it disagrees at a real edge: a key PRESENT in the bag with anundefinedVALUE is not a binding --resolveParametersfalls such a key through to theDefaultcheck -- so the obvious!(name in bound)paraphrase would have made the two sites answer differently for it. That edge has its own case. ADefault-carrying parameter the caller never merged is deliberately still tolerated: reaching the resolver with one means the whole bag was discarded, and refusing there would newly hard-fail input cdkd accepts today for a parameter whose value the template itself declares.bestEffort(cdkd diff/cdkd scrub) stays exempt on the same terms #2270 set, and the ORIGINAL error is re-thrown unchanged --Ref Stage not found, still carrying itsmarkNonRetryable-- rather than wrapped, so the substring-matching retry classifiers see what they saw before. The one caller this actually reaches now names the right remedy.resolveImportedProperties' per-resource catch (import.ts) was written for the sibling-not-yet-imported cause and offered "re-import once every referenced sibling is in state" pluscdkd state orphan. Both are useless for an unbound parameter, andcdkd importhas no parameter flag at all, so the operator was being pointed at an action that does not exist. A parameter-shaped clause is now APPENDED (the sibling guidance is untouched -- both causes reach this catch) naming the unbindable parameters, and it is gated by the SAMEisUnboundTemplateParameterrather than by re-reading the error text, so the message cannot disagree with the refusal that produced it. That import also makes the export load-bearing. 15 unit cases on the resolver plus 2 on the import command, and the INPUT is part of the discriminator: every refusing case declaresStageunderParameterswhileResourcesholds only an unrelatedBucket, because a resource of that name would make #2270's existing arm refuse on its own and the case vacuous. Eight mutations, applied one at a time with the file restored and re-verified by digest between each. Removing the parameter arm reds four resolver cases and leaves all eleven controls green -- and, re-measured against the import suite, reds the end-to-end case withexpected 'topic-${Stage}' to deeply equal { 'Fn::Sub': 'topic-${Stage}' }, which is the literal that used to be persisted; removing theDefaultexclusion reds two -- one on EACH site, which is the direct evidence the predicate is genuinely shared, not merely named once; ignoring boundness reds two, again one per site; dropping theundefined-value edge reds two, also one per site; removing the upfrontresolveParametersthrow reds the two agreement cases; dropping thebestEffortearly return reds the exemption case; making the predicate refuse EVERY unresolvable placeholder reds three, which is what fences the over-tightening direction; and suppressing the new import clause reds the import case while leaving its state assertion green. Twelve of the fifteen resolver cases are reddened by at least one mutation. The three that are not -- a bound parameter in the bare${Stage}form, the 2-arg variable-map form, and the escaped${!Stage}-- all short-circuit BEFOREresolveSub's catch, so no mutation of this change can reach them; they are regression guards on paths this change does not touch, and naming them is more honest than implying the whole file is probe-covered. Two controls exist only because a probe showed the obvious one does not discriminate. "Still resolves a parameter that IS bound" passes on the broken predicate too: a bound parameter resolves inresolveRefand never reaches the refusal at all. The DOTTED${Stage.Value}is the only shape that reaches the predicate with a head that is bound (Stage: 'prod', must keep) or key-present-but-undefined(must refuse), so those two carry the boundness clause and theundefinededge respectively. A related pre-existing behaviour is deliberately NOT changed: for the BARE${Stage},resolveReftestslogicalId in context.parametersand so returns theundefinedVALUE rather than throwing, and the placeholder becomes the literal textundefined. That isresolveRef's own contract, on a hot path, and out of scope here.A Cloud-Control-routed
AWS::S3::Bucketdelete confirms the bucket's region before issuing it (issue #2283) --src/provisioning/cloud-control-provider.ts,tests/unit/provisioning/cloud-control-s3-delete-identity-2283.test.ts,tests/integration/s3-lifecycle/. The issues #2227 / #2245 guards live inS3BucketProvider, on the SDK route. A bucket recorded asprovisionedBy: 'cc-api'never reaches that provider --ProviderRegistry.getProviderForstep 2 (the sticky rule) hands it toCloudControlProviderBEFORE the SDK provider is consulted -- and that provider's only region check is theassertRegionMatchon itsNotFoundbranch, which S3 is not expected to produce here because it follows the region redirect for a body-bearing operation. A state record naming a bucket that is ours but lives elsewhere therefore had itsDeleteResourceland on the live bucket in the other region, unrecoverably.delete()now issues oneGetBucketLocationbefore every mutating step -- ahead of the--remove-protectionflips and the SDK delegations too, since a protection flip against the wrong resource is already damage -- and refuses, non-retryably, when the answer disagrees with the state's region. The comparand's fallback population was MEASURED, and an earlier revision of this entry stated it wrongly. It said the fallback covers "the type-onlygetProvidercall sites (destroy / drift / state-refresh)"; all three of those in fact usegetProviderForWITHprovisionedBy(destroy-runner.ts:1284,drift.ts:2122/:3930,state.ts:2340/:2394), and drift / state-refresh never calldelete()at all -- the realgetProvider(sites areimport.ts,deploy-engine.tsandcanonicalize-properties.ts. The genuine population is (a)destroy-runner.ts:1336, which spreadsexpectedRegiononly whenstate.region !== undefined, so a PRE-v2 record (whereregionwas not yet part of the key layout) arrives with none, and (b) any caller threading an EMPTY region string --deploy-engine.tstypes itsstackRegionasstring, so''reaches the provider as a DEFINED value. That second case is why the resolution normalizes empty-to-absent instead of using a bare??:''is neithernullnorundefined, so??would have accepted it, skipped the client fallback, and landed in a warning whose text ("neither the stack state nor the AWS client reports a region") would then be false, because the client was never asked. The probe has three outcomes and all three are distinct. Answered-and-matching proceeds silently; answered-and-differing refuses non-retryably -- the marker is honoured by BOTH loops that wrap adelete(), the destroy path's own loop atdestroy-runner.ts:1328(FOUR attempts --attemptruns 0..maxAttemptsandmaxAttemptsis 3 at:1326-- with the marker gating both retryable arms at:1372) and the deploy engine'swithRetry(retry.ts:332) on the replacement-delete path; a probe that CANNOT answer proceeds but warns at default verbosity, because refusing would strand destroys for least-privilege roles while proceeding silently would let a bucket policy denyings3:GetBucketLocation-- settable by anyone holdings3:PutBucketPolicyon the target -- disable the guard with output identical to a normal destroy. An unresolvable SDK region chain gets that same treatment (the resolution is inside thetry), so the guard cannot fail CLOSED on one undeterminable input while failing open on every other. An ABSENT bucket is a fourth case and is deliberately not the warning one: it falls through to the existingNotFound/assertRegionMatchhandling, so an ordinary re-run of a finished destroy stays quiet. Absence is read from the wire CODE alone, never a message, so a bare 404 from a proxy or an S3-compatible gateway cannot be mistaken for a positive statement about a bucket's region. The probe deliberately omitsExpectedBucketOwner, unlikestate.tsandutils/aws-region-resolver.tswhich both pass it: those ask "is this MY bucket", while this one has to HEAR the foreign answer to refuse, so restoring the convention would turn every cross-account collision from a refusal into a 403 and thence into warn-and-proceed. The refusal's remedy names correcting the state record rather than re-running with--region, because the comparand is the region stored in state and the flag does not change it. 31 unit cases. Both polarities of the routing decision are asserted, and the split is exact rather than uniform:AWS::S3::Bucketplus TWO control types (AWS::SQS::Queue,AWS::DynamoDB::Table) are driven throughdelete()end to end and must issueDeleteResourcewith no probe and no new IAM dependency, while FIVE further control types (AWS::Lambda::Function,AWS::EC2::Instance,AWS::AutoScaling::AutoScalingGroup,AWS::S3::BucketPolicy,AWS::S3Express::DirectoryBucket) are asserted against the exported predicate only. The us-east-1 fold is pinned across all THREE wire spellings -- an ABSENT field,'', andnull-- because AWS's own API documentation calls a us-east-1 bucket's constraintnull(verbatim in@aws-sdk/client-s3'smodels_0.d.ts:6806, and what the CLI renders) -- but that sentence is the doc comment ON the field, and the DECLARED type four lines BELOW it, atmodels_0.d.ts:6810, isGetBucketLocationOutput.LocationConstraint?: BucketLocationConstraint | undefined.nullis not in that union, so a v3 client can present the same fact as an absent field. (An earlier revision of this entry cited:1581, which isCreateBucketConfiguration-- the CreateBucket INPUT -- and placed it "above" rather than below.) A fixture that can only expressnullshares its premise with the production code, which is the one thing a mutation probe cannot falsify. Every fold gets both polarities, so an OVERfold is caught as well as an underfold (eu-central-1must not read as the legacyEUalias). Twenty-three mutations, applied one at a time and restored between, and the counts below were re-measured in one batch on the exact tree this entry ships with -- the two files as committed here,shasum(SHA-1)c3f449d5b323...forcloud-control-provider.tsandef6f2822692d...for the test -- rather than patched from an earlier round -- every count in an earlier revision of this sentence had drifted low, because four cases were added after it was written. Every mutation reds at least one case, none is a no-op, and all 31 cases are reddened by at least one mutation (computed as a set difference over the run logs, not by inspection). Emptying the type set reds 24, removing the guard call 23, inverting the comparison 22, moving the guard AFTER the delete is issued 8, dropping the us-east-1 fold 6, preferring the client region over the state's 5, widening the type set to two control types 3, gating the guard onremoveProtection !== true3, downgrading the indeterminate warn todebug2, treating anullconstraint as the only absent spelling 2, and 1 each for: moving the guard BELOW all three--remove-protectionblocks, making the injected protection entry inert, droppingmarkNonRetryable, collapsing absent into indeterminate, matching absence by message instead of wire code, dropping theEUfold, overfoldingeuby prefix, dropping the region canonicalization, dropping the empty-region normalization, hoisting the client-regionawaitout of itstry, dropping either.trim()(one each), and reverting the remedy wording. One ordering claim was asserted, probed, and found FALSE before shipping. An earlier revision said the guard's placement ahead of the--remove-protectionflips was "fenced by a unit case"; moving the call below all three protection blocks left the suite fully green -- as it still does on any tree without the injected case described next. With the production tables the order is unobservable --AWS::S3::Buckethas nocc-protection-properties.tsentry and is neither SDK-delegating type, so nothing mutating precedes the probe and "zero Cloud Control traffic on a refusal" fences only a mutation that SKIPS the guard. The test now injects accProtectionPropertyentry for the bucket type (test-side only, no production routing changed) so the delete has a realUpdateResourceCommandto issue first, plus a guard-the-guard case proving the injection reaches the provider; the ordering mutation now reds. The two SDK delegations remain unfenced, deliberately -- fencing them would mean putting a delegating type into the checked set, which is a routing change rather than a test. Live arm:tests/integration/s3-lifecyclephase 0c plants two hand-written single-resource state records -- the defect's actual premise, a record written before the guards existed -- one naming a bucket really in this region (the negative control, which must still delete through the Cloud Control route, and without which the other arm would pass on any malformed-state failure) and one naming a per-run unique bucket in another region, asserted on the refusal text AND on the bucket still standing afterwards. Both bucket names are per-run unique because a name that has existed in one region answersOperationAbortedfor well over ten minutes if re-created in another. The arm has RUN against real AWS. The ledger holds ONE row per test, so the row this sentence can cite is whatever ran last:docs/_generated/integ-last-run.tsv(s3-lifecycle, 2026-09-02T12:34:35Z, PASS, 290s), a run made by the issue #2301 item 3 lane, which added a phase 0c-ID arm to this same fixture and therefore re-ran the phase 0c arms described here along with it. The citation originally named 2026-08-27T05:08:35Z / 208s -- the run taken after this PR's own round-4 comment-only corrections, themselves enough to staleinteg-destroyunderhash: diff-- and that row is gone rather than wrong. The verdicts have been identical across every run of it: the XR arm refuses with rc=2 naming both regions and the us-west-2 bucket survives, while the OK control arm still deletes through the Cloud Control route, so the guard is not a blanket refusal. Cleanup was clean on each: 7 deleted, 0 errors, an account-widestate.jsoncount of 0 and no leftover buckets in either region. Accepted residual, with its mechanism CORRECTED:CloudControlProvider.update()is NOT guarded. An earlier revision of this entry saidResourceProvider.update"carries noDeleteContext, so the state's region is not available there at all" -- the conclusion holds but the reason was false, and it overstated the cost of ever fixing it.src/types/resource.ts:793-800DOES giveupdate()a sixthcontext?: UpdateContextparameter (this provider's ownupdatemerely does not declare it); what is missing is the FIELD, sinceUpdateContext(resource.ts:502, which also extendsSecretMaskingContext) carries no region field at all whileexpectedRegionis declared onDeleteContextalone (region-check.ts:27). The remedy is therefore one optional field plus threading it from callers that already holdstate.region, not a signature change across every provider -- a separate change with its own review, filed as issue #2301 together with the non-S3 CC delete types. The delete path is taken first because its consequence is unrecoverable where a misapplied configuration is not.A literal layer-version ARN now PARSES in all eight partitions instead of three, and its partition must agree with its region (issue #2143) --
src/local/lambda-resolver.ts,tests/unit/local/lambda-layer-arn-partitions.test.ts(new),tests/integration/local-invoke-layers/{lib/local-invoke-layers-stack.ts,verify.sh},docs/local-emulation.md.parseLayerVersionArnmatched/^arn:(aws|aws-cn|aws-us-gov):lambda:([a-z]{2}-(?:[a-z]+-){1,2}\d+):(\d{12}):layer:([A-Za-z0-9_-]+):(\d+)$/, which carried TWO independent defects. (1) The partition alternation listed three of eight, so a layer ARN inaws-iso,aws-iso-b,aws-iso-e,aws-iso-foraws-euscdid not parse -- and the caller HARD-THROWS on an unparsed ARN, socdkd local invoke/local start-apion any function with a literal-ARN layer failed AT RESOLUTION in five partitions. (2) The region group required a first token of exactly two letters, the same^[a-z]{2}shape issue #2001 fixed instate-file-keys.ts, which rejects the European Sovereign Cloud partition's four-lettereusc-de-east-1; it also capped the interior<word>-chunks at two, an independent bound. Neither defect subsumes the other: fixing the partition list alone still rejectseusc-de-east-1, and fixing the region shape alone still rejectsarn:aws-iso:lambda:us-iso-east-1:.... The partition is now DERIVED from the region throughderivePartitionAndUrlSuffix(src/utils/aws-partition.ts) rather than hand-listed a fourth time, so the list cannot go stale here independently -- and that also makes the pair SELF-CONSISTENT, which no alternation can:arn:aws-cn:lambda:us-east-1:...used to parse, pairing China's partition with a commercial region. The region group stays SHAPE-based ([a-z]{2,}(?:-[a-z]+)+-\d+) rather than becoming a charset, becausederivePartitionAndUrlSuffixanswersawsfor a region it does not recognise: a charset would makearn:aws:lambda:notaregion:...parse. It is still much wider than the set of real regions --garbage-junk-1matches where the old pattern refused it (measured) -- and that is deliberate, since the segment's POSITION inside anarn:string already establishes it is the region field, so unlike a state-key segment there is no second interpretation for a loose match to steal. The commercial fallback is otherwise the direction that must keep working -- a brand-new COMMERCIAL region still resolves before the table hears about it, while a region in a future partition is refused rather than mis-attributed to commercial, the same trade every other consumer of that table makes. Deliberately NOTisClientSafeRegionfromsrc/deployment/intrinsic-function-resolver.ts: that predicate is charset-based because its job is keeping a value inside a hostname label, and the note it carries already says so. SCOPE: this fixes the PARSE, and the download behind it is still commercial-only -- so "eight partitions" is a claim about resolution, not about end-to-end support.materializeLayerFromArn(src/local/layer-arn-materializer.ts) is a one-line shim re-exporting cdk-local's implementation, and that implementation rebuilds the ARN with a hardcodedawsbefore the SDK call --node_modules/cdk-local/dist/local-studio-BBtUAVNy.js:15214,const command = await buildGetLayerVersionCommand(`arn:aws:lambda:${layer.region}:${layer.accountId}:layer:${layer.name}`, Number(layer.version)). So a layer in ANY of the seven non-commercial partitions still fails atlambda:GetLayerVersion--aws-cnandaws-us-govincluded, and those two are no better off than before, since they already parsed under the old alternation. What this change moves is confined to the FIVE that previously did not parse (aws-iso,aws-iso-b,aws-iso-e,aws-iso-f,aws-eusc): for them the failure shifts from cdkd refusing to read a perfectly valid ARN to an AWS-side error naming the real blocker. Nothing in this repo can close the rest: it is filed as go-to-k/cdk-local#575, which also records that cdk-local's ownderivePartitionAndUrlSuffixknows only four of the eight prefixes. UPGRADE NOTE -- one shape that used to work now hard-throws. A literal layer ARN whose partition disagrees with its region --arn:aws-cn:lambda:us-east-1:...,arn:aws:lambda:cn-north-1:...,arn:aws-us-gov:lambda:us-east-1:...-- parsed before and is now refused withcdkd cannot resolve locally. This is deliberate and was asked for by the issue's own direction ("which also makes the pair self-consistent"). It costs nothing real: because of the commercial-ARN rebuild above, such a layer could never have been fetched from the partition its ARN named, so the refusal only moves an inevitable failure earlier and gives it a message that says what is wrong. A correctly-paired ARN in any partition is unaffected. The refusal message gained one sentence for exactly this class, and only for it -- every other rejection is visible in the ARN a user is looking at (a missing version, a 13-digit account,functionwherelayerbelongs) whereas a mismatch looks ordinary. The sentence has TWO arms, becausederivePartitionAndUrlSuffixanswersawsboth for a genuinely commercial region and for one noPARTITION_TABLEprefix matches: on a table HIT it states membership as fact (...which is in partition 'aws-eusc'.), and on a MISS it states the RESOLUTION instead (...no partition prefix matches that region, so cdkd resolves it to the commercial partition 'aws'.). A single assertive arm would tell a user thatarn:aws-iso-g:lambda:us-isog-east-1:..."is in partition 'aws'", which is a guess asserted as fact. Which arm is which is easy to get backwards, and the first cut of this split did:PARTITION_TABLEholds seven rows and none of them isaws-- the commercial partition is the fallbackreturn, not a row -- so the MISS arm is the arm every genuinely commercial region takes, and the HIT arm can never renderawsat all. Its first wording, "no partition prefix cdkd knows matches that region", was true of the mechanism and read as cdkd failing to recogniseus-east-1, which is both the commonest AWS region and the canonical example this entry, the docs and the integ fixture all use. Review caught it; the wording now leads with the prefix test and ends on the resolution, and a unit negative pins the old phrasing out. Tests treat this as the CLASSIFIER it is. Hand-picked cases cannot fence an accept/reject function -- a pattern widened until it accepts everything satisfies every positive assertion ever written -- so the new suite runs a DIFFERENTIAL fence: the pre-fix regex, transcribed fromorigin/mainrather than from memory, is run against the shipped code over 352 generated inputs (10 partition strings x 28 region shapes, plus 72 structural malformations of four self-consistent bases), and every difference must fall in an enumerated intended class. Cells are classified by the value the NEW code returns, not by the input's shape, so a total regression lands wholly in one bucket and fails that bucket's predicate instead of being sorted into "expected" ones; the predicate itself is an INDEPENDENT oracle built fromsplit(':')/split('-')plus a hand-written prefix table (pinned againstPARTITION_TABLE), so it cannot agree with the implementation by construction. Each class carries a floor -- all five previously-missing partitions present among the newly accepted, at least oneeusc-region, at least one region past the old{1,2}cap, at least 10 newly-rejected mismatches (measured: 11 newly accepted, 40 newly rejected, 0 parsed-field drift, 13 unchanged accepts, 288 unchanged rejects) -- so a pool that stops covering a class cannot pass as "no regressions". On top of it: one case per partition pluseusc-de-east-1, all driven through the REAL CALLER (resolveLambdaLayers) so what is asserted is the hard-throw, and 16 negative controls that must still be REJECTED. Mutation-probed in three directions, each half separately, because a single all-or-nothing revert cannot show the two are independently fenced: reverting the PARTITION half alone reds 10 (the five per-partition cases, the eusc pair, all three mismatch controls, and the fence's newly-accepted predicate); reverting the REGION half alone reds 4 (the eusc pair, the three-hyphen-group case, and the fence's partition floor); widening the region to[^:]+with no derivation check -- the "accepts everything" direction -- reds 10, nine of them negative controls. Three more probe the message: collapsing the two arms into the assertive one reds 2, emitting a hint for a shape rejection reds 1, and unwiring the hint reds 3. Structurally, ONE exportedclassifyLayerVersionArnnow returns either the segments or a TYPED rejection, and both the resolved layer's fields and the error message read that one verdict -- so the message cannot describe a different verdict than the parse reached. The first cut had the message re-run the pattern in a separate helper, which left an arm -- "the partitions agree" -- that the sole call site could never reach, since it only asked after the parse had already refused; one classifier removes the arm rather than justifying it, and runs the regex once. The oldparseLayerVersionArnexport is GONE rather than kept as a wrapper, and the reason is worth recording because the repo's own critic found it: once the resolver moved to the classifier that wrapper had nosrc/caller left, andscripts/check-local-reachability.ts(shipped days earlier by #2293) reported it as an orphaned export -- 7 of its 39 cases red. Its opt-out tag would have been a FALSE statement here, since it asserts cdk-local owns the live implementation and cdkd owns this one, so the wrapper was deleted and theT | undefinedshape its cases were written against became a three-line adapter insidetests/unit/local/lambda-resolver.test.ts. Naming that tag in a JSDoc comment while explaining the decision then reported a STALE annotation on a live symbol, which is the same critic working correctly from the other side; the comment now describes the tag instead of spelling it. Thelocal-invoke-layersinteg fixture gains aMismatchedArnLayerHandlercarryingarn:aws-cn:lambda:us-east-1:...and a fourthverify.shtest asserting cdkd refuses it. That is the half of #2143 an integ CAN exercise -- the five previously-unsupported partitions have no endpoint reachable from a normal dev account, whereas a mismatch is a purely local verdict with no network call -- and what it adds over the unit matrix is that it runs the SHIPPEDdist/bundle, where a brokensrc/local->src/utilsimport would show up. It is discriminating rather than decorative: pre-#2143 that ARN parsed and cdkd went on to attempt alambda:GetLayerVersiondownload, and the test asserts the refusal names the DERIVATION rather than merely refusing -- a bundle whose layer parse broke wholesale would also refuse.docs/local-emulation.mdis corrected as part of this, and the correction predates this issue: its "Lambda Layers" section still listed literal-ARN entries under "Out of scope (v1) -- hard-errors", and its v1-scope table still deferred them to a "Future PR", both of which stopped being true when issue #448 shipped literal-ARN resolution. They now describe what ships -- the download-and-merge flow, the partition rules with the commercial-only download limitation called out beside them, and--layer-role-arn, which existed as a flag but appeared in no documentation at all and is now in both thelocal invokeand thelocal start-apiflag tables.
- ✅ The secret-mask critic's masker recognition was receiver-blind, file-wide
scoped, and blind to a bare logger call (issue
#2269) —
scripts/check-provider-secret-mask.ts,.claude/rules/layout-scripts.md. Three findings and five nits from the review rounds on PR #2265 (issue #2178), all on one predicate: what the critic accepts as "the value reached the project's masker". No shipped exposure — the real tree classified 83 files / 41 sites / 37 masked / 4 exempt / 108 derived masker names before and after, bit for bit. (1) RECEIVER-BLIND. Every masker position accepted any property access whose FINAL name landed in the derived set, so with ~108 derived names tree-wideconst junk = { maskLeaf: (t) => t }bought bothmaskDeep(p, junk.maskLeaf)andjunk.maskLeaf(p)amaskedverdict; the fence really read "the value reached something whose last identifier collides with a masker name", which is not what KNOWN BOUND (4) claimed. Now receiver-checked — the CONTRACT's ownmaskSecretson any receiver (that IS the bound, and stays self-probed), a DERIVED name only offthisor a chain rooted at it — and the bound is restated in both artifacts. Free on the real tree, whose only property-access maskers arethis.maskErrorMessage(18) andthis.maskedRetryLogger(5). (2) FILE-WIDE IDENTITIES.identitieswas one name pool, so a class whosecreate()bindsconst mask = maskerOrIdentity(context?.maskSecrets)and whosedelete()bindsconst mask = maskerOrIdentity(undefined)had BOTH arms readraw— a false POSITIVE on the correct sibling, dodged in #2265 only by spelling the delete armDELETE_PATH_UNMASKED. Refusals are now recorded with the function that owns them; a module-scope one still reds everything. Scoping ALONE would have traded that for a false NEGATIVE, sincenamesstays a file-wide pool, somaskersAtsubtracts the refusals in scope at a site from the names in scope at it and rule 1 records a wrapper at its own lexical scope — both directions carry a probe. (3) A barelogger.warn(JSON.stringify(x))reaches the same sink as a site and was neither classified NOR counted, because the concat counter fences only+operands. It now gets bound (1)'s treatment as KNOWN BOUND (5) (MAX_BARE_SINK_SITES). A LEVEL call is matched RECEIVER-first with its own refuse twin; a FACTORY call is matched by NAME (wrapError/wrapUpdateError/handleError), which is a REVIEW-ROUND correction of exactly the over-claim this critic exists to prevent: the first cut requirednew, so the 33 messages this corpus throws throughthis.wrapError(...)(24) andthis.wrapUpdateError(...)(9) sat outside the count while the prose called the zero measured, andthrow this.wrapError(JSON.stringify(properties))ran at exit 0. Two receivers were missed the same way —options.warn(...)andgetLogger().child('SNSTopicProvider').warn(...), the latter putting a CallExpression in the receiver position — so the receiver reader now WALKS the chain. The zero is now measured against the WHOLE population: 331JSON.stringifycalls in the corpus = 41 interpolated sites + 0 concat operands + 0 bare message sinks + 290 residue, and the residue is entirely non-message (228 deep-equality comparisons, 41?:value arms, 10returns, 6 arrow bodies, one??, one=, oneMessage: JSON.stringify(request)on an SNSPublishCommandatcustom-resource-provider.ts:2182, onecreateHash('sha256').update(...)atcloudwatch-anomaly-detector-provider.ts:421and oneBuffer.from(...)). The widening's COUNTER-DIRECTION was then measured and closed, because a guard that reds correct code is worse than the miss it replaced:throw this.wrapError(JSON.stringify(maskDeep(v, m)))had the value already through the project's masker and failed CI, so a MASKED bare sink is no longer counted — decided byisMasked, the same predicate the interpolated-site path uses, so the two populations cannot drift. The failure now LOCATES each site and names which FORM matched (<file>:<line> (logger call | error factory | error constructor)), the same argument that addedtruncatedFiles. Two more escapes were closed — a cast argument (logger.warn(JSON.stringify(v) as string)) counted zero because the parent walk stripped parentheses only whileunwrapstrips casts everywhere else, andwrapDeleteErrorescaped the NAME list, now matched by a^wrap[A-Za-z]*Error$pattern whose durable half is an ENUMERATING test over everythrow <call>(...)callee in the corpus that fails on a name neither the predicate nor an audited not-a-factory list knows. Three remaining loosenesses are STATED rather than left to be found, all fail-closed with zero live hits: the receiver reader matches any name in a chain, the factory arm ignores the receiver, and an aliased logger (const sink = this.logger; sink.warn(...)) escapes. The nits:maskDeep(v, m, 1)classified raw because EVERY argument past 0 had to be a masker (index 1 still must be, indexes past it may be a CONSTANT option, somaskDeep(v, undefined)stays refused);maskDeep(v, flag ? a : b)classified raw for want of aConditionalExpressionarm (both arms required, unlike??, whose left-arm rule exists for the contract's own fallback); the fixpoint cap truncated SILENTLY and now fails the run;file.startsWith(REPO_ROOT)matched a sibling by prefix and now matches on a path segment, which matters because that branch mints the stringEXEMPTis keyed on; and the deadreport?.filesScannedis gone with the scan-failure path returning through one shared reporter. A FOURTH route to a masker that masks nothing surfaced from the differential rather than the finding list and is closed here too:const mask = maskerOrIdentity(undefined); const maskLeaf = (v) => maskDeep(v, mask)mademaskLeafa masker on BOTH sides of the change, becausewrapsFirstParameterread only the callee. Verified by a DIFFERENTIAL fence, because probes are cases chosen by whoever chose the rule — which is how the receiver-blindness shipped past 50 of them.tests/unit/scripts/provider-secret-mask-recognition-2269.test.tsruns the frozenorigin/mainclassifier (tests/unit/scripts/fixtures/secret-mask-baseline-2269.ts, verbatim, never re-synced) beside the live one over the 83-file real corpus plus a CROSS-PRODUCT pool, and requires every disagreeing cell to land in an enumerated class whose declared transition it actually shows. Both directions are watched — a cell a class CLAIMS and that no longer moves fails too — and each class carries a FLOOR, with the coverage guards written as EXACT products rather than floors (a floor let a dropped generator arm slide while the class floors still passed). Measured: 98 cells, 39 differing (receiver-narrowed 15, identity-scoped 8, wrapper-launders-identity 6, option-argument 6, conditional-masker 4), ZERO differences over the real corpus. 36 new self-probes (50 -> 86) and 30 new unit tests plus 4 new spawn probes on the existing suite, each fix mutation-proven ONE AT A TIME — 31 probes: one per fix, a partial-revert twin for four, and nineteen more across three review rounds. Bound (5)'s own looseness is stated with the DIRECTION of each item (one fail-CLOSED, three fail-OPEN with zero live hits) rather than as a blanket fail-closed claim, which would have been this critic's own defect committed inside its remedy; and the two spellings of the transparent-wrapper question —unwrap's strip and the parent-chain test — are bound together by a test that parses thets.isX(guards out of both bodies and requires the sets to be equal, since their DIVERGENCE was the cast escape this round closed. The twins are what found the sink RECEIVER check unfenced (theBuffer.fromrefuse case rejects on the METHOD name and never reaches it) and thewrapsFirstParametercopy of the trailing-option guard unfenced (relaxing it left all 92 tests green while makingconst maskLeaf = (v) => maskDeep(v, undefined)a masker — a wrapper laundering a no-op).--jsonstdout is now pure data: the human summary moves to stderr under that flag, matching the sibling criticcheck-local-reachability.ts, so--json | jqno longer fails on trailing prose. - Two nested-stack
Parametersresolving to ONE plaintext now keep DISTINCT{{resolve:...}}expressions across the parent -> child handoff (issue #2291) --src/deployment/secret-redaction.tsandsrc/deployment/deploy-engine.ts, plustests/unit/deployment/secret-redaction-nested-parameter-source.test.ts,tests/unit/deployment/deploy-engine-nested-stack-shared-parameter-plaintext.test.tsand a new arm ontests/integration/nested-stack-secret/. A parent stack resolves its child'sParametersblock, so the child engine receives PLAINTEXT while the child's own template spells the consumption as{Ref: <ParamName>}. Two references to one secret whose values coincide -- the same secret and JSON key at two version-stage spellings, where...:handoff::and...:handoff:AWSCURRENT:resolve identically -- therefore collapsed for TWO independent reasons: the parent'sRecordedSecretValuesbag is keyed by PLAINTEXT, so the pair became a single entry BEFORE the child engine was constructed, and the child's intrinsic-object source leaf carries no expression for the position pass to certify against. Both child leaves persisted whichever expression the parent recorded LAST, so a leaf held its SIBLING's version stage -- andresolveReplayPropsre-resolves what is persisted, socdkd drift --revert/ rollback would have pushed the WRONG secret version to the live resource. The parent now records, per child PARAMETER NAME, which expression that parameter was resolved from (recordNestedStackParameterExpressions, at the deploy engine's CREATE and UPDATE call sites for anAWS::CloudFormation::Stackrow) -- derived from the POSITION pass over the parent's own UNRESOLVED template, which is the only uncollapsed source left once the bag has collapsed. Those per-parameter entries are copied onto each child resolver context's bag as ordinary{Ref: <Param>}position associations, so the existing three-condition cross-stack reader answers for them with no new arm. The DIFF side moves with the persist side (redactParametersForDiffnow answers per parameter first): without it the losing parameter's desired side would keep the survivor's expression forever and its resource would report a spurious UPDATE on every deploy. Every refusal degrades to the plaintext-keyed value scan, i.e. to the previous behaviour, so no non-nested path changes. Three findings from this lane's own review rounds are worth recording, because each is a trap for the next change to this module. (a) Making only the DIFF side per-parameter left the two halves DISAGREEING for every EMBEDDING shape:crossStackSourceKeyrefuses a non-dottedFn::Subplaceholder, soFn::Sub "postgres://u:${LoserParam}@host"-- the dominant CDK connection-string shape -- still took the collapsed survivor on the persist side while the desired side computed the loser's, i.e. a perpetual UPDATE (a perpetual REPLACEMENT on a create-only property), which is issue #2087's symptom through a different door.IntrinsicFunctionResolver.recordInheritedParameterSecretsnow takes the parameter NAME and records THAT parameter's own expression, so the value scan agrees with the diff side; the residual is named in the code and, in round 3, MEASURED: a resource consuming AND embedding BOTH colliding parameters keeps one expression, which is genuinely inherent to a plaintext-keyed bag -- but the neighbouring MIXED shape (one EMBEDDED leaf plus one WHOLE-VALUE leaf, in one resource) is a NEW disagreement this PR introduces rather than one it merely fails to close. Measured againstmain(f56c2cf9) and against this branch: onmainboth halves take the collapsed survivor, so they MATCH (on the wrong expression -- that is #2291 itself -- but they match, so nothing perpetually updates); here the diff side is per-parameter while the embedded leaf reads the one plaintext-keyed bag entry, which is whicheverRefresolved LAST, so the two halves diverge and the resource reports an UPDATE on every deploy (a REPLACEMENT on a create-only property). It is order-dependent -- reversing the two properties makes them agree again -- and needs both leaves in ONE resource, sinceperResourceSecretsis keyed by logical id. Closing it needs a placeholder-SPAN position arm, which is a new positioning concept rather than an arm beside the existing ones, so it is deferred to issue #2320 with the measurement. Nothing live covers the mixed shape: the new integ arm deliberately gives itsFn::Subleaf its OWN resource so its expected value does not depend on property iteration order. (b) The rollback REPLAY had none of it:rollback-executor.ts's three arms (reverse-replacement,revert,--revert-failed) hand a nested child the same kind of bag, so a rollback silently rewrote correct state back into the #2291 shape and acdkd drift --revertinside that window pushed the WRONG secret version to the live child resource. They now record from the JOURNAL (the uncollapsed side) underSTATE_DERIVED_RULES, and only from the DESIRED side, which is the generationNestedStackProviderforwards as the child'sParameters. (c) A fourth refusal was added after a probe showed the recorder could not tell a CERTIFIED leaf from a value-scan FALLBACK: anssmreference whoseSecureStringverdict is unpinned takes the public-reference branch underTEMPLATE_DERIVED_RULESand value-scanned its way to the sibling's expression, so the losing parameter of an unpinned ssm pair was stored against the WRONG reference.
Recently Implemented (2026-08-26):
The custom-resource poll no longer debug-logs the first 200 bytes of the response body, so a generated secret in
Datanever reaches--verboseoutput (issue #2250) --src/provisioning/providers/custom-resource-provider.ts, plustests/unit/provisioning/custom-resource-provider-response-body-log.test.ts. Each poll of the pre-signed S3 response key used to logbody.substring(0, 200)of the CloudFormation custom-resource response document.Datais the documented place a handler returns a GENERATED VALUE -- including a generated secret -- so behind a shortPhysicalResourceIdthose values landed inside the 200-character window and reached the terminal and, in CI, the retained build log. It fired for EVERY custom-resource response, gated only by--verbose. The line now renders the non-sensitive ENVELOPE instead:Status,PhysicalResourceId(already persisted tostate.json, so not a new channel) andObject.keys(Data)-- never aDatavalue, and neverReason, which is free-form handler text that can quote them. Every one of those fields is HANDLER-CONTROLLED, so each goes throughdisplaySafe(issue #2170) and a 200-character cap before it is rendered -- both regressions this change introduced and a review caught. The line it replaced printed raw WIRE json, where the encoder had already escaped control characters and wheresubstring(0, 200)bounded the output by construction; parsing first UNDOES the escaping, so an ESC and a newline reach the terminal as real bytes and anyone holding the pre-signed response URL could clear the screen and print a forgedERROR [cdkd]line into a CI transcript. Dropping the substring removed the bound as well: a 5000-character id with 300Datakeys rendered a 19,714-character line, re-emitted on EVERY poll of a resource that can run for an hour. The body stays UNTRUSTED input: the parse is hoisted above the log so one result feeds both the summary and the terminal-status check, but the log still fires for a body that does not parse -- reporting the body's LENGTH only -- because a malformed response is exactly when the diagnostic is worth most; a body that parses to a JSON scalar / array /nullgets its own summary rather than an envelope-shaped read of it. An earlier write-to-every-reader trace concluded this log line was the ONLY reader emitting the payload and that the body is never written tostate.json; the second half is FALSE and was relayed here without being re-derived.cfnResponse.Databecomesattributes(custom-resource-provider.ts:1128/:1208) and is persisted intoResourceState.attributes, so a generated secret ALSO sits in plaintext in the state record -- a durable channel strictly worse than this transient one, filed as #2274. What this entry closes is the--verbosechannel, not the exposure as a whole. gc's placeholder sweep really is metadata-only. The four new cases assert the RENDERED line (captured at theconsole.debugboundary of a REALConsoleLoggerat debug level, not avi.fn()that would record any argument handed to it), covering the envelope, an unparseable body, a non-object JSON body and an object missing the protocol fields.cdkd drift --jsonnow puts the payload and nothing else on stdout, so--accept/--revertoutput parses (issue #2230) --src/utils/logger.ts,src/cli/commands/drift.ts,docs/cli-reference.md, plustests/unit/cli/drift-json-stream.test.tsandtests/unit/utils/logger-stdout-reservation.test.ts.logger.info/logger.debugresolve toconsole.info/console.debug, which write to STDOUT -- the streamwriteJsonReportprints the payload on -- so every human-facing line of the remediation path interleaved with the document. Measured against the real binary before the fix:cdkd drift <stack> --json --acceptover a stack with one unreadable resource emitted 692 bytes on stdout andJSON.parserejected it withUnexpected non-whitespace character after JSON at position 331; after, stdout is 331 bytes and parses, with the prose on stderr. The failure is silent in the worst direction, because the terminal shows exactly the same text either way. Two mechanisms, because ONE could not cover both populations.reserveStdoutForPayload()(new, opt-in, module-level inlogger.ts) routesinfo/debugtoconsole.errorfor the rest of the process;drift.tscalls it once whenoptions.json, BEFOREapplyRoleArnIfSet. It is module-level rather than an instance field becausechild()hands out freshChildLoggerinstances, and the lines a per-call-site fix indrift.tsstructurally CANNOT reach live in exactly those:applyRoleArnIfSet'sAssumed role ...(src/utils/role-arn.ts:286, unconditional on any--role-arnrun) andS3StateBackend.saveState's legacy-migration notice (src/state/s3-state-backend.ts:448, reachable on--acceptover a pre-v2 record). The plan printers and the confirmation prompt callprocess.stdout.write/readlinedirectly, which no logger flag reaches, so they take a per-call-siteHumanTextSinkinstead -- 13process.stdout.writecalls inprintAcceptPlan/printRevertPlanplusconfirmPrompt's readlineoutput. The lines are MOVED, never suppressed: an operator still sees the plan, the prompt, the--dry-runnotice,No drift detected -- nothing to accept., theComparison INCOMPLETEblock (issue #2208),State updatedandRevert summary, and--verbosedebug output. Dropping them would satisfy a "stdout parses" assertion while losing what the confirmation is asking about, so every case asserts the line ARRIVED on stderr rather than merely left stdout. The reservation is OPT-IN and onlydriftopts in, so no other command's output contract moves --cdkd diffalready solved its own instance differently (diff.ts:89-101demotes the logger towarnunder--json, which SUPPRESSES rather than moves). The newdriftsuite deliberately does NOT mock the logger, unlike the four siblingdriftsuites: avi.fn()standing in forgetLogger()answers the stream question by construction, which is whydrift.test.ts's two existing--jsoncases pass over the defect. Vitest also interceptsconsole, so its output reaches neitherprocess.stdout.writenorprocess.stderr.write(probed); the suite therefore spies the console methods and funnels them into an ordered fd-1 / fd-2 transcript. Mutation-probed in both halves separately: disabling the logger route reddens 8 of 10 drift cases (the headline one with the issue's ownSyntaxError) while both no---jsoncontrols stay green, and forcing the sink to stdout reddens the 5 plan-bearing cases while the 3 logger-only ones stay green. An audit of the other--jsoncommands found the same mixing on six more subcommands, left unfixed here as a cross-command output-contract change, and filed as issue go-to-k/cdkd#2280:cdkd state list/state resources/state show/state info(--verboseun-gates helper debug onto stdout, androle-arn.ts:286fires unconditionally),cdkd list(worst --src/synthesis/app-executor.ts:165re-emits the CDK app's stderr at info level on a DEFAULT run), andcdkd events(--json -vonly).cdkd diffis clean.cdkd destroy/cdkd state destroyno longer HANG at the per-stack confirmation prompt on an EOF stdin (issue #2259) --src/cli/commands/destroy-runner.ts,docs/cli-reference.md, plus cases intests/unit/cli/destroy-runner-sigint.test.ts. The per-stack prompt was a bareawait rl.question(prompt)with neither a non-TTY guard nor an abort signal. It is NOT the only such prompt in the repo --rollback.ts, BOTH ofstate.ts's (thestate rmprompt andconfirmRefresh),orphan.ts,import.ts,export.ts,drift.ts,retire-cfn-stack.tsandstate-migrate.ts-- NINE sites -- all still take a barerl.questionwith no guard, and this entry does not close that class. The sharedconfirm-prompt.tsis deliberately NOT among them despite being a bare prompt: its only caller (deploy.ts) short-circuits on!process.stdin.isTTYbefore reaching it, so it cannot hang on EOF today -- the same reasonevents.ts's helper is excluded. It is the one that gates a DESTROY, which is why it is fixed first; the rest are filed as #2275.rl.questionnever settles once stdin is at EOF, and EOF delivers no signal at all, so the abort arm the entry above added for the BATCH prompt could not help here: there was nothing to abort ON.cdkd destroy <stack>,cdkd destroy --allandcdkd state destroy <stack>without--yes/--forcetherefore parked FOREVER in CI on nothing more than an absent stdin, burning the job's whole timeout budget instead of failing. Measured on Node 24.15.0, the version.node-versionpins (and 24.19 before it) against realnode:readline/promises:echo y |resolves"y", whileprintf 'y' |(a real answer with no trailing newline) and< /dev/nullboth stay pending indefinitely. That is exactly the gap the #2117 follow-up entry above left open and pointed here: its narrow reason -- the per-stack prompt sits in the watch's PRE-REGISTRATION window where the watch already force-quits, so a SIGNAL there is an exit rather than a hang -- is correct for a signal and says nothing about EOF. The fix applies the same non-interactive REFUSAL the batch prompt one layer up now uses:process.stdin.isTTY !== true->CdkdError(..., 'NON_INTERACTIVE_CONFIRM'), thrown BEFOREcreateInterfaceso there is no window a never-settling question can be awaited in. Two decisions were settled rather than assumed. REFUSE, not auto-confirm:deploy.tstakes the other branch (!process.stdin.isTTY-> proceed) and five prompts (gc.ts,bootstrap-destroy.ts,recreate-confirm-prompt.ts,prefix-migration-check.ts,migrate-command.ts) refuse, and a destroy belongs with the refusers -- a deploy that assumes "yes" is recoverable, whereas silently answering "yes" for an absent operator here deletes every resource in the stack; it also makes the two layers of the same command agree. And the ERROR SHAPE matches the only TWO of those five that carry a code --gc.tsandbootstrap-destroy.tsthrowCdkdErrorwithNON_INTERACTIVE_CONFIRM, while the other three throw a bareErroror aLocalMigrateError-- so CI can branch on it. (An earlier revision of the sibling entry claimed all five carried the code; that was measured false and is not repeated here.) POSITION is load-bearing and is fenced separately: the guard sits AFTER the--yes/--forceshort-circuit, so a non-interactive run that already passed a flag never consults stdin at all, and after the resource banner, so the refusal still names what would have been destroyed. Hoisting it above the short-circuit reds the--yescase AND all seven pre-existing SIGINT cases (they runskipConfirmation: trueon vitest's non-TTY stdin), which is the same CI population. Both entry points reach this one prompt (destroy.tsandstate.tseach callrunDestroyForStack), so fencing the runner covers both; nested-stack children are unaffected, sinceNestedStackProvider.deleterecurses withskipConfirmation: true-- the parent already confirmed the cascade -- andstate destroy --allpasses it too, its batch prompt having already been answered. The probe reproduces the PRODUCTION SYMPTOM rather than a proxy for it, the way the batch prompt's twin was fenced: removing the guard makes the case fail as a 5014 ms TIMEOUT, not as an assertion about a mock. Negative controls that stay green under it: a TTY run still prompts and still destroys, a TTY user answeringnstill cancels and deletes nothing, and a--yes/--forcerun never consults stdin even on a non-TTY. It IS a behaviour break AND a new exit-code contract, documented as an upgrade note rather than silently:printf 'y\n' | cdkd destroy MyStacksucceeded before (piped stdin does settlerl.questionwhen the input ends in a newline) and now exits 1 withNON_INTERACTIVE_CONFIRM(CdkdErrorcarries noexitCode, sohandleErrormaps it to 1 -- worth naming, since a destroy also emits 2 for a partial failure and "non-zero" alone does not let CI tell the two apart).--yes/-y(or-f/--forceoncdkd destroy) is the supported non-interactive path -- the same tradecdkd gcandcdkd state destroy --alleach made in the two entries above.Three readback shapes POSITION could not certify now redact, when the positions themselves corroborate the pairing (issue #2012) --
src/deployment/secret-redaction.ts, plus a newtests/unit/deployment/secret-redaction-anchor-pairing.test.ts, three flipped assertions and two new residual proofs intests/unit/cli/state-refresh-observed.test.ts, and one flipped bound plus two repaired fixtures intests/unit/deployment/secret-redaction-array-identity.test.ts. On the readback paths (cdkd state refresh-observed,cdkd drift's baselines, and a plaincdkd deploythroughdrainObservedCaptures) the secrets map is EMPTY by construction, so the value scan has no needle and POSITION is the only mechanism left. WhereidentityKeyForfound no identity field there was no position either, and an array-nested secret reachedstate.jsonand the drift baseline holding the DECRYPTED value --['--pw', '{{resolve:...}}']and[{Field, Val: '{{resolve:...}}'}], both pinned as residual LEAK rows.refuseUncertifiedReadbackPositionsnow pairs two such containers ANCHOR-wise, under four conditions: the key sets (objects) or index counts (arrays) must match; every position whose SOURCE carries no dynamic reference must be deep-equal on both sides; every reference-bearing ELEMENT must carry its own distinguishing anchor (a non-empty string), where a bare reference leaf -- having no interior to carry one -- may instead lean on the array's non-reference-bearing FRAME; and no two reference-bearing elements may share an ORDER-INSENSITIVE anchor signature. Substitution then happens only at the reference-bearing positions. Anchors are what make the pairing evidence rather than a guess -- a position AWS did not rewrite proves the two containers describe the same element -- which answers thedescendArrays: falseorder objection on its own terms: a REORDERED list normally stops matching its anchors and is refused exactly as before. The relaxation cannot buy baseline content, which is the constraint that killed the first attempt at these rows (taking the SOURCE array wholesale, rejected by the issue #1915 fences for rewriting{Name:'', Value:'an-unrelated-literal'}onto an expression and inventing aNamefield AWS never reported, both of whichcdkd drift --revertpushes to the live resource): every substitution is a STRING leaf at a position the bag already has, so no key, element, or scalar-over-container can be added. The yield cost is stated rather than discovered -- one normalised sibling field in the same container drops it to zero, so this closes a SUBSET of each row and the remainder stays a refusal. A THIRD shape closes for the same structural reason and is worth naming because it was documented as a permanent BOUND rather than as one of the issue's rows: an array of ARRAYS (Matrix: [[{Name, Value}]]), whose OUTER elements have no identity field to key on.identityKeyForstill refuses it -- that half of the bound is unchanged -- but refusing a keyed pairing is no longer the end of the readback walk, since the inner elements' own anchors can vouch for the outer alignment. Its old assertion pinned a LEAK and is flipped; the order-assumption bound it carried is restated on a REORDERED fixture whose inner elements are KEYLESS, so only the anchor pass can refuse it (a first cut kept{Name, Value}inside and was therefore INERT -- the inner keyed descent refused it under every mutation, including the unmodified pre-change source). One #1915 fence was repaired rather than flipped:does not key on a field outside ARRAY_IDENTITY_KEYSused a single-element fixture that the anchor arm pairs, so it produced the same output whether or notValuesat in the list -- it now uses a two-element reordered array where a widenedARRAY_IDENTITY_KEYSmis-assigns and anchors refuse, which discriminates the rule it names.ARRAY_IDENTITY_KEYSitself is unchanged. Three defects the review round MEASURED are worth recording, because the first cut of this pass shipped past all of them and each has its own fixture. The distinguishing-anchor counter was scoped per ARRAY, so one element's anchor licensed a sibling with none and an unrelated literal took the sibling's expression -- the same false redaction the #1915 fences rejected the first attempt over, arriving through the counter's SCOPE rather than its definition. The anchor projection carried no UNIQUENESS requirement, so two elements the anchors describe identically could be swapped by AWS invisibly:['--pw', <exprA>, '--pw', <exprB>]matched every anchor at every index against a readback holding the two values the other way round, pinning each secret's reference at the OTHER secret's position.AWS::AmazonMQ::Broker.Usersis that shape in the field -- noName/Key, bothUsernameandPasswordrendered throughsecretValueFromJson, andGroups: ['admin']equal on every element -- so aDescribeBrokerreturning the users reordered recorded the admin credential at the app user's position, whichcdkd drift --revertpushes to the live broker.isUniquelyKeyedBy, the bar this pass claims to match, demands the identity be non-empty AND unique across elements; the first cut kept only the first half. The uniqueness rule then had to be made order-INSENSITIVE as well, which a second review round measured on the same shape: signing only sorted object KEYS left list order significant, so two users whoseGroupswere['admin','ops']and['ops','admin']signed differently, satisfied the uniqueness rule, still deep-equalled position for position, and reinstated the misattribution through a different door. The signature now sorts list elements too, so a projection is compared as a MULTISET -- fail-closed, since sorting can only make two signatures collide that previously differed, and an extra collision is a missed closure rather than a leak. Deep-equality itself stays order-SENSITIVE on lists, deliberately: it asks whether AWS returned THIS position unchanged, and a reordered list is a changed position. AnddeepEqualJsonValueequated anyDatewith{}and with any otherDate, sinceisPlainObjectadmits class instances andObject.keys(new Date())is[]-- reachable rather than theoretical, because an AWS SDK v3 readback reachingdrainObservedCapturesis pre-JSON and carriesLastModified/CreationDate; it now requires a plain prototype on both sides, so aDateanchor corroborates nothing and its element refuses. The two rows with no position to anchor against at all remain open on issue #2012, and each is now pinned by a case the anchor arm would answer DIFFERENTLY if it reached them, rather than by a comment: an UNPAIRED element beside a paired one (some element pairs by identity, soidentityKeyForanswers and the anchor arm never runs) and an observed KEY the source does not carry (nested in a keyless array the anchor arm does see it, and refuses on the differing key sets). A real-AWS arm ridestests/integration/secrets-array-nested, behindCDKD_INTEG_ANCHOR_ARM=1so the OFF polarity synthesizes byte-for-byte what that fixture shipped before: the existing rows all rideEnvironment[], which carriesName, soidentityKeyForanswers and the anchor gate is never consulted -- the new code had no live coverage at all. Ananchorprobecontainer adds two UNKEYED arrays (lists of plain strings, noName/Keyon any element) with opposite expected verdicts on the same unchanged redeploy:Command: ['-c', <expr>, '-v']must PAIR, so the captured baseline holds the EXPRESSION at index 1 -- the POSITIVE marker, since absence of plaintext alone is also satisfied by an arm that never ran -- whileEntryPoint: ['-p', <exprA>, '-p', <exprB>]is a NEGATIVE CONTROL that must be REFUSED by rule 3 and left holding exactly what AWS reported, without which a gate that paired EVERYTHING would satisfy every positive assertion. The premise is asserted rather than assumed at each step: that the references reached AWS RESOLVED, that AWS echoed the anchors back unchanged (so a refusal is attributable to rule 3 rather than to a normalised sibling), and that both arrays are genuinely unkeyed -- the identity probe answerstrueon this same fixture'sEnvironment[], so it is not vacuous.An
Fn::Subover a nested stack's output no longer ships the literal${Child.Outputs.Foo}to AWS (issue #2270) --src/deployment/intrinsic-function-resolver.ts,src/deployment/secret-redaction.ts,.claude/rules/architecture.md, plustests/unit/deployment/intrinsic-sub-nested-stack-outputs.test.ts(new, 17 cases), updates totests/unit/deployment/intrinsic-split-list-value.test.tsandtests/unit/deployment/secret-redaction-cross-stack-source.test.ts, and a live arm ontests/integration/nested-stack-secret/. Found by the security review of PR #2266, pre-existing rather than a regression from it. TWO separable changes, and the issue asked for both. (1)resolveGetAtt's STRING spelling split on EVERY dot and rejected anything but two segments, so"Child.Outputs.Foo"-- CloudFormation's own parse of which is["Child", "Outputs.Foo"], and which the ARRAY spelling has resolved since #2055 / #2266 -- threwInvalid Fn::GetAtt format. It now splits on the FIRST dot, which is the same splitsrc/analyzer/template-parser.tsalready applied to the identicalFn::Subplaceholder to draw its DAG edge: before this the dependency graph and the resolver DISAGREED, the edge being drawn for a reference the resolver would then refuse. Both ends must still be non-empty, soMyResource,.AttrandMyResource.are rejected exactly as the arity test rejected them. Attribute names containing dots are not special-cased toOutputs.--Cluster.Endpoint.Addresstakes the same parse. (2) INDEPENDENTLY,Fn::Sub's catch no longer launders a STRUCTURAL failure into a literal. Issue #1740 had made the DELIBERATE refusals loud by re-raisingIntrinsicResolutionRefusalError, butInvalid Fn::GetAtt formatandResource X not found for Fn::GetAttare plainErrors, so the class test could not see them -- and inFn::GetAttposition that throw was loud while insideFn::Subit became a warn line plus a resource deployed holding the placeholder TEXT. The new test is STRUCTURAL rather than class-based: when the placeholder's head segment (everything before the first dot) names a resource incontext.resourcesOR in the template'sResourcesblock, the placeholder is a REFERENCE and not ordinary text, so the failure re-throws. The template half is the load-bearing one -- a resource that is in state normally resolves, so acontext.resources-only test would have left the reported shape unfenced. The ORIGINAL error is re-thrown UNCHANGED, neither wrapped nor re-worded, becauseretryable-errors.tsclassifies by message SUBSTRING andmarkNonRetryablerides the error OBJECT, so a new message could silently flip a transient SDK failure (one surfacing out of the nested-stack output re-resolution) to terminal, or a terminal one to retryable via a template-controlled logical id. The accepted-input set was established BEFORE narrowing anything, and the counter-cases are half the new file: ordinary text whose head names nothing (${some_shell_var},${config.value}), the${!Literal}escape (asserted against a head that IS a declared resource, so the escape cannot fall into the refusal), the empty${}, pseudo parameters, parameter refs, aRefto a resource in state, the 2-arg variable map (which short-circuits above the refusal), and everybestEffortcaller.bestEffortis EXEMPT by name: it marks the diff /cdkd scrubpaths whose documented expected case (issue #1017) is a reference to a resource this same deploy will CREATE -- precisely the declared-but-not-in-state shape this refuses -- and those callers already catch resolution failures and keep the raw intrinsic. Parameters are deliberately NOT in the structural test, and the FIRST published reason for that was wrong: it cited "the routinecdkd scrubcase (it takes no--parameters)", butscrub.ts'sresolverContextfactory setsbestEffort: truein the same object literal that bindstemplateandresources, so scrub short-circuits before the predicate is consulted and can neither benefit nor suffer. The REAL reason is a regression risk on working templates: the only thing atemplate.Parametersarm would newly refuse is a placeholder naming a DECLARED parameter with no bound value, and that includes one carrying aDefaultthe caller never merged intocontext.parameters-- deploys that succeed today would start hard-failing. The residual is real and tracked separately: such a placeholder still ships${Stage}as literal text. Every half was mutation-probed SEPARATELY, because an all-or-nothing revert cannot tell them apart. Reverting the first-dot split AT THE RESOLVER CALL SITE (leaving the shared helper and the key arm sharing it) reds 11 cases across THREE files -- eight intests/unit/deployment/intrinsic-sub-nested-stack-outputs.test.ts, two intests/unit/deployment/secret-redaction-cross-stack-source.test.ts, andintrinsic-split-list-value.test.ts's dotted-attribute rendering -- while every structural-refusal case stays GREEN. An earlier revision of this entry said "6 cases" and named only one other file, omitting the redaction suite; it also predated the round-3 cases. Note this is a DIFFERENT probe from reverting the shared helper itself, which necessarily reverts BOTH call sites at once and reds more. Reverting only the structural predicate reds exactly the refusal cases and leaves every resolution case green. That file'srenders a dotted ATTRIBUTE wholecase had PINNED the old unreachability with a note saying it would red the day nested-pathFn::GetAttlanded -- it landed, the rendering was found already correct, and the case now drives the string spelling through the live path instead of the throw. The integ arm adds a NON-secret child output (ChildPlainOutput) and a fifth stack-owned parameter (SubConsumer) reading it throughFn::Sub; it asserts the LIVE SSM parameter, not state, because a fix that resolved at persist time but not on the wire would pass a state-only check, and it reads a non-secret output so a failure there cannot be confused with a redaction failure. SSM accepts any string, so the pre-fix deploy exited 0 with the placeholder text in the parameter -- the value is the only witness. The fixture's literal is asserted NOT to overlap any other needle in that file, the mirror of the #2087 arm's assertion that its literal DOES. A THIRD site held the same assumption and is fixed in the same PR rather than filed, because it is not a second defect:crossStackSourceKeyinsrc/deployment/secret-redaction.tshad RE-SPELLED the resolver's arity rule (parts.length !== 2, under a comment reading "the string form only at exactly two dot-separated segments,resolveGetAttthrows otherwise"), so widening the resolver alone would have left the two disagreeing in the direction that matters -- the resolver keying a leaf the persist path refuses, which silently degrades issue #2059's per-leaf positioning to the plaintext-keyed value scan for exactly the nested-stack OUTPUT references that positioning exists for. Not a plaintext leak (the scan still redacts) but a WRONG-EXPRESSION one: a child exportingCurandPrevof one rotating secret has both outputs resolve EQUAL during theAWSPENDINGwindow, so both parent properties persist the survivor's expression andresolveReplayPropsapplies the wrong stage on a rollback or acdkd drift --revert. The two sites now call ONE exported function,splitGetAttStringForm-- not a copy and not a paraphrase, since a paraphrase is what produced this. It lives insecret-redaction.tsbecause that module is a LEAF BY DESIGN (its header states it imports nothing, both the resolver and the deploy engine consuming it) and the resolver already imports eleven symbols from it, so this is the only direction that does not create a cycle. Fenced end to end rather than at the key function:keeps each leaf on ITS OWN expression through the STRING spelling toodrives the resolver over two child outputs whose plaintexts COINCIDE and asserts each redacts back to ITS OWN expression. The per-leaf equalities are the discriminator and thenot.toContain(SHARED)beside them is deliberately not -- the value-scan collapse also satisfies "no plaintext", the survivor still being an expression, so an absence assertion would pass with the defect intact. The key-level case that PINNED the old refusal (crossStackSourceKey({'Fn::GetAtt': 'Child.Outputs.CurrentPw'})toBeUndefined) is replaced by one asserting the string and array spellings produce the SAME key, with a sibling-attribute inequality beside it so an equality holding by both sides beingundefinedcannot pass. ROUND 3 -- four defects independent review found in the round-2 fix, one of them CREATED by it. (1) THE BLOCKER: making${Child.Outputs.X}resolve created a secret-COLLAPSE population. Before it that leaf was literal text carrying no secret; after it the leaf resolves to a plaintext and needed positioning, and had none --crossStackSourceKeyrefused everyFn::Sub, andintrinsicSkeletonPatterncannot position it either (its[^}]*wildcard cannot cross a{{resolve:...}}token's own}}). Zero candidates, so both leaves fell to the plaintext-keyed value scan; measured end to end, aCURRENTleaf redacted to the SIBLING's:AWSPREVIOUSexpression, whichresolveReplayPropsthen applies to the LIVE resource on rollback /cdkd drift --revert-- issue #2059's exact failure.crossStackSourceKeygains anFn::Subarm that normalizes a bare-string template of EXACTLY ONE non-escaped placeholder to theFn::GetAttkey viasplitGetAttStringForm. Keying it asFn::Subwould have been useless: the WRITER isresolveGetAtt, whichresolveSubcalls with the bare placeholder text and which never sees theFn::Subwrapper, so the two halves would never have met. Surrounding text, two placeholders, the${!Literal}escape, a${Ref}form, a pseudo parameter and the 2-arg form all still refuse. (2) The escaping errors could be classified RETRYABLE:Resource X not found for Fn::GetAtt/Ref X not found/Invalid Fn::GetAtt formatinterpolate a template-controlled logical id, andisRetryableTransientErrormatches by BARE SUBSTRING over patterns includingThrottling/SlowDown/DependencyViolation, so a construct namedThrottlingmade a deterministic refusal read as transient -- and escapingFn::Sub's catch is exactly what put it inside the parent'swithRetryviaNestedStackProvider.create. All three are nowmarkNonRetryableAT THE THROW SITE, which needs no rewording (the re-throw must hand back the error object untouched) and leaves a transient SDK error surfacing fromreresolveCrossStackValueretryable. (3) A missing nested-stack output shipped a synthetic ARN:constructAttributehas noAWS::CloudFormation::Stackcase, so it fell toguardedPhysicalIdFallback, whose ARN-shape test is!physicalId.startsWith('arn:')-- and a nested stack's placeholder id DOES start witharn:, so the #1103 guard passed andarn:cdkd-local:...shipped into a free-text property. A child's outputs are known exactly, so an absentOutputs.attribute now refuses, naming the outputs that DO exist. (4) The string form drew no DAG edge:template-parser.tsanddag-builder.tsboth requiredArray.isArray(getAtt), so the spelling this PR made resolve could RACE its producer -- a silent race where there had been a loud failure. Both now use the shared helper's head, so the graph accepts exactly the set the resolver resolves. The two graph sites are fenced SEPARATELY:dag-builder.ts's parser is reached fromaddCustomResourcePolicyEdges, not fromextractDependencies, so thebuildGraphcase does not exercise it and neutering it alone reddened nothing until its own case existed. Also closed: thecontext.resourcesarm of the structural predicate was UNFENCED (every refusal case used an emptyresources, so only the template arm ever ran, and neutering it reddened 0 of 1678 cases in that directory) -- a case with an EMPTYResourcesand a populatedresourcesnow drives it alone. The integ arm gains the half it could not see:SubSecretPairreads two expressions of ONE secret that resolve EQUAL (:shared::versus:shared:AWSCURRENT:, since an empty version-stage defaults toAWSCURRENT), each through its own single-placeholderFn::Sub, and each must persist ITS OWN expression -- with the plaintext and expression counts read AS A PAIR, because "no plaintext" alone is also true of an arm that did nothing. Two properties of that arm are load-bearing and were both learned the hard way on a real-AWS run. BOTH LEAVES SIT IN ONE RESOURCE (the parameter'sValueand itsDescription):perResourceSecretsis keyed by logical id, so two separate resources get two separate bags each holding a single pair, and each would redact correctly with or without the fix -- the arm would prove nothing. And THE PAIR USES ITS OWN SECRET JSON KEY rather than sharingstage: the first cut fed a second spelling ofstagedown through the child'sParameters, which made the pre-existingStageParamleaf stop being the only one carrying its plaintext and broke the unrelated #1903 assertion. The mechanism is worth recording because it is not the one a reader would guess: the parent'sinheritedSecretsbag isMap<plaintext, expression>(RecordedSecretValues,secret-redaction.ts:48), so two expressions resolving to one plaintext collapse to a single entry IN THE PARENT before the child engine is ever constructed, and the child's{Ref: <Param>}source leaves carry no expression for the position pass to certify against. For the same reason the shared pair is handed to the child as LITERAL output values it resolves itself, not asParameters: a whole-token source leaf is one the position pass can certify per leaf, which is what keeps the child's two outputs distinct. ROUND 4, from a four-reviewer delta pass. The 2-ARGFn::SubFORM carried the same collapse through a narrower spelling, found independently by the security and code reviewers:{'Fn::Sub': ['${Child.Outputs.Cur}', {X: 1}]}leaves the placeholder UNBOUND, soresolveSubfalls through its variable map to the same-stack lookup, reachesresolveGetAttand IS keyed by the writer -- while the reader refused the whole form ontypeof template !== 'string'and fell to the plaintext-keyed scan. Same consequence as the one-arg blocker, and this PR created that population too (pre-#2270 the placeholder stayed literal). The reader now accepts the 2-arg form whenArray.isArray(raw) && raw.length === 2 && isPlainObject(raw[1])AND the placeholder name is NOT an own key of the variable map. ThehasOwnhalf is not optional: a BOUND variable wins inresolveSuband therefore never reachesresolveGetAtt, so the writer recorded nothing for it, and certifying it from the source alone would attach another leaf's expression to the value -- over-redaction, which on this path is the worse direction becauseresolveReplayPropsre-resolves the persisted expression andcdkd drift --revertPUSHES that baseline to AWS. A malformed 2-arg form (wrong arity, non-object second element) stays refused, matchingresolveSub, which throws on it. TheNESTED_STACK_RESOURCE_TYPEconjunct of the new refusal was unfenced -- deleting it reddened 0 of 1689 -- so anOutputs.-prefixed attribute on ANY type would have hard-failed, reachable through aCustom::resource whoseDatacarries such a key. Its counter-case must use an ABSENT attribute: the first cut used a PRESENT one, which the flat-key lookup returns before the refusal is ever reached, so it passed with the conjunct deleted. The fixture's collision guard omitted its own two new literals against each other, the class that broke this fixture's first real-AWS run; it now iterates by VARIABLE NAME rather than by value, because a self-comparison guard keyed on equal VALUES would skip precisely the case it exists to catch, and the pre-existing three stay inner-only so the #2087 arm's REQUIRED overlap is never flagged. The separateSHARED_PW_VALUE != SECRET_STAGE_VALUEassertion is dropped as strictly subsumed (substring-both-ways implies inequality), its rationale moved into the loop's comment rather than left as a second spelling of one question. Finallysrc/utils/error-handler.tsis corrected twice: the group-1 enumeration gains the nested-stack missing-output refusal as its FIFTH site -- the JSDoc beside it warns that a stale enumeration is how the #1730 site went unlisted for three releases, so leaving it would have been that same failure committed by the PR that reads the comment -- and the two bare counts ("all six", now seven marked sites of eight constructions) are replaced with "each" rather than re-numbered, since a count in prose is what goes stale on the next lane. KNOWN RESIDUAL, filed separately rather than fixed: mixed-text and multi-placeholderFn::Sub(pre-${Child.Outputs.Cur}-post) keep the same positioning gap, architecturally out of reach here becauserecordCrossStackExpressionis whole-token only, andFn::Joincarries the identical residual onmaintoday. No plaintext leaks in that case -- the value scan still redacts.A COMPLETED destroy no longer reports itself unfinished, and
cdkd state destroy --allno longer hangs at its batch prompt (follow-up to issue #2117, shipped in 0.284.55) --src/cli/commands/destroy.ts,src/cli/commands/state.ts,src/utils/interrupt-signals.ts,src/provisioning/interrupt-watch.ts,src/cli/commands/destroy-runner.ts,docs/cli-reference.md,.claude/rules/layout-provisioning.md, plus cases in the five interrupt suites. Two REGRESSIONS that the #2117 fix itself introduced, both found by independent review of the merged PR and both reproduced by execution before being fixed. (1) The terminalif (runInterrupted())is a LIVE read, so a signal landing in the TAIL window -- after the last (or only) stack returned cleanly, duringeventRecorder.finalize/purgeEventsAfterDestroy, both real S3 round-trips -- threwDestroy interrupted by Ctrl-C. State preserved -- re-run 'cdkd destroy' to finish.and exited 2 over a destroy that had fully completed, telling the user to re-run work that was already done and showing CI a failure that was not one. Before #2117 that site read the once-sampledresult.interrupted, which is false there. The fix gives the question ONE owner per scope and has every consumer read that owner. PER-RUN, "did this run leave a stack unprocessed?", is the terminal verdict's own: astoppedEarlyflag set ONLY where abreakgenuinely leaves a target unprocessed -- unconditionally at the pre-dispatch guard (THIS stack is the one left undestroyed, whether or not any follow it), and at the end-of-body / inner-region / outer-stack breaks only when something real remains.state.ts's outer guard tests the remaining names againststateRefsrather than counting INDEXES, because under--stack-regiona later name whose only record is in another region is warn-and-skipped and was never going to be destroyed;destroy.tsneeds no counterpart, itscandidateStacksalready being pre-filtered by the set of names that have state. The terminal read isinterrupted || (interruptWatch.interrupted() && stoppedEarly), and the loopbreaks KEEP the live read -- that question is still "has the user asked to stop". PER-STACK, "did THIS stack finish?", isDestroyRunnerResult.interrupted's, and it had the SAME defect one frame inward:destroy-runner.ts's outerfinallyre-syncedresult.interrupted ||= drainingAFTERdeleteState, so a signal duringrenderer.stop()/ thesaveChainflush / the realdeleteStateround-trip /releaseLockflipped the per-stack flag true over a stack whose state file was already GONE -- and the caller ORs it unconditionally into the terminal condition, so the reviewer measuredexitCalls=[[2]]plus that same false sentence on a single-stack--all --yesrun. That re-sync is now gated onstatePreserved, published from thepreserveStatedecision BEFORE the branch acts on it, so the fenced invariant is: a stack whose state was deleted never reportsinterrupted. ROUND 3 made that sentence TRUE, and it was not before: a THIRD write to the same flag sat in theresourceCount === 0early-return branch (result.interrupted ||= emptyInterrupted), ~950 lines ABOVE the gate and outside thetry/finallythat carries it, so the identical symptom survived at the one branch that ALWAYS deletes the state record -- exit 2 withState preserved -- re-run 'cdkd destroy' to finishover a stack whose state file was gone, plus a skipped--purge-eventsfor a stack with no state left to post-mortem. The repo's own GREEN test pinned that defect as the intended behaviour. The write is DELETED rather than gated (nothing is preserved on that branch, so a gate would be permanently false), and the property it protected -- a first Ctrl-C on an empty-state stack still stops an--allrun -- is served by the command-scopedwatchCommandInterruptboth loops read live, now with a case proving it. Because the enumeration was short in all THREE rounds,tests/unit/cli/destroy-result-interrupted-write-sites.test.tsreplaces the human enumeration with a mechanical one: it derives the constructing files from the source, matches<identifier>.interruptedfollowed by any assignment operator (so a rename of the local cannot dodge it), and fails any write not covered by an allow-list of (statement, rationale) pairs. Floors on both the population and the find count, probed in both directions -- a stray ungated write names the offending line, and a needle broken to match nothing fails the count floor. With the per-stack flag accurate,destroy.tshandspurgeEventsAfterDestroythat flag rather than the command-level read -- a per-stack consumer asking a per-stack question. The command-level read there had disagreed with the exit code in the commonest shape: a tail signal on the LAST stack skipped the purge while the run exited 0, so full success was reported with the event history left behind. Round 3 added the SECOND way a stack comes back with its resources standing, which readinginterruptedalone missed: a user answeringnat the per-stack prompt makes the runner return{cancelled: true, deletedCount: 0}, and witherrorCountandskippedCountboth 0 the run maps toSUCCEEDEDwithinterruptedfalse -- socdkd destroy --purge-eventsDELETED the event history of a stack that still exists (probed: two declined stacks,pruneRunscalled twice). The gate now readsstackInterrupted || stackCancelled, fenced in both directions. (2)state.ts's--allbatch promptawait rl.question(...)took no abort signal. Because #2117 installed a command-level SIGINT listener, Node's default terminate no longer fires there, and the handler's between-stacks branch only records and returns -- so a single SIGTERM (forwarded to SIGINT) from CI orkillHUNG FOREVER where it previously exited 130. At a TTY readline intercepts^Citself, so the reachable population is the piped / non-interactive shape, i.e. exactly the population issue #1342 exists for.watchCommandInterruptnow holds anAbortController, aborts it in every branch that records a signal, and exposesreadonly signal;state.tspasses it torl.question(prompt, { signal })and treats the abort as cancel + exit 130.rl.close()runs in afinallyfor every path that UNWINDS, and explicitly before theprocess.exit(130)as well, becauseprocess.exitis synchronous and never unwinds -- so on the one path this fix adds, thefinallydoes not run at all. The same prompt also gained an EOF path:rl.questionnever settles when stdin ends (measured still pending after 1500 ms under both< /dev/nullandecho -n "" |), socdkd state destroy --allwithout--yeshung in CI on nothing more than an absent stdin, with no signal for the abort path to catch. Round 2 raced the question against the interface's owncloseevent so EOF read as "N"; ROUND 3 WITHDREW that race in favour of this repo's existing non-interactive convention -- guard onprocess.stdin.isTTY !== trueBEFORE prompting and throwCdkdError(..., 'NON_INTERACTIVE_CONFIRM'), as five sibling prompts already do (gc.ts,bootstrap-destroy.ts,recreate-confirm-prompt.ts,prefix-migration-check.ts,migrate-command.tsall testisTTYbefore creating the interface) -- though onlygc.tsandbootstrap-destroy.tsshare theCdkdError+NON_INTERACTIVE_CONFIRMSHAPE this one copies, the other three throwing a bareErroror aLocalMigrateError. The race lost three ways, each measured against realnode:readline/promiseson Node 24.15.0:printf 'y' |(a real answer with no trailing newline) DECLINED, silently discarding the answer, becauserl.lineis''at close;(sleep 0.3; echo y) |DECLINED, a delayed answer simply losing the race; and at a TTY readline consumes^Citself and callsclose(), so the INTERACTIVE Ctrl-C landed on the EOF arm and exited 0 with "stdin closed" instead of the 130 + "Destroy cancelled" the abort arm 15 lines above deliberately produces -- one user action with two spellings, the one a human actually hits being the wrong one. Exit 0 on a piped CI run is also success-shaped over a destroy that did nothing. The existing control could not see any of this because its mock resolves an ALREADY-SETTLED promise, so the question arm always won. A refusal has none of those failure modes: no answer can be discarded, the TTY path is untouched, and--yesshort-circuits above the check so it never consults stdin. It IS a behaviour break, and an upgrade note rather than a silent one:printf 'y\n' | cdkd state destroy --allsucceeded before (piped stdin does settlerl.questionwhen the input ends in a newline) and now exits non-zero withNON_INTERACTIVE_CONFIRM.--yesis the supported non-interactive path, the same tradecdkd gcmade one entry above. The SIGINT/abort arm from round 2 is correct and is KEPT.destroy.tsowns no prompt;destroy.tsowns no prompt, anddestroy-runner.ts's per-stack prompt is left alone HERE with the reason stated narrowly: for a SIGNAL it sits in the watch's pre-registration window where the watch already force-quits, so that case is an exit rather than a hang. That argument does not cover EOF, which delivers no signal --cdkd destroy <stack>without--yeson an absent stdin still parks there, the same shape one layer below, anddeploy.tsalready auto-confirms on a non-TTY so destroy is the outlier. Filed as #2259 rather than fixed in this diff: it is pre-existing, it is a behaviour change on a second command, and taking it at review round four is how a fifth round happens. Also closed here: thestate.tsrunStackbracket was INERT-able with all 63 cases green -- the exact hole #2117's own round 2 closed fordestroy.ts-- which left the stranded-lock regression of #1348 unfenced on the state side; the three new throw-guard branches (catch -> dispose(); throw,catch -> endCommandInterruptScope()) had zero coverage against their own round-2 regression;interrupt-watch.ts's residual-population claim named six commands that provably cannot reach the code (none callsforwardSigtermToSigint(), so the scope never opens) and is replaced by the measured answer, that the force-quit has NO live population today, stated as a structural guarantee with the condition that would make it reachable again; the between-stacks force-quit re-prints the hedgedcdkd force-unlock <stack-name>line, becausedestroy-runner.tscatches a failingreleaseLockand only warns, so "no lock is held" is false exactly where recovery matters most; the pre-registration force-quit's flat "Nothing was deleted and no stack lock is held" is hedged the same way, since on an--allrun whose earlier stacks completed BOTH halves are false; and the stale Route53 attribution is corrected at all FOUR sites that carried it (src/utils/interrupt-signals.tsx2,src/provisioning/interrupt-watch.ts,src/cli/commands/destroy-runner.ts), each now naming thegrep -rn "process.on('SIGINT'" src/provisioning/providers/that regenerates the closed set -- todaycustom-resource-provider.ts,cloudfront-distribution-provider.tsandacm-certificate-provider.ts, and Route53's provider registers none.isPromptAbortErrorgains a case driving the REAL rejection out of an abortedreadline/promisesquestion (Node 24.15.0:name === 'AbortError'ANDcode === 'ABORT_ERR') plus one case per arm, since every hand-built fixture carried both and removing either arm reddened nothing. Every fix carries a case that fails without it, applied and measured ONE AT A TIME -- the S3-window gap above existed precisely because one site's probe was masked by another's. Two reproduce their production symptom rather than a proxy for it: the dropped{ signal }fails as a 5003 ms TIMEOUT, and so does removing the non-TTY refusal (5000 ms) -- the CI hang itself, not a proxy for it.The shared integ version-sweep helper is now EXECUTED under a fake AWS CLI, the version-purge mode follows the DESTROY rather than the script,
rollback-cross-region-secretjoins the sweeping fixtures, andgenerateSecretStringis recorded as a measured NON-seeding shape (issues #2106 / #2225 / #2212) --tests/unit/scripts/integ-s3-versions-harness.test.ts(new, 65 cases),tests/unit/scripts/integ-secret-fixture-sweep.test.ts,tests/integration/s3-versions.sh,tests/integration/local-run-task-from-state/verify.sh,tests/integration/rollback-cross-region-secret/verify.sh,tests/integration/cross-stack-secret-import/verify.sh,tests/unit/scripts/integ-s3-versions-helper.test.ts,tests/unit/scripts/integ-aws-commands.test.ts,.claude/rules/testing.md. Test-only; nosrc/change. (1) #2106 -- the helper is now executed, not just reviewed.tests/integration/s3-versions.shis sourced by sixteen fixtures and nothing in CI ever RAN it: the only automated coverage wasbash -nplus a static bash-4-ism scan, leaving its three documented silent-partial traps fenced by review alone. The new harness sources the real helper under/bin/bashagainst a fakeawsonPATH, backed by a local version store that replays realListObjectVersionsshapes and applies--queryper page -- not a detail, since trap 3 IS that per-page application, and a fake applying the query once would make the trap unreachable. The fake is calibrated against the real-AWS numbers recorded in the helper's own header (all 1189 / noncurrent 1064 / latest-only 125 over a multi-page prefix, the1000\n189two-linelength()output, and the unparenthesised flatten reporting 0 where the parenthesised form reports 347) before it is trusted to judge the helper. Cases: zero / one / many versions, a forced page boundary at page size 50 across 2300 entries, an injectedSlowDownlisting failure (which must FAIL rather than read as zero), thenoncurrent/allmode split, delete-marker-latest, exact-key scoping againststate.json.bak/.tmpsiblings, aNoneversion id, per-objectDeleteObjectserrors underQuiet:true, and six malformed prefixes. Each trap is reintroduced as a MUTATION PROBE and watched going red -- including two findings the probes produced rather than confirmed: trap 3's real consequence is not a crash but a vacuous green (the[builtin rejects a two-line operand, returns non-zero, theifis therefore false, and the assertion announcesOK: 0 surviving object versionswhile 1189 survive), and removing EITHER of trap 2's two guards alone still sweeps correctly, so only removing BOTH exposes it. It is a SIBLING of the existinginteg-s3-versions-helper.test.tsrather than a replacement: that file owns the prefix guard and the stronger property that the guard fires BEFORE any AWS call. The harness is hermetic by construction -- its child environment is built from an allow-list rather than by spreadingprocess.env, pinningPATH/HOME/TMPDIR/BASH_ENV/ENV/LC_ALL/LANG/TZ/ cwd and forwarding noAWS_*at all. Every pin is READ BACK from inside a real child and asserted by value, so deleting any one of them fromchildEnvfails by name; PATH, theHOME/BASH_ENV/ENVtrio and theAWS_*omission additionally carry adversarial probes that plant a decoyawson the ambient PATH, a hostile.bashrcdefining anawsfunction, andAWS_DEFAULT_OUTPUT=yaml. That by-value layer was added after review measured the earlier claim UNEARNED: deletingHOME,BASH_ENV,ENV,LC_ALL,LANGorTZwas green, onlyPATHandTMPDIRwere load-bearing, and the decoyawswas created but never placed on any PATH. Measured negatives record that the helper shells out to nojq/python3/date. Its scratch root honoursCDKD_TEST_SCRATCH_DIRand prints a receipt naming the directory it used. (2) #2225 -- the purge mode now follows the destroy.local-run-task-from-state/verify.shran its destroy as... | tail -5 || trueand then chose the version-purge mode fromrc, the SCRIPT's status on entry tocleanup. A run whose assertions all passed and whose destroy then FAILED therefore took theallbranch and deleted the currentstate.json-- the one record a latercdkd state destroywould have worked from, leaving orphan resources unreachable. It now capturesdestroy_rc=${PIPESTATUS[0]}(not$?: the pipe totailis what hid the failure, so the wrong status would reproduce the bug inside its own fix) and gates on both. The rule is documented ins3-versions.shbeside the existing mode comment so it stops being per-fixture folklore, and is enforced by a lint scoped tocleanup()-- the other fixtures purgeallonly on the success path after an unpipedset -e-guarded destroy and need no capture, so an unscoped rule was measured at a 93% false-positive rate. The lint's invocation classifier is enumerated from the tree (${CDKD}/${CLI}/node "${LOCAL_DIST}"/dist/cli.js), excludes the AWS CDK CLI's owncdk destroy, and reports an unrecognised spelling AS SUCH rather than as a missing guard. (3) #2212 -- shipped as a FENCED EXEMPTION, not as the proposed new pattern. The issue asked for agenerateSecretStringentry inSECRET_MATERIAL; traced against the tree, its premise ("cdkd resolves and persists that value into state.json exactly like a hand-written one") does not hold.SecretsManagerSecretProvidermints the value locally from a CSPRNG, hands it toCreateSecret, returnsattributes: { Id }only, never issuesGetSecretValue, and lists bothSecretStringandGenerateSecretStringingetDriftUnknownPaths(); all four candidate fixtures reference their secret by ARN alone. Adding the pattern would have made four fixtures sweep for a value that is not there and forced three of them --composite-stack,event-driven,full-stack-demo, none of which has averify.sh-- to grow one for a non-problem. The shape is instead recorded in a newEXEMPT_SHAPESlist in which each premise is fenced against the provider source, so the exemption fails loudly if any of them stops being true, plus a conditional that revokes it for any fixture that also consumes a secret's VALUE into a template property. (4) The one real exposure the #2212 audit did find is closed here too.rollback-cross-region-secretresolves{{resolve:ssm:...}}against a SecureString -- the shape that makes cdkd issue a realGetSecretValueon the deploy path -- and was the only one of the six fixtures in that class not sweeping. Today's redaction keeps the plaintext out of state, which is a reason to sweep rather than to skip: the redaction is a src-side invariant one bug away from failing, and object versions are forever. It now sources the helper and purges both its cross-region prefixes, with the mode gated onrcplus BOTH per-region destroy statuses, and the lint enforces the rule for the whole class rather than leaving it a soft note. (5) A review round then found that twenty of these fences asserted a SPELLING rather than a PROPERTY, six of them measured green with the guarded thing deleted outright, and the fix was to the shape rather than the instances. The purge-mode guard required.everycaptured destroy status rather than.some-- with.some, dropping half the guard from a two-stack fixture was green while a run whose consumer destroy succeeded and whose producer failed would delete the live producer state and every version of it. Destroy recognition became per-invocation rather than a singlesawDestroyflag, its marker list gained the unbraced$CDKD/$CLI/$LOCAL_DISTforms (unbraced$CDKDis live inacm-certificate), dropped the dead${BIN}, and is now floored by a tree-wide census that requires every destroy-looking line to classify as either cdkd or the AWS CDK CLI. ThegenerateSecretStringexemption fence moved from watching one assignment spelling to asserting the create/update RETURN SHAPE against an allow-list, becauseeffectiveProperties: { ...properties, SecretString: secretString }-- a field seven other providers populate and the engine records verbatim -- left all four of its conjuncts green.rollback-cross-region-secret'snoncurrentarm was INERT: two unconditionalaws s3 rm --recursivecalls above the branch delete-markered the live state.json, demoting it to a noncurrent version the safe arm then deleted, so they moved inside the success branch and a new rule catches the shape anywhere. Three harness cases were vacuous and now assert what the helper REQUESTED via a recorded delete log (theNonecase also gained the realistic literal"null"version id, which must be deleted rather than skipped);s3_purge_key_versions'snoncurrentarm -- the ONLY mode any real call site uses -- gained coverage, as did the quoted-key single-object fallback and the empty-key refusal.cross-stack-secret-importnow sweeps the shared exports indexcdkd/_index/<region>/exports.json-- a sibling prefix nos3_stack_prefixreaches, holding RESOLVED Output values -- key-scoped andnoncurrentonly so a concurrent lane's live index survives, fromsweep()so it runs on EVERY path rather than only after the trap is disarmed, and ASSERTED via a news3_assert_key_versions_sweptwhose mode defaults tononcurrent(demanding zero versions of a SHARED key would demand a state no correct run can reach). A further round then found the same name-vs-identity class in four more places and fixed the MODEL rather than the instances: destroy statuses are tracked as records with a line rather than a list of names, so two destroys copy-pastingdestroy_rc=$?are reported as a CLOBBER instead of satisfying.every; the purge's guard is the depth-aware ENCLOSINGif, so anallpurge after a closedif … fiis unguarded rather than adopting that block's condition; theGetSecretValuepremise matches the call rather than one constructor spelling;attributes.Idis pinned to the physical-id EXPRESSION rather than to the key name; theaws s3 rmcheck keys on the state BUCKET rather than a literalcdkd/path; each falsified-claim record carries its own retired sample; andchildEnv's pins are read back from a derived list and cannot be shadowed by a per-call extra. Finally, three prose claims this lane had corrected per-FILE were found still standing in other files, so the correction is now a FENCE: a tree-wide residual check overdocs/**,.claude/rules/**and the test corpus fails if any falsified claim reappears anywhere.cdkd no longer acts on an S3 bucket it has not confirmed is the one it means, at three sites (issues #2241 and #2245) --
src/provisioning/providers/s3-bucket-provider.ts, plus two new suites (tests/unit/provisioning/s3-bucket-provider-us-east-1-preflight.test.ts, 15 cases;tests/unit/provisioning/s3-bucket-provider-state-region-guard.test.ts, 18 cases) and premise repairs in four existing S3 suites. Same family as the create-path adopt guard of issue #2227, which stopped NEW poisoning and reached neither of these.#2241, the
us-east-1legacy 200. The partial-create cleanup of issue #376 is gated oncreatedNewBucket, which was set from a 200 onCreateBucket. That is proof in every region but one:@aws-sdk/client-s3dist-types/models/errors.d.tsdocuments onBucketAlreadyOwnedByYouthat S3 "returns this error in all Amazon Web Services Regions except in the North Virginia Region. For legacy compatibility, if you re-create an existing bucket that you already own in the North Virginia Region, Amazon S3 returns 200 OK and resets the bucket access control lists (ACLs)." So inus-east-1an ADOPTED bucket was marked as created, and a later sub-config failure (a malformed lifecycle rule, a policy the account rejects, a throttle) firedDeleteBucketCommandat a PRE-EXISTING user bucket -- precisely the outcome #376's gate exists to prevent.DeleteBucketrefuses a non-empty bucket, so a bucket holding objects survived; an EMPTY one did not. It is not an explicit-BucketNameedge case:generateResourceNameproduces{stackName}-{logicalId}with no region or account in it.create()now runs a pre-flightGetBucketLocationONLY when the target region canonicalizes tous-east-1, so every other region keeps its hot path unchanged and no extra round trip is spent where the 200 already proves the create. Three outcomes, deliberately kept distinct: the name is FREE (a 404) so the 200 proves creation and the self-heal still runs; the name is TAKEN socreatedNewBucketstays false and a warning says the bucket was ADOPTED and that the legacy 200 has just reset its ACLs -- the second, smaller effect of the same adopt, and one a SUCCEEDING deploy gives the user no other signal of; or the probe could not answer, in which case the cleanup is withheld and a warning at the moment it would have run namesaws s3api delete-bucketfor the orphan it declined to delete. Collapsing "could not answer" into "absent" is exactly what would restore the delete this fixes, so the two are separate variants of one type rather than a nullable region. The probe informs the cleanup gate ONLY and never replaces theCreateBucketcall:CreateBucketis the authoritative OWNERSHIP oracle (a bucket held by another account fails it withBucketAlreadyExists) whileGetBucketLocationcan succeed against a foreign-owned bucket whose policy allows it, so skipping the create on a positive probe would have traded this bug for a worse one. This NARROWS the defect rather than closing it, and the residual is accepted deliberately: the probe and theCreateBucketare two calls, so a name that is free when asked can be taken before it is used, and inus-east-1the legacy 200 then adopts that bucket withcreatedNewBucket === true— the original bug, through a window now measured in milliseconds instead of standing open for every deploy. Closing it entirely needs an atomic create-or-tell-me-who-owns-it that S3 does not offer in this region; aHeadBucketretry after the 200 would only move the window. One further branch is a fail-CLOSED floor rather than a live fix: if a 200 ever arrives over a bucket the pre-flight placed in ANOTHER region, cdkd raises the identicalRefusing to adopt existing S3 bucketerror the 409 path raises instead of warning and configuring it. Not producible against AWS as documented (the legacy 200 is scoped to a bucket you own IN N. Virginia, and a cross-region owned bucket answers 409 — measured), and kept because the previous behaviour there was a warning whose own text contradicted the region it named.#2245, an already-poisoned state record.
assertExistingBucketRegionis on the CREATE path only, so a record written by any build predating it -- naming a bucket this account owns in another region -- was never detected, and two paths acted on it.update()had no region check at all and applied the stack's whole bucket configuration to whateverphysicalIdthe record named.delete()'sassertRegionMatchfires only from theNoSuchBucketbranch, which a cross-regionDeleteBucketnever reaches: SDK v3's region-redirect middleware FOLLOWS the 301 for body-bearing operations, so the delete simply SUCCEEDED against the other region's bucket. Both now REFUSE on a determinate mismatch, with amarkNonRetryableerror naming both regions and what the operation would have done. The REMEDY is per-operation, not shared: cdkd's state is region-keyed (cdkd/{stack}/{region}/state.json), so "rerun this stack against the bucket's region" -- correct for an update -- is wrong twice over for a destroy, since that run finds no record of the stack and, if one existed, the advice would be to go and destroy the very bucket the guard just refused to touch. The delete branch instead says to delete the bucket deliberately in its own region if it is genuinely yours, or to drop the stale record withcdkd state orphan <stack>. Refuse rather than heal on update, because healing means rewriting a recorded physical id -- a state mutation whose blast radius exceeds the misconfiguration it repairs; refuse rather than proceed on delete, because a stranded destroy needs a human while deleting a live bucket in another region cannot be undone. The delete guard sits AHEAD of the auto-empty, not beside theDeleteBucket: emptying a foreign-region bucket destroys the data whether or not the delete that follows succeeds.What the guards deliberately do NOT do, and the new IAM dependency that creates. Both PROCEED when the probe reports absence or cannot answer. The hazard needs a bucket that EXISTS and is owned by this account, and for exactly that bucket
GetBucketLocationanswers -- from any endpoint in the same partition (measured 2026-08-13, recorded insrc/utils/aws-region-resolver.ts) -- so the determinate case covers the whole defect, while failing closed on an unanswered probe would strand every update and destroy for a least-privilege role, with no per-resource override to force one through. A principal that cannot calls3:GetBucketLocationon the target is NOT an unrelated population -- an earlier revision of this entry said it was, and that is wrong. It is the same population, merely unprobeable: such a principal can hold a poisoned record like any other, and for it the issue #2245 guard is INERT on every call, permanently. So this change adds an IAM dependency: the guard is only as good ass3:GetBucketLocationon the target bucket, which a bucket POLICY can alsoDeny-- and aDenyis indistinguishable on the wire from a missing grant, so refusing on 403 alone cannot separate the two. Because the guard can therefore be switched off by its own subject, the delete-side degrade logs atwarn, notdebug: cdkd's default level isinfo, so the previous debug line made the one path where failing open destroys data look byte-identical to a healthy destroy. The warning names the check that did not run, the consequence ("this destroy will delete THAT bucket"), and both ways out, including that a bucket policy can be the cause -- an operator who checks only IAM finds the grant present and stops looking. The update-side degrade stays atdebug, and that asymmetry is asserted rather than assumed: a misapplied bucket configuration is re-appliable. Not retried on throttling, deliberately -- a retry does nothing for the hostile-Denypath (a 403 carries neither a throttling name nor a retryable status), and the provider has no injected sleep seam here, so a bounded retry would add wall-clock to every throttled destroy or need a new timer seam plus fixtures to control it. The delete guard is additionally gated onDeleteContext.expectedRegionbeing present, which isassertRegionMatch's own documented back-compat rule: with no recorded region there is no expectation to compare against, so a pre-v2 record keeps its previous behaviour rather than paying a round trip for a comparison it cannot make. That cohort now WARNS too, and it is the one that most needed it. A record with no region is the likeliest to predate the #2227 create-path guard and therefore the likeliest carrier of a foreign-region physical id, yet it was the one population that got no probe, no refusal and no output at all -- the destroy's console was byte-identical to a healthy one. It gets a default-level warning with a remedy specific to ITS cause (re-deploy so the record gains a region), not the probe-failure one, since nothing is missing from that caller's IAM. Still warn-only: refusing there would strand every legacy destroy with no IAM remedy available at all, and defaultingexpectedRegionto the client region indestroy-runnerwould fabricate an expectation the record never made while also changingassertRegionMatchon theNoSuchBucketpath. Cost on a real destroy is therefore one read-onlyGetBucketLocationper bucket whose state record carries a region, and the outcome for every in-region bucket is unchanged.One predicate, not three spellings. A FAILED probe can still carry the answer, and is no longer discarded -- but ONLY from the two shapes where the header describes the BUCKET: the 301 redirect (
PermanentRedirect) andAuthorizationHeaderMalformed, the SigV4 mismatch reporting the region you should have signed for. Every other failure -- a 403, a throttle, a 404 -- is answered by the endpoint that was ASKED, so itsx-amz-bucket-regionnames where the QUESTION went. Reading it off ANY failure, which an earlier revision of this change did, INVERTED both guards built on it and is worth recording as a defect the fix itself introduced: on delete, a 403 whose header echoed the deploy's own region compared EQUAL, so the guard returned silently and the warning written for the hostile bucket-policyDenypopulation never fired; on create, it promoted an unanswerable probe to "the bucket is already there", producing a false ADOPTED-and-ACLs-reset warning over a genuinely NEW bucket and suppressing the orphan-cleanup warning as well.carriesBucketRegionHeaderis now the single statement of which shapes count, and both polarities are fenced (a 403 header is read neither as a match nor as a mismatch). The absence check still runs FIRST: a 404 is answered by the endpoint that was ASKED, and "the name is free" has to win. All three sites answer "where does this bucket live" through the sameresolveOwnedBucketRegionthe #2227 guard already used -- itscreateErrorparameter is now optional, because a PRE-FLIGHT caller has no AWS error to readx-amz-bucket-regionoff and only theGetBucketLocationhalf applies -- and the two #2245 sites share ONEassertStateBucketRegion, differing only in the operation-specific clause. The empty / nullLocationConstraintfold tous-east-1and the legacyEUfold toeu-west-1therefore cannot diverge between them.Wire shapes are MEASURED, not inferred from a command's declared throw list. An earlier revision of this entry claimed
GetBucketLocationdoes not deserialize its 404 into the modeledNoSuchBucketclass "the wayDeleteBucket's is", reasoning fromGetBucketLocationCommand.d.tsdeclaring only@throws {@link S3ServiceException}. That is false, measured 2026-08-26 by feeding canned 404 XML through the real client: both operations yieldctor=NoSuchBucket,name=NoSuchBucket,instanceof NoSuchBucket === true,status=404.DeleteBucketCommand.d.tsdeclares the same lone throw, and errors resolve through a per-NAMESPACE schema registry rather than a per-operation one, so the declared list never decided this. The predicate shipped was right for the wrong reason; the reason is corrected at all five in-tree sites rather than quietly dropped, because the wrong one would have justified the wrong fixture in the next provider that copied it -- and the modeled class is now the DEFAULT fixture everywhere, the hand-built variants remaining only where they fence an individual arm. The predicate itself is now the wire CODE alone. A bare HTTP 404 is deliberately NOT absence: on the create pathabsentis the answer that ENABLES the cleanup'sDeleteBucket, so a 404 from a corporate proxy, anAWS_ENDPOINT_URLoverride or an S3-compatible gateway must not license a delete on the strength of a response S3 never sent; narrowing costs only that such a 404 becomesindeterminate, which is every caller's non-destructive branch. Aninstanceof NoSuchBucketarm was written and then REMOVED: the generated class assignsname = 'NoSuchBucket'on the instance, so no case could ever make it the deciding one, and a branch nothing can make decide is a branch nothing protects.The guards are SDK-ROUTE-ONLY, and a sixth door bypasses all of them.
AWS::S3::Bucketdeclares silent-drop properties (AccessControl, which CDK's L1 still emits foraccessControl:), soprovider-registry.tsauto-routes such a bucket toCloudControlProviderand pinsprovisionedBy: 'cc-api'stickily -- the type is not inSTICKY_CC_MIGRATION_EXEMPT. On that routeS3BucketProvider.deletenever runs: no probe, no refusal, no warning at all, andCloudControlProviderdoes only the client-regionassertRegionMatch, which cannot see a physical id naming a bucket elsewhere. Tracked as issue #2283 and deliberately NOT addressed here (the fix is in routing, not in this provider); recorded because the guards would otherwise read as full coverage for the type.Every default-level warning on these paths names the error CLASS, never AWS's message -- all four of them. The guard's fail-open line, the create path's declined-cleanup line, and the create path's failed-
DeleteBucketline (which predates this work, issue #376) each carried an AWS message; an earlier revision of this entry claimed the invariant while only the first had been fixed, because the sweep was done by reading the diff instead of grepping every reader ofreason. On the very population these warnings exist for -- a bucket policy denyings3:GetBucketLocation-- S3's text readsUser: arn:aws:sts::<account>:assumed-role/<role>/<session> is not authorized to perform: s3:GetBucketLocation on resource: ..., so an earlier revision printed an account id, a role name and a session name to the terminal and to CI logs for exactly that cohort: making the guard visible had also made the CALLER visible. AWS's own text moves todebugand the line points at--verbose, the answerdynamodb-index-busy-delete.tsalready reached for the same class. Terminal-only, so the issue #2179 redaction surface is not in play.carriesBucketRegionHeaderwas tightened the same way in the same pass: it matches the wire error CODE alone and no longer accepts a barehttpStatusCode === 301, since a status S3 never NAMED is not S3 speaking -- a gateway answering 301 with the deploy's own region would have compared EQUAL and returned silently, which is the 403 defect arriving through the status instead of the header.A cdkd refusal is not an AWS error to classify. The create path's
catchnow re-throws aProvisioningErrorbefore anything else, so the classifier keying oncreateError.name === 'BucketAlreadyOwnedByYou' || message.includes('you already own it')can never swallow the foreign-region refusal raised in the sametryinto the "already owned" arm and configure that bucket -- the #2227 outcome, reachable by nothing more than a reword of the refusal's English. Inert today, and it is fenced at SOURCE level rather than behaviourally for exactly that reason: no input can distinguish the two orderings while the wording does not match, so the fence reads the code and asserts the pass-through precedes the classifier. Same unguarded-wrap class already fixed forupdate().Four existing suites had their PREMISE repaired rather than their assertions relaxed, because the
us-east-1mock they shared encoded a combination AWS cannot produce.s3-bucket-provider-already-owned-region.test.tsands3-bucket-provider-partial-create-cleanup.test.tsnow mockeu-west-1: aus-east-1client receivesBucketAlreadyOwnedByYouONLY for a bucket that lives elsewhere, so three cases pairing that 409 with aus-east-1client and aus-east-1readback were unreachable. The ADOPT side of the empty-LocationConstraintfold moved to the new pre-flight suite, which is the reachableus-east-1path to it; the REFUSING side stays where it was.s3-bucket-provider-container-shape.test.tsanswersGetBucketLocationwith its own client region rather than the blanket{}(an absentLocationConstraintIS a valid us-east-1 answer, so the blanket mock told the update guard the bucket was elsewhere), ands3-bucket-provider-per-item-string-replay.test.tsanswers the pre-flight with a 404 so its creates are genuinely NEW buckets. The onePublicAccessBlockConfiguration: present -> absent issues NO call at allcase asserts the WHOLE command list rather than subtracting a name, so its CFn-parity claim keeps its force. Retiring the impossible combination was right, but the blanket region flip also moved a REACHABLE one -- a us-east-1 client DOES receiveBucketAlreadyOwnedByYoufor a bucket of yours in another region, the legacy-200 exception covering only a bucket you own IN N. Virginia -- and that is precisely where issues #2241 and #2227 meet. The pre-flight suite now covers it in both shapes, including the twoGetBucketLocationcalls a headerless 409 costs (the pre-flight resolves the region, the #2227 readback asks again), asserted so the count is a decision on record rather than an unexplained diff.Each guard was mutation-probed separately and the probes are one-to-one: reverting the #2241 cleanup gate reddens 7 pre-flight cases; comparing the region raw instead of canonicalized reddens only the
--region US-EAST-1case; removing the update guard reddens 5; removing the delete guard reddens 7 while leavings3-bucket-provider-delete-data-guard.test.tsgreen (it passes noexpectedRegion); makingabsentorindeterminaterefuse reddens exactly the over-tightening cases written for them.cdkd local start-service/start-albno longer echo a secret's plaintext when a:json-key:reference points at a non-JSON secret -- the fix reaches the BUNDLED resolver, which is the one those commands run (cdk-local PR #558) --package.json,pnpm-workspace.yaml,pnpm-lock.yamlonly; nosrc/**change. Issue #2189 fixed cdkd's ownsrc/local/ecs-secrets-resolver.ts, and the entry below correctly notes that copy IS live -- but only forcdkd local run-task.src/cli/commands/ecs-service-emulator.tsimportsrunEcsServiceEmulatorfromcdk-local/internal, sostart-service/start-albdelegate wholly to cdk-local and kept executing the leaking shape: the samecatchinterpolatingJSON.parse's own message, into which V8 embeds a prefix of the PARSED INPUT -- here the secret plaintext this resolver just fetched from Secrets Manager. cdk-local shipped the identical remediation in 0.147.7; cdkd said^0.147.6while the lockfile pinned 0.147.6 andpnpm-workspace.yaml'sminimumReleaseAgeExcludepinnedcdk-local@0.147.6exactly, so users were served the leaking bundle. Both pins now name 0.147.7, the exclude staying an EXACT pin rather than a range so the min-release-age bypass does not become standing. Verified against the installed tree, in both directions. Grep ofnode_modules/cdk-local/dist/*.jsbefore the bump: 1 hit forbut the secret value is not valid JSON:(the interpolating form) and 0 for the withheld-detail wording; after: 0 and 1. Live probe driving the shipped bundle'sresolveEcsSecretswith a stubbed Secrets Manager client -- no AWS resources created -- against a 14-character secret, the worst case a prefix-only assertion misses, since V8 quotes an input of 20 characters or fewer IN FULL: 0.147.6 answeredContainer 'app' secret 'DB_PASSWORD' specified json-key 'password' but the secret value is not valid JSON: Unexpected token 'P', "PLNTXT-hunter2" is not valid JSON, 0.147.7 answers... but the secret value is not valid JSON (SyntaxError). The parser detail is withheld because it would echo the secret plaintext.The pre-bump run is the negative control that proves the probe REACHES the branch, and the assertion pairs the absence of the plaintext with positives on the container, env var and requested json-key, because "the secret is absent" alone is satisfied by any unrelated rejection.resolveEcsSecretsis not re-exported fromcdk-local/internal, so the probe imported the shipped chunk byte-for-byte with one appendedexportline and nothing else edited. The version delta is exactly this one change: a text diff of the two published tarballs shows identical file sets, identicaldependencies/peerDependencies, an unchanged lockfile entry count (1014 both sides, no new transitive package), an identicaldocker-cmd-*.jschunk, and in the main chunk two changed lines -- theEcsSecretsResolutionErrorthrow and its newconst kind-- plus the content-hash rename and the CLI version string.The gc placeholder sweeper's fences now fence, and a CI-visible consent change is documented (follow-up to issue #2052, shipped in 0.284.54) --
src/cli/commands/gc.ts,src/state/s3-state-backend.ts,docs/cli-reference.md, plustests/unit/cli/gc-custom-resource-responses.test.ts,tests/unit/state/s3-state-backend.test.tsandtests/unit/state/custom-resource-response-prefix-sync.test.ts. Independent review of the merged PR found the SWEEP itself correct in both directions -- a 5,000,000-sample probe of the realgetResponseKeyoutput found zero misses, and a corpus of 7 colliding--state-prefixvalues x 5 stack names x all 8 state key families classified 0 for DELETE -- but found four FENCES that did not fence, which on a sweeper is the dangerous class, since under-collection and a clean bucket are indistinguishable. (1)listRawObjectsnever asserted itsPrefix: deletingPrefix: keyPrefixpassed 136/136, and unscoped the sweep would list the whole state bucket with the leaf regex as the only remaining guard -- the same class as the--state-prefixcollision the PR had just fixed. The pagination case now asserts it on EVERY page, since a continuation call that dropped it would widen page 2 alone. (2) The prefix-sync fence derived its population fromgit ls-files 'src/**/*.ts', which returns 326 of 328 tracked files -- git wildmatch needs a literal/for the**/segment, sosrc/index.tsandsrc/version.ts, one of them the library ENTRYPOINT, were exempt; planting the literal insrc/index.tsleft the fence green, and its> 200floor cannot see a 2-file gap. The pathspec is now a pair and carries explicit membership floors on both files. (3) A DECOY needle made a guard test vacuous: it assertednot.toContain('placeholder(s) from'), a literal that exists only in the ERROR message and never reacheslogger.info, so removing the very guard it claimed to fence passed 136/136. It now anchors on the literal the success line actually renders, with a positive assertion in front so the negative is not a confluence point. (4) The consent prompt -- which the PR body itself calls the consent surface for a widened blast radius -- was ungrepped by any test; reverting its wording passed 136/136. Also: the reclaim totals were unfenced in both the plan and the final line (now pinned with deliberately different per-arm byte counts, since a single-armed fixture cannot tell a dropped term from a printed one); the partial-delete error reported the ATTEMPTED count in its wrapper while the wrapped error reported the ACTUAL one, contradicting itself and both asset arms;listRawObjectsdropped entries missingLastModified/Sizesilently, which is the right DECISION but the invisible failure this feature is about, and now debug-logs which field was missing; and two JSDoc blocks had been orphaned by symbols inserted between them and the functions they document. The published mutation matrix is re-measured rather than patched: two rows were stale, and the marker-reorder mutation's failure set was a strict SUBSET of the delete-removal set -- an isolating--dry-runcase now separates them. The empty-random-suffix frequency claim is corrected by exhaustive enumeration rather than sampling (0 hits in 5,000,000 calls says nothing): V8'sMath.random()returnsk * 2^-52, and counting all 36^5 five-digit base-36 fractions gives 14,921,970 reachable inputs, a rate of 3.3e-9, roughly 1 in 3.0e8 -- four orders of magnitude commoner than the review's sampled estimate, so the*quantifier decision is better supported than it was stated to be. Recorded in theRESPONSE_PLACEHOLDER_KEYJSDoc so it need not be re-derived.docs/cli-reference.mdgains an upgrade note for CI: a non-interactivecdkd gcwithout-ythat used to exit 0 on an account with nothing to collect now hard-errorsNON_INTERACTIVE_CONFIRMonce placeholders have accumulated, because "zero candidates" now includes them and the pre-prompt early return no longer fires.cdkd bootstrapno longer adopts an asset bucket this account owns in ANOTHER region (issue #2240) --src/assets/asset-storage.ts, plustests/unit/assets/asset-storage.test.ts. S3 bucket names are globally unique while a bucket is regional, soBucketAlreadyOwnedByYou(409) and a cross-regionHeadBucketredirect both report ACCOUNT ownership, not the bucket's region. (The same conflation inS3BucketProvider.createis issue #2227, still open and NOT onmainat the time of writing -- this change shares its shape, not its code.) Only the 409 actually proves ownership: a redirect is emitted by the routing layer BEFOREExpectedBucketOwneris evaluated, so the refusal says the name "resolves to a bucket in X" rather than claiming cdkd owns it. It read as structurally unreachable here becausegetCdkdAssetBucketNameembeds the region, but that is only the DEFAULT:cdkd bootstrap --asset-bucket <name>takes a caller-chosen, region-free name, so bootstrapping two regions under one custom name reaches it. Measured against real S3 on 2026-08-26, which CORRECTS the issue's own mechanism: theBucketAlreadyOwnedByYouswallow is NOT how the ordinary case arrives, because theHeadBucketpre-check runs first and a cross-region owned bucket makes it fail with the SDK's syntheticUnknown/UnknownErrorat status301. So the ordinary case already failed CLOSED -- but vianormalizeAwsError, whose 301 wording ("cdkd resolves this automatically; if you see this message, please report it") is true for the STATE bucket and FALSE here, sending a user with a self-inflicted naming collision to file a bug; andverifyAssetStorageExistsdid not even reach that, rethrowing the bareUnknownErrorwith no context at all. The 409 swallow remains reachable through aHeadBucket-then-CreateBucketrace, and would have run this region's encryption / public-access-block / deny-external-account PUTs against the foreign-region bucket. A second measurement makes that arm ordinary rather than exotic: a same-region re-create answers200only on theus-east-1legacy endpoint, whileap-northeast-1andus-west-2both answerBucketAlreadyOwnedByYou-- so in every region but one a plain same-region race arrives as a 409, and the guard's region EQUALITY check, not the error name, is what lets it through. All three sites now route through oneassertAssetBucketRegionguard that refuses withASSET_STORAGE_FOREIGN_REGION_BUCKET, naming both regions and both remedies. The redirect predicate is DERIVED from the AWS SDK's own (regionRedirectMiddleware: header present AND status301, or400withIllegalLocationConstraintException/ aHeadBucketcommand) and is deliberately wider in two places -- it accepts a301with no header at all (theGetBucketLocationfallback resolves that case, and requiring the header there was a regression caught in review) and it drops the SDK's inner conjunct on the400arm, because that conjunct decides whether to silently RETRY elsewhere while the only thing done here is refuse. A status-301-only gate silently misses the400form; a bare header-present gate would read a500carrying a stray region header as a redirect, so both halves are pinned. The region is read fromx-amz-bucket-regionon the error itself (measured present on both the 301 and the 409 -- no extra call, no extra IAM), falling back toGetBucketLocationwith the empty / legacy-EUconstraints folded tous-east-1/eu-west-1; deliberately NOTresolveBucketRegion, which never throws and returns itsfallbackRegionon a failed probe and would turn a fail-CLOSED guard fail-OPEN -- an undeterminable region refuses too.--forcedoes not license it:--forcemeans re-apply configuration to the bucket you INTENDED, never write to one in another region. Contrast the STATE bucket, whichbootstrap.tsdeliberately re-points at the bucket's own region viarebuildClientForBucketRegionbecause one state bucket serves the whole account; asset storage is per-region by design. The sibling-site sweep found the same conflation on the TEARDOWN path:emptyAndDeleteBucket(src/cli/commands/bootstrap-destroy.ts) probes with the sameHeadBucket, so a cross-region asset bucket died on the same misleading 301 wording. It now takes an OPT-INexpectedRegionand routes a 301 through the same guard -- opt-in rather than always-on because its two callers have OPPOSITE polarity: the asset bucket is per-region, while the STATE bucket legitimately lives anywhere and its caller already pre-resolves the client withrebuildClientForBucketRegion, so a blanket refusal would turn--include-state-bucketinto a false refusal whenever that pre-resolution degrades to the ambient client.cdkd gc's site was checked and left alone on measurement rather than reasoning: it probes withListObjectsV2, whose 301 carries a real XML body, so the SDK raises a genuinePermanentRedirectwith AWS's own actionable text rather than the syntheticUnknownError-- andnormalizeAwsErrorpasses a non-synthetic error through untouched. Seventeen unit arms across two files, each half of the fix mutation-probed RED separately (the redirect arm, the 409 swallow, theverifyAssetStorageExistsarm, and the teardown arm), plus three deliberate controls that must stay GREEN: a SAME-region 409 race still proceeds and still writes the marker,EU/ empty location constraints fold rather than refuse, and the state bucket still gets the generic 301 rendering -- making the guard always-on reds that last one alone, which is what proves the opt-in is load-bearing rather than decorative. Four reviewers then found five further mutations the first suite let through -- a header reader that ignored the header's KEY (its fixture carried a single header, so in production it would have returned a date string as the region), an emptyLocationConstraintfolding to the TARGET region rather thanus-east-1(fail-OPEN, and the original arm could not see it because its target WASus-east-1), a same-region redirect being read as "verification passed", the region canonicalization on the comparison'sactualside, and the400spelling above. All five now go RED.An optional
DeploymentCircuitBreakermember dropped from an ECS service template is now RESET to its AWS default instead of silently retained (issue #1861) --src/provisioning/providers/ecs-provider.ts, plustests/unit/provisioning/ecs-deployment-configuration-subfield.test.ts, a live arm intests/integration/ecs-service-update-props/, and the §2a checklist indocs/provider-development.md. This is the #1160 absent-field-removal class one level DOWN -- the #1225 shape -- and the opposite polarity to #1802: there cdkd is too PERMISSIVE (it deploys a template CloudFormation refuses), here it was too STICKY (it failed to apply a removal CloudFormation applies).AWS::ECS::Service.DeploymentConfigurationis forwarded toUpdateServiceas a recursive PascalCase -> camelCase flip, and forDeploymentCircuitBreaker's two OPTIONAL members the ECS API RETAINS an omitted value: a user who deletedResetOnHealthyTaskfrom their template kept the stale livefalseundercdkd deploywhilecdk deploygave them AWS'strue, withcdkd diffreporting nothing. Measured us-east-1 2026-08-13 from an identical baseline withRollbackflipped in the same call so the update demonstrably applied, service identity captured on both sides so a replacement could not be mistaken for a reset: cdkd leftfalse/{COUNT, 7}intact where CloudFormation producedtrue/{BOUNDED_PERCENT, 50}. The fix keys on the semantic that measurement actually supports, which is narrower than "materialize the defaults": a member the template NEVER declared is LEFT ALONE by CloudFormation -- an out-of-band value survived a CFn update that changed onlyMinimumHealthyPercent, and survived again when a SIBLING was flipped INSIDE the same block, the arm that excludes the competing reading "CFn re-serializes a nested struct whenever its declared content changed". So this is a previous-present / current-absent REMOVAL, and the newECSProvider.resolveDeploymentConfigurationreuses the sharedclearOnUpdateRemovalhelper rather than re-spelling the predicate; only the DEPTH is new. Scope is deliberately narrow and each boundary is measured rather than reasoned about: only those two members (the required siblingRollbackreads as REPLACED, so one block holds members with different semantics); NOT the depth-1 siblingsLinearConfiguration/CanaryConfiguration/ a hook'sTimeoutConfiguration, which are already PARITY because the ECS API itself replaces the struct and default-fills the omitted member (PR #1848) so a reset there would BE the divergence; and only while BOTH template sides still declare theDeploymentCircuitBreakerblock, since removing the wholeDeploymentConfigurationresets nothing under either engine (#1805) and the parent block vanishing entirely was never measured. THIRTEEN unit shapes replace the two that pinned the old pass-through, each written against what the REGRESSION would emit rather than against a confluence point. (Thirteen is the count of#1861arms inecs-deployment-configuration-subfield.test.ts, derived by listing the file'sit(titles rather than from recall -- an earlier draft of this entry said ten, which was the count before the last review round added three.) Both removal halves (a scalar member and a whole nested BLOCK, since a fix could plausibly handle one and miss the other); the NEVER-declared arm that stops the fix degenerating into always-send-defaults; the ROLLBACK replay direction with previous/desired SWAPPED (issue #1609 -- a fix keyed on "the sides differ" would discard the user's value on the way back); the whole-block-gone boundary; and the CREATE path, which must never synthesize a member. Four more came out of review, and the first of them closed a real hole: every one of the arms above is ONE-SIDED (previous-only or current-only), so a resolver reading the PREVIOUS side alone --previous['ResetOnHealthyTask'] !== undefined ? DEFAULT : desired-- passed all of them while sending AWS'strueon every deploy of a template declaringResetOnHealthyTask: false, silently discarding the user's value. The BOTH-SIDES-declared arm and its CHANGED-value polarity twin close it. The third pins that the reset value is a FRESH object per call rather than the shared module constant (ECSProvideris a singleton serving concurrent resources, so a by-reference default would be one mutable object across every in-flightUpdateService; the constant is nowObject.freezed and spread at the use site). The fourth pins the LIMIT of the presence test rather than a desirable behavior:clearOnUpdateRemovalasks whether the previous BAG carried the member, which equals "the previous TEMPLATE declared it" only on the deploy path -- atdrift --revertthe previous bag is an AWS READBACK, so a member no template declared reads as a removal. That exposure is shared by everyclearOnUpdateRemovalcall site in the codebase -- 78 of them across 14 provider files before this PR, 80 with this resolver's two (grepped, not estimated;asg-provider.tsandlambda-function-provider.tscarry 13 each) -- and belongs to that shared contract rather than to this resolver, so it is documented and pinned rather than fixed here. A later round added THREE more. One covers the circuit breaker DISABLED (Enable: false): every other unit fixture AND every integ phase usedEnable: true, so a reset gated onenable === truepassed both, silently reinstating the divergence for anyone who turns the breaker off without deleting its configuration. The other two cover MALFORMED shapes, which is where the guard changed from truthiness to "is a plain object": a non-object breaker used to take the rebuild arm, so'oops'spread to{0:'o',1:'o',2:'p',3:'s'}and then gained both synthesized defaults. The matching PREVIOUS-side guard is DEFENSIVE ONLY and is labelled as such in both the code and the test rather than left to look load-bearing -- relaxing it leaves the file green, and while an array carrying a NAMEDResetOnHealthyTaskproperty does discriminate it, no previous bag reachable fromJSON.parsed state, resolved template properties or an SDK readback can be one. FOURTEEN distinct mutations were probed across the three rounds -- thirteen go red, and the fourteenth SURVIVES and is reported rather than hidden. (Both numbers were derived by counting the entries in the list that follows, not from recall; an earlier draft claimed nine while listing ten, and a first attempt at this correction said fifteen/fourteen by double-counting the by-reference mutation, which is measured in both freeze states inside a single entry.) The thirteen that red: reverting to the pass-through reds four; an always-send-defaults fill reds five including the never-declared arm; a symmetric "sides differ" key reds the rollback arm; widening past the still-declared guard reds the boundary arm; moving the fill into the shared converter reds eight including both CREATE pins; the one-sided previous-only key reds the two both-sides arms; a!xpolarity slip reds three; handing the shared constant out by reference reds the aliasing arm; removing ONLY theObject.freezewhile keeping the copy also reds it, which it did NOT before review, because nothing assertedObject.isFrozen(the leak into a neighbouring test that an earlier draft of this entry described happens only when the freeze is removed TOO -- with the shipped freeze in place the by-reference mutation is a singleTypeError, no leak); replacing the conditional spreads with plain assignments reds the two removal arms -- which it did NOT before review, because the key-set assertion had been sitting on the never-declared arm, the one case that takes an identity early-return and never builds the object at all; deleting the...desiredspread from the rebuild reds the removal arm (that mutation drops every SIBLING top-level member fromUpdateServiceon any deploy that removes a circuit-breaker member, and ECS's server-side merge then keeps the old values silently -- it was CI-invisible until the removal fixture gained siblings); gating the reset onenable === truereds the DISABLED arm; and relaxing the desired-side plain-object guard back to truthiness reds the malformed arm. The survivor is relaxing the PREVIOUS-side plain-object guard, which leaves all 36 green -- recorded as inert rather than pinned by a contrived fixture. Theecs-service-update-propsinteg gains BOTH live arms on the fixture it already had. Phase 1 now declares the two members with NON-DEFAULT values (false/{COUNT, 7}) and asserts they LANDED, because a baseline equal to the AWS default would make the phase-2 assertion vacuous; phase 2 drops them from a still-declared block and asserts AWS reports the defaults, tolerating either the explicit form or an OMITTED member (both mean "at its default") while still failing on the stale phase-1 value, which is a third distinguishable state. A new phase 2c is the NEVER-DECLARED arm, and it is the one that DISCRIMINATES the removal rule from "re-serialize the struct whenever its declared content changed": with the members never-declared since phase 2, verify.sh sets them OUT OF BAND, asserts the write landed, then runs a cdkd deploy whose only change isRollbackflipped back on INSIDE the same block, asserts that flip actually applied, and requires the out-of-bandfalse/{COUNT, 9}to SURVIVE -- clobbering them would be a divergence in the opposite direction, destroying a value CloudFormation deliberately preserves. Numeric reads go through--output jsonplus anawknumeric compare rather than a--output textstring compare, so a7.0rendering cannot fail a correct run; every read expression was executed against fixed, broken and omitted payloads before shipping. No newtrapis registered: the out-of-band edit lands on the service the existingcleanuphandler already tears down, and a secondtrap ... EXITwould REPLACE the first rather than chain, stranding the stack. A shape sweep of every other nested struct forwarded whole on an update path found roughly a dozen further candidate clusters (CognitoPoliciesand the API Gateway v2 Stage blocks are the two whose AWS-retains half is already live-probed, with the CloudFormation arm still missing); they are NOT swept here, since the ECS block is itself the proof that members of one struct carry different semantics and each needs its own A/B.docs/provider-development.md§2a gains the generalizable step the audit was missing -- walk the DEPTH-1 members, not just the top-level properties -- with the never-declared and rollback-replay traps named.Every declining arm of
cdkd scrub's cross-stack pre-pass now names itself, andcdkd drift's accept summary and accept plan stopped claiming writes they did not make (issues #2163 and #1958) --src/cli/commands/scrub.ts,src/cli/commands/drift.ts,src/deployment/outputs-export-alias.ts, plustests/unit/cli/commands/scrub-import-value-secret.test.ts,tests/unit/cli/commands/scrub-export-name-collision.test.tstests/unit/cli/drift-secret-redaction.test.tsandtests/integration/secrets-dynamic-ref/verify.sh.makeCrossStackPrePass'sreadOnedecides whether a stack may be reported clean, and FIVE of its outcomes returned without logging anything:!producer(the resolver recorded no cdkd cross-stack read, i.e. a CloudFormationListExportsfallback), theproducerTemplatesmiss (the producer is not a stack of this app), anoverdict (the producer's template declares no{{resolve:...}}expression for that key),!stored(the producer's record could not be read or does not carry the key), and the healthycarriesDynamicReference(stored)arm. Issue #2163 named the first three and reasoned from "every arm that classifies and then declines logs a debug line" that a failing real-AWS run must have taken one of them; that premise was false for the other two, so the absence of a debug line ruled nothing out. All five now emit alogger.debugnaming the reference, the producer and the reason -- with both identifiers MASKED, which the review round made the blocking finding:producer.keyisrecordedImports.at(-1).exportName, the POST-resolution export name, andresolveImportValuesets it fromresolveValue(importValueArg), so anFn::ImportValueover anFn::Sub/Fn::Join/ bare string assembling a{{resolve:secretsmanager:...}}makes it a resolved secret verbatim. The refusal path in the same file already masked both and records whymaskSecretsInErroratscrubStack's boundary is not sufficient (allRecordedSecretsDROPS every needle belowMIN_NEEDLE_LENGTHwhile the whole-value arm ofmaskSecretsInTextmatches at any length); alogger.debugline has no boundary net at all.producer.stackis masked because it is REACHABLE too, which the first cut of this change got wrong and a later review round corrected: it justified that mask as UNIFORMITY on the reasoning that a producer name comes from a state record and is never resolved. That holds forFn::ImportValue, whose producer name comes from the state-bucket listing, and is FALSE forFn::GetStackOutput--recordOutputRead(context, stackName, ...)is fedawait this.resolveValue(args['StackName'], context), so anFn::Sub/Fn::Joinassembling a{{resolve:...}}intoStackNamemakes the recorded producer name a resolved secret exactly the way the export key can be. No value ever leaked -- both identifiers were masked at every site throughout -- so what shipped wrong was the RATIONALE, and the same wrong sentence sat on the refusal builder's twin comment, where it had been since issue #2133. A wrong rationale is worse here than none: "never a resolved value" reads as licence to drop the mask.producer.regionIS the identifier that cannot carry one, and the comment now says why rather than asserting it --Fn::GetStackOutputgates its region throughisClientSafeRegion(/^[a-z0-9][a-z0-9-]{0,30}$/, which no{{resolve:...}}token satisfies) and throws rather than falling back, whileFn::ImportValuereads it from the same state-bucket listing. A dangling{@link}introduced by the same round is fixed too: the symbol isplaintextProducerCrossStackReadError, notcrossStackProducerPlaintextError. The mask that was being reasoned about now has a TEST, which it did not before -- measured, replacingloggedStackwithproducer.stackleft every test intests/unit/cli/commands/green, so the fence nobody could remove by accident was in fact removable in silence. Two cases pin it: anFn::GetStackOutputwhoseStackNameis itself a{{resolve:...}}reference, declining through theproducerTemplatesmiss, and the REFUSAL twin. The second uses a THREE-CHARACTER stack name and that is the whole point of it rather than a detail -- a refusal passes throughmaskSecretsInErroratscrubStack's boundary on its way out, so with a 28-character name removing the builder's own mask left the suite green;allRecordedSecretsfilters toMIN_NEEDLE_LENGTH(4) while the whole-value arm ofmaskSecretsInTextmatches at ANY length, which is exactly the asymmetry the comment beside the mask asserts, and the short needle is what makes that assertion falsifiable rather than decorative. The HEALTHY declining arm's export-name mask is fenced on the same pass (a decline that reports good news is the one whose masking nobody re-checks), andexportAliasCollisionScrubWarningnow masks the OWNING output key as well as the exported name: both reach it from the same place -- the collision fires because the export name matched a DECLARED output name -- so the reachable shape puts the plaintext on whichever of the two does the exporting, and masking one while printing its neighbour raw is the mask-one-argument-leave-its-neighbour shape #2176 found in the providers, one line apart instead of two files. The--acceptsummary's count also gained an arithmetic fence: replacingacceptedResourceCount++with= 1survived all 268 cases across the five drift suites, since only zero-vs-nonzero was pinned, so a two-accepting-resource case and a MIXED case (one resource accepts a public path, its neighbour drifts only at the refused secret path) now pin the number itself. Every one of these was mutation-probed and reds exactly its own case. The two pre-existing sites that named the producer raw are masked on the same pass, and theWHERE THE READ ISstring is hoisted above thetryso all nine sites share one spelling instead of four re-spelled copies. Pinned by anFn::ImportValuewhose export name resolves out of a secret, over two arms; mutation-probed by removing the mask at each site in turn, each reddening exactly its own case. On thedriftside: the--acceptsummary counted drifted OUTCOMES rather than accepted changes, so a resource whose every change hitacceptRefusalReasonstill reportedaccepted drift on N resource(s)while the per-change warnings just above said the opposite -- it now counts resources that actually RECORDED something and says0 resource(s) acceptedexplicitly, since the write still happens for the positioned re-redaction. The count is taken AFTER the not-recorded check, not ataccepted.length: there is a SECOND refusal site one step further along -- the positioned re-redaction winning over an accepted value at a public{{resolve:...}}reference, which the comment beside it calls a real hole rather than a hypothetical one -- and counting before it reproduced the same summary-contradicts-the-warnings-above shape one site later. The summary also no longer asserts that "every drifted change was refused above", which the code does not establish (an outcome whoselogicalIdis absent fromstate.resourcesalso contributes zero, silently); it says no drifted change was recorded, which is exactly what the count measures;printAcceptPlanannouncedupdate cdkd state for <stack>over a body that could be nothing butSKIPPEDlines, and now saysno accepted values will be written ... (the run still writes the positioned re-redaction)in that case while still listing each resource and its refusal reason. Deliberately NOTNOTHING will be written, which the first cut said and which a--dry-runreader takes to meanstate.jsonis untouched -- the real run over the same input takes the lock, bumpslastModified, rotates the ETag and can rewrite the stored bag. Both messages also have a real-AWS arm now:tests/integration/secrets-dynamic-ref/already drove--acceptinto the all-refused state -- one drifted resource whose single changeacceptRefusalReasondeclines -- but asserted nothing about either string, so a green run over it proved only that nothing else had broken. It now pins that premise first (exactly one drifted resource carrying exactly one change, since an arm whose premise silently fails reports a reassuring zero), then asserts the plan saysno accepted values will be writtenand names the positioned re-redaction without printing the bareupdate cdkd state forheader, and that the summary says0 resource(s) acceptedbeneath aState updatedline proving the write still happened. Each half was mutation-probed on its own: reverting only the plan wording reds the plan assertion, reverting only the summary count reds the summary assertion. Thescrubdebug lines are NOT covered there -- reaching them needs--verboseplus a cross-stack read this fixture does not have, so they stay on the unit suite and belong intests/integration/cross-stack-secret-import/if they ever get a live arm. The plan message and the run message are pinned together by one test over one input, because the two are written thousands of lines apart and are the only two statements cdkd makes about what an all-refused--acceptdoes; the revert plan's two mirroredif (cannotMaskKeys ...)/if (!cannotMaskKeys ...)blocks became theif / else ifits sibling twenty lines below already used; and four shipped comments that described review-round history, or over-claimed that an array-nested leaf "reached neither pass" / "cannot be positioned" (keyed descent has reached an ECSContainerDefinitions[].Environment[]since #1944), now describe the code. Three test anchors were tightened:toContain('withheld')matched both withhold notes and is now pinned to the tag block's uniqueAWS-authored tag(s) will be preserved; the--revert PLAN masks an AWS-authored keycase now proves the unbaselined block printed instead of asserting onenot.toContain; and the{{resolve:...}}-shaped-substring case pins that the resolve actually happened -- with BOTH a call count and the absence of thecould not resolvefailure wording, because the call count alone does not catch a mock that is called and rejects. The review round tightened one more: the--revert PLAN masks an AWS-authored keycase asserted a baretoContain(SECRET_MASK), which the drift line above it satisfies regardless (Environment.Variables.SECRET_PASSWORD: *** -> ***), so it pinned nothing -- measured, with the printer's mask removed and the secret leaking into the plan, that assertion stays GREEN. It is now the path-shapedEnvironment.Variables.***, which only the unbaselined-key list can emit, and that reds. This entry also closes issue #1958's last two items. Item 9:exportAliasCollisionScrubWarning's masking comment no longer reads as a guarantee. What bounds the printed string is the COLLISION TEST upstream --scrubStackwarns only for a name matching a DECLARED output name, andcollectDeclaredOutputNamesisObject.keys(template.Outputs)-- so the mask is a belt, not the hazard it was justified by; it is REACHABLE, but only through a template that NAMES an output with the secret plaintext, which the suite's end-to-end case builds. The stale "BEST-EFFORT resolved intrinsic" claim the issue quotes turned out to live in that test's comment rather than in the source, and is corrected there. A CONTROL case was added for the polarity nothing pinned -- that the belt stays OFF a name carrying no secret -- since the three assertions that call the builder as their own expected value are tautological with respect to masking; measured, a builder returning the bare mask for every name is green across the whole file except that case. Item 10 (cdkd state showprinting output bag KEYS unstripped) was already fixed and fenced by earlier work, verified here by mutation: droppingstripControlCharsfrom the Outputs key redsstate-show.test.ts'sSTRIPS control characters from an Outputs row, key AND value. Note #2163's underlying real-AWS divergence is NOT fixed here and the issue stays open: the shape it describes -- anFn::Ifexport whose selected branch carries the secret, imported through anFn::Join-- refuses correctly against the templatesaws-cdk-libactually synthesizes fortests/integration/cross-stack-secret-import/and a deploy-shaped v9 producer record, which the new tests pin; and thefrom indexlines the issue reads its evidence from cannot come fromcdkd scrub, which supplies noexportIndexby design.The pre-stringify secret walk is now a mechanical check, and eighteen raw sites were closed (issue #2178) --
scripts/check-provider-secret-mask.tsplustests/unit/scripts/provider-secret-mask.test.ts, theaudit:provider-secret-mask:checktask invite.config.ts, itsci.ymlstep, and the masker threading inappsync-provider.ts,budgets-budget-provider.ts,cloudwatch-anomaly-detector-provider.ts,custom-resource-provider.ts,kinesis-provider.ts,lambda-function-provider.tsandsns-topic-provider.ts..claude/rules/provider-masking.mdhas required since issue #1932 that a provider interpolating aproperties-derived value into a message mask it, and since issue #2176 that the walk run BEFOREJSON.stringify-- masking the finished message cannot recover the value, sinceJSON.stringifyescapes"/\/ newlines so the secret no longer occurs in the line, and a message is always longer than the value inside it so onlymaskSecretsInText's >= 4-character substring arm is reachable. The rule was prose and was violated anyway inside files already hardened for it, so the remedy is a check rather than another sentence. The naive syntactic rule does not work: it false-positives onasg-provider.tsandcloudfront-distribution-provider.ts, which mask UPSTREAM of the stringify. The critic is therefore dataflow-aware, deriving the masker set per file (aliasedmaskDeepimport,SecretMasker/MaskerFnannotations,maskerOrIdentity,context?.maskSecrets) and growing it to a fixpoint through local wrappers and through helper parameters every call site threads. An IDENTITY masker is REFUSED:maskerOrIdentity(undefined)masks nothing, so accepting it let a raw site be silenced at exit 0 while COUNTING toward the masked floor -- issue #2007 records why a masker fencing nothing is worse than none, since its presence stops the next author looking. The refusal is scoped to a BINDING (a parameter's identity DEFAULT is the contract's back-compatible answer and stays legitimate) and folds??/?:only when every arm is identity, so the contract's owncontext?.maskSecrets ?? ((t) => t)is still a mask. It is enforced in BOTH positions a masker occupies -- the derivation root AND the shared walk's masker ARGUMENT (maskDeep(value, M)) -- because the root refusal alone is inert where the callee is itself a root, which review caught by injecting the named-constant spelling and watching sites go 41/37 to 42/38 at exit 0 with the masker-name count unmoved. The argument position is an ALLOW-LIST rather than a deny-list of no-op spellings: review defeated two successive deny-lists (a hand-rolledfunction, then a cast and an object property), so the argument now accepts only what resolves to the derived capability and refuses everything else. The fence's honest strength is therefore "the value reached something DECLARED to be the project's masker": a no-op annotatedMaskerFn, or a property literally namedmaskSecrets, is still believed, which is recorded as a known bound and pinned by self-probes rather than left to be discovered. The mask module's specifier is RESOLVED against the importing file rather than suffix-matched, so a./my-masked-retry-logger.jssibling cannot lend itsmaskDeepthe shared walk's provenance. Measured population, by a paren-matching AST walk rather than a line regex: 41 interpolated sites across 18 files, 19 masked before this change, 37 after, plus 4 exempt (3 import-path sites whereImportInputcarries no masker by contract, and 1 delete-path site recorded against issue #2007, which owns that root cause). Nine of those 37 were EXEMPT in an earlier revision of this change, held open only because issue #2177's DynamoDB and S3 lanes owneddynamodb-table-provider.ts,dynamodb-globaltable-provider.tsands3-bucket-provider.ts; both lanes merged (PRs #2248 / #2251), so those sites were FIXED at the source and their rows DROPPED rather than left standing behind a green check -- an exemption that outlives its reason being exactly the rot the verdict check above exists to catch. The delete-path entry iscustom-resource-provider.ts's payload refusal, SPLIT into a masked create / update arm and an unmasked DELETE arm so the pathDeleteContextgives no masker for is counted and re-audited rather than hidden behind an identity default. Every exemption is keyed by expression rather than by line and re-audited on each run against its site's current VERDICT, so it fails the moment its site disappears, becomes MASKED upstream with the same expression text, or gains a same-spelled sibling -- the middle arm being the retirement path the issue #2177 entries are actually waiting on. Known bound (1) is fenced rather than stated: zero string-CONCAT sites is measured every run and anything above zero fails. Its own two failure modes are defended separately: five population floors plus a hard parse-diagnostic failure against collapse toward zero, and 50 self-probes with known verdicts including raw ones against collapse toward green -- one accept probe and one refuse probe per accept arm, since an accept probe alone dies only when the arm is deleted and a refuse probe alone only when it degrades toreturn true. The collapse-toward-green channel was ITSELF unfenced until review: the entrypoint didfailures.push(...runSelfProbes())and nothing SPAWNED the binary to check it, so deleting that one line left the CLI at exit 0 with a byte-identical success line while every unit test stayed green (the suite called the EXPORT). Measured, then closed with a seam fenced in both directions:runSelfProbesnow reports how many probes it EVALUATED (counted inside the loop, so a constant cannot satisfy it),maintakes an injectable runner so a probe FAILURE can be proven to reach the exit code, aMIN_SELF_PROBESfloor refuses a run that evaluated almost none, and the count rides--jsonasselfProbesRunfor a SPAWNED assertion. One independent guard did already cover part of it and is recorded rather than claimed away:auditExemptionsreads each site's VERDICT, so anisMasked -> truecollapse makes all four exempt sites report "is now MASKED" and fails the run even with the self-probe call deleted -- measured, but it covers only that one collapse shape. The seven providers listed above gained the masker on the paths that quote a resolved value back at the user;kinesis,budgets,cloudwatch-anomaly-detector,custom-resource,appsyncandlambda-functionalso gained theCreateContext/UpdateContextparameter the contract threads. No behavior change for a caller that supplies no masker -- absent still means unmasked, the back-compatible default.A name held during an asynchronous delete is now retried on the ORDINARY create path, and the Step Functions spelling is recognised at all (issue #2116) --
src/deployment/retryable-errors.ts, plustests/unit/deployment/retryable-errors.test.ts, a re-pointed discriminator intests/unit/deployment/deploy-engine-named-replacement-collision.test.ts, and a live arm intests/integration/custom-resource-provider/. PreviouslyisNameCooldownErrormatched two spellings, both SQS (QueueDeletedRecently,wait 60 seconds), and only the delete-then-re-create sites consulted it. Step Functions holds a deleting state machine's NAME until the delete completes -- measured at ~23s on an idle machine -- and answers a same-nameCreateStateMachinewithStateMachineDeleting: State Machine is being deleted, which matched nothing: acdkd destroyfollowed by a promptcdkd deployof any stack carrying acustom_resources.Providerwaiter state machine failed hard and rolled back (26 resources created and torn down again on the run that filed the issue) where CloudFormation converges. Two changes. (1) The spellings moved into an exportedNAME_COOLDOWN_ERROR_MESSAGE_PATTERNSlist and gained both Step Functions forms plus S3'sconflicting conditional operation, which had been retryable on an ordinary create but invisible to the re-create sites -- five spellings, one source of truth. (2) That list is now composed intoRETRYABLE_ERROR_MESSAGE_PATTERNS, so a name cooldown is retryable on the ORDINARY create path too, which is the reachable case: a fresh deploy process has no idea a prior run deleted anything. (3) The class gets its own BACKOFF GRID on the default schedule (src/deployment/retry.ts: 2s/4s/8s then 10s, 64s total across the generic 8 retries), because inheriting the generic 47s would NOT have fixed the reported case -- the longest window in this class names its own duration, SQS's sentence being "You must wait 60 seconds", so a 47s budget does not converge, it just fails 47s later. The numbers are precedent rather than a fresh guess: they are the delete-then-re-create sites' existing budget, chosen for this same window, so both paths now ride it identically and whether a cooldown is survivable no longer depends on which call site met it. An exhausted cooldown wait also now SAYS so in the give-up summary instead of rethrowing the raw AWS sentence -- issue #2018's lesson applied to this class, with a per-class exhaustion conjunct so a single 2s cooldown retry inside a mixed sequence cannot report "the full name-cooldown budget". The three sites that nest a default-schedulewithRetryinside their own outer loop compound with it: total sleep on a cooldown there goes ~487s -> ~640s (8.1 -> 10.7 min), still inside the 30-minute per-resource deadline, and the number is recorded in the JSDoc rather than left to be re-derived.dynamodb-index-busy-delete.tspasses all four schedule knobs, so its own deadline arithmetic is untouched. A window longer than 64s still fails, with the same message as before. Before this, the two consumers held DIFFERENT spellings of the identical SQS error, so whether the same AWS condition was survivable depended on which spelling the SDK surfaced. A sibling sweep of every provider that deletes asynchronously deliberately EXCLUDED ELBv2'sDuplicateLoadBalancerNameand DynamoDB's create-side refusal: AWS raises both for a resource that merely EXISTS, so recognising them would turn a terminal collision into a wasted retry budget ending in the same failure. Both are pinned as negative rows in the classifier table.tests/integration/custom-resource-provider/verify.shphase 6 previously polled the state machine to gone before redeploying, as a documented workaround for this issue; that wait is deleted and its absence is the live arm. The phase splits its two questions: a FATAL fence (exit 0 plusDeployment completed successfully, which a regression cannot pass because the create would fail and roll back) and a non-fatal COVERAGE verdict driven by evidence from the run itself -- the redeploy runs--verboseand the phase greps for a retry line quoting the AWS cooldown message, printingOK: COVEREDorINCONCLUSIVE. Exit 0 alone is a confluence point: a fully-closed window produces it too. Taggedordinary-create-name-cooldownin the scenario taxonomy, distinct from the existingrollback-reverse-replacement-name-cooldownwhich covers the sites where cdkd itself just deleted the name holder.A
--remove-protectiondestroy no longer strips a DynamoDB table's deletion guard when the retry sequence outlives the reuse window (issue #2211), and no longer claims a table is LIVE when it is gone (issue #2224) --src/provisioning/providers/dynamodb-delete-budget.ts,dynamodb-table-provider.ts,dynamodb-globaltable-provider.ts, plus new cases intests/unit/provisioning/providers/dynamodb-remove-protection-compensate.test.ts. #1978 added a compensating re-enable ofDeletionProtectionEnabledwhen a--remove-protectiondelete fails terminally, latched per table inProtectionFlipRegistry. That registry measured an entry's age from FIRST acquisition, so a--resource-timeoutovershoot past the 30-minuteDELETE_BUDGET_REUSE_WINDOW_MSlet a long retry sequence age out its OWN record mid-flight:acquiredropped it, handed back a freshflippedOffByThisRun: false, and the next attempt's pre-flipDescribeTablesaw the guard already off -- because cdkd itself had turned it off on attempt 1 -- so the latch stayed false and nothing compensated, reproducing #1978's residue (a live table with its deletion guard silently stripped) through the mechanism added to prevent it. The window is now SLIDING: every reuse restarts the stopwatch, so what is bounded is IDLE time since last use rather than total lifetime. The window itself is kept deliberately -- dropping it would let a much later destroy of the same table re-enable a guard it never touched, the inverse hazard #1978's registry JSDoc names -- and bumping the constant was rejected as bounding nothing, since the wall clock is fixed while the retry sequence is not. Separately, on a region-mismatch raceassertRegionMatchthrows insidedelete()'s ResourceNotFound branch BEFORE that branch reaches itsprotectionFlips.release, so the record is still latched and the compensation runs against a table that is already gone; itsUpdateTablethen got its ownResourceNotFoundExceptionand the catch logged at ERROR that "that table is LIVE with its deletion protection still off", which is false and pointed the operator at a table that does not exist. That one case now reports atwarnwith the claim dropped rather than aterrorwith it asserted, and deliberately NOT atdebug: DynamoDB returnsResourceNotFoundExceptionfromUpdateTableboth for a table that is gone AND for one whose status is merely notACTIVE(its own SDK model says so), and the second is reachable -- aGlobalTablewhosewaitForReplicaGonetimes out is still live, still unprotected, anddeleteAcceptedis still false, so silencing it at debug would hide exactly the case the line exists to report. The line now names the ambiguity and gives adescribe-tablecheck before the restore command. Every other compensation failure keeps the ERROR line. Both fixes are mutation-probed in both directions, including a control proving a non-ResourceNotFound compensation failure still logs at error, and a case proving a REUSED registry record still ages out -- without which a window made permanent-on-reuse passed the whole suite, which is the forbidden "drop the window" fix arriving by the back door. The sliding window's bound is stated rather than overclaimed:acquireruns once per attempt, so what it measures is the previous attempt's own duration plus the loop backoff, and the window is sized like one attempt's worst case -- it removes the accumulation case go-to-k/cdkd#2211 reported, not every instance of the class.cdkd deployno longer adopts an already-owned S3 bucket that lives in another region (issue #2227) --src/provisioning/providers/s3-bucket-provider.ts, plus a newtests/unit/provisioning/s3-bucket-provider-already-owned-region.test.ts(16 cases) and an updated primer ins3-bucket-provider-partial-create-cleanup.test.ts, plus a Phase 0 / Phase 0b arm pair intests/integration/s3-lifecycle/verify.sh.CreateBucketanswersBucketAlreadyOwnedByYouon OWNERSHIP, which is account-global, while a bucket is regional -- so the error fires just as readily for a bucket of yours somewhere else. The provider short-circuited it to an idempotent-create success unconditionally, so a deploy whose bucket name collided with one of the account's buckets in a DIFFERENT region reported success, applied this stack's whole bucket configuration (versioning, encryption, policy, notifications, lifecycle) to that foreign-region bucket, and recorded a physical id denoting no bucket in its own region. This is NOT an explicit-BucketNameedge case:generateResourceNameproduces{stackName}-{logicalId}with no region or account, so one stack deployed to two regions collides by construction. It also closes a data-loss path on property-driven replacement, where create-first "succeeded" against the foreign bucket and the destroy step then deleted the real in-region one. The guard now takes the bucket's region from thex-amz-bucket-regionheader on the 409 ITSELF -- no extra API call, no extra IAM -- falling back toGetBucketLocation, folding an empty/nullLocationConstrainttous-east-1and the legacyEUtoeu-west-1, comparing throughcanonicalizeRegion, and refusing with amarkNonRetryableerror naming both regions and the two remedies. Deliberately NOTHeadBucket: it 301s cross-region and SDK v3 turns the empty-body HEAD response into a syntheticUnknownError(src/utils/aws-region-resolver.tsalready recorded this); the first cut used it, its seven mutation-probed unit tests passed because they mocked the AWS CLI's redirect-following shape, and only the real-AWS integ arm caught that the guard could not fire. Deliberately not that module'sresolveBucketRegioneither -- it never throws and returns a fallback region, which would turn this fail-closed guard fail-open. The refusal is also worded to avoid the literaldoes not exist, a member ofOTHER_TRANSIENT_ERROR_MESSAGE_PATTERNS, which would otherwise have it retried for the full budget. Measured on real AWS: a bucket created inus-west-2answersBucketAlreadyOwnedByYouto aCreateBucketineu-west-1,us-east-1andap-northeast-1, always carryingx-amz-bucket-region: us-west-2. The same measurements FALSIFY the mid-delete mechanism the issue was filed for: aDeleteBucketfollowed immediately by aCreateBucketof the same name SUCCEEDS outright, so there is no window in which this error can mean "being deleted"; that correction is recorded on the issue. Sibling-site sweep (grep -rn "AlreadyExists\|AlreadyOwned" src/provisioning --include=*.ts-> 47 hits across 13 files):AWS::S3::Bucketis the only affected type, every other hit being either a regional namespace or a global namespace over a global resource, andCloudControlProviderhaving no adopt arm at all.src/assets/asset-storage.ts:548carries the same short-circuit and is tracked separately in issue #2240 -- an earlier revision of this entry called it structurally unreachable because the default name embeds the region, which is wrong:cdkd bootstrap --asset-bucket <name>supplies a region-free name. A separate pre-existing hazard in the same method, whereus-east-1's legacyCreateBucket200 OK marks an ADOPTED bucket as created and so lets the issue #376 partial-create cleanup delete a pre-existing user bucket, is filed as issue #2241; the guard here does not reach it, because the legacy 200 never enters theBucketAlreadyOwnedByYoucatch.cdkd drift --accept/--revertno longer report "No drift detected" over a stack they never compared (issue #2208) --src/cli/commands/drift.tsanddocs/cli-reference.md, plus new cases intests/unit/cli/drift-per-resource-failure.test.tsandtests/unit/cli/drift-cross-region-secret.test.ts.anyIncompletewas computed for every run but read only on the detection-only path, so a remediation run whose every read threw -- an IAM denial, a throttle -- printedNo drift detected — nothing to accept.and exited0. Nothing drifted only because nothing was read: the false reassurance #2135 madenotCompareda variant to prevent, surviving on the one path #2135 did not touch, and worse on a remediation command than on a detection one because the user ran it to CHANGE something. The fix is the MESSAGE, deliberately, and the exit code stays0: the remediation modes' exit codes are a documented contract that #2108 scoped its2away from on purpose ("changing them would alter what a remediation run means"), the population is anyone whose CI runs--accepton a stack that can hit a throttle, and--accept/--revertalready correctly leave an uncompared resource alone (both iterate the drifted outcomes only), so the state and AWS were never wrong -- only the sentence was. That path now printsComparison INCOMPLETE — nothing to accept, and that is NOT a clean bill of health: N of M resource(s) could not be compared (...), so cdkd does not know whether they drifted., followed by a pointer at the detection-only run -- the mode whose exit code DOES report it, as2. The TRIGGER and the COUNT are deliberately different populations, and collapsing them was the defect review round 1 found in the first cut. The trigger isoutcomeExitSignal'sincomplete, which is narrow on purpose: a stack whose only uncompared resource holds a{{resolve:ssm-secure:...}}token must not shout on every run about a comparison no action of the user's can clear -- the CI-forever hazard that cause is kept out of the exit code on. The COUNT, once the line fires, covers EVERY uncompared resource, each named by its OWN reason:Mis the total outcome count andNis everything not compared, sounsupportedandskippedland inNwith their own phrase rather than silently inflating only the denominator. That makesNWIDER than the report'sNOT fully comparedheading (which counts the reference / read population and reportsdrift unknownseparately) and matches the question #2154's glyph asks, "was everything actually compared". Counting only the clearable reasons printed1 of 3on a stack the report a few lines above called2 resource(s) NOT fully compared-- two lines disagreeing about one run, the newer one quieter, inside the fix for quiet reassurance. The per-reason phrases live in an exhaustiveRecord<NotComparedCause | 'unsupported' | 'skipped', { kind; phrase }>(the shapenotComparedReasonalready uses), so a cause added later is a COMPILE ERROR rather than anelsebranch describing it as a dynamic-reference problem -- which matters here becauseoutcomeExitSignalroutes every new cause to the incomplete side, i.e. straight into this message. The UNCERTAINTY CLAUSE is per-cause rather than one blanket tail, which is thekindhalf of that record. The line used to end..., so cdkd does not know whether they driftedfor everything inN, which is true of a throttled read and FALSE of aCustom::*resource: issue #323's position there is that drift is not APPLICABLE, not that cdkd is unsure -- so a stack with one throttle and fiveCustom::S3AutoDeleteObjectsclaimed uncertainty about all six.readFailed/refused/unresolvedTokennow report undercdkd does not know whether these drifted, andunsupported/skippedunderNot drift-checked by cdkd at all, which is a coverage limit rather than uncertainty, with no uncertainty claim attached. #323's SILENCE is deliberately not honoured on this path, and the JSDoc says so rather than leaving the next reader to read the new line as a regression of it: #323 governs the detection REPORT, a per-resource listing where aCustom::*entry every run is noise, while this is a single sentence explaining why a command asked to CHANGE something changed nothing -- and a sentence of that kind has to account for the whole stack or it is quietly reassuring again. Nothing about the detection report moves.docs/cli-reference.md's exit-0row is split in two, because the old single row claimed "cdkd left no comparison incomplete" for BOTH modes -- true of detection, false of remediation -- and the new--accept/--revertrow says so, points CI gates at the detection-only run or at--json'snotCompared[].cause, and records why the exit code was not changed. Fifteen mutation probes, one per BRANCH rather than one per pair -- round 2 caught exactly that shortcut here, where a single row reading "droppingunsupported/skippedfrom the tally" had only ever exercised theunsupportedhalf while BOTHskippedbranches sat green at 66/66, an unearned claim in the changelog of the PR that exists to stop unearned claims. Each row is now its own measurement: removing the branch reds 8, dropping the pointer line 2, blanking thereadFailedphrase 5, blanking the refusal phrase 2 (both in the cross-region suite, the only harness that can produce arefusedoutcome -- every fixture in the per-resource suite producesreadFailed, so that arm would otherwise be unfenced), widening the TRIGGER to the wholenotComparedroll-up 1, triggering unconditionally 2, hoisting the branch above the drift check 1 (drift still outranks it, so a stack that both drifted and threw is still accepted), counting from the incomplete subset instead of the uncompared population 1, no-op'ing theunsupportedtally bump 1, blanking theunsupportedphrase 1, no-op'ing theskippedtally bump 1, blanking theskippedphrase 1, movingskipped'skindtounknown1, giving the by-design lead the uncertainty wording 1, mislabelling the permanent token cause with the refusal wording 1, miscounting the denominator 2, and hard-coding the mode word 2. Theskippedarm is fenced by aCustom::S3AutoDeleteObjectsmember in the five-fates mixed fixture -- via that CDK helper it is the COMMONEST member ofNin real stacks, and it was the one nothing checked. The exhaustiveness fence is TYPE-level rather than probe-level, and that distinction is load-bearing: adding a fourthNotComparedCauseleavesvp test runreportingType Errors no errorswith every test passing, and onlyvp run typecheckreds it (measured:TS2741: Property 'probeCause' is missing ... in type 'Record<UncomparedReason, { kind; phrase }>'). A green test run does not cover it.cdkd gcnow collects abandoned custom-resource response placeholders from the state bucket (issue #2052) --src/state/state-prefix.ts,src/cli/commands/state-file-keys.ts,src/state/s3-state-backend.ts(newlistRawObjects),src/cli/commands/gc.ts,src/provisioning/providers/custom-resource-provider.ts,docs/cli-reference.md, plustests/unit/cli/gc-custom-resource-responses.test.tsandtests/unit/state/custom-resource-response-prefix-sync.test.ts.CustomResourceProviderPUTs an empty object atcustom-resource-responses/{requestId}.jsonin the STATE bucket before each invocation so the handler has a pre-signed URL to write to.cdkd gcscanned the ASSET bucket and contained zero references to that key family, so nothing swept the abandoned ones: an interrupted deploy between the PUT and any cleanup, a throw on a path that reaches no cleanup call, and -- the only shape with CONTENT -- a LATE handler PUT landing after cdkd stopped polling, which writes a real CloudFormationDatapayload to a key nothing collects, making it a data-retention question rather than only a storage leak. The open scope question the issue carried is settled the way its own Direction section named as durable: gc learns the prefix, rather than the provider taking a best-effort sweep of its own stack's stale keys, because gc already holds the whole-bucket view and the two guards this needs. Staleness adds no clock of its own -- the LOCK guard refuses the entire run while any stack holds a lock, and every deploy that can write one of these keys holds one for its duration, so a concurrent run's key is unreachable regardless of age;--older-than(default 30d) then applies to the object's ownLastModified, inclusive-KEEP at the boundary. The lock guard moved AHEAD of the bootstrap-marker check for this: the placeholders live in the state bucket, which exists independently of whether a region opted in to cdkd ASSET storage, so the "not opted in" early return would have made the sweep unreachable for exactly the accounts that have placeholders and no asset marker -- and the guard now covering that case is the safe direction. The prefix is ONE binding shared by producer and collector, homed insrc/state/state-prefix.tsfor the layering reason that file already records and re-exported fromstate-file-keys.ts, with a fence that greps the source for a re-spelled literal and carries a floor so "found nothing" cannot pass as "everything matches" -- a sweeper pointed at the wrong prefix finds nothing, which is indistinguishable from a clean bucket. Every positive test asserts the DELETE naming the key rather than an exit code, and each of the three halves is mutation-probed to a distinct failure set: removing the delete reds 3 cases, disabling the age guard reds a different 3, and restoring the pre-fix marker return reds exactly 1.cdkd destroy --all/cdkd state destroy --allnow stop on a Ctrl-C that lands in a window the per-stack runner does not span (issue #2117) --src/utils/interrupt-signals.ts(newwatchCommandInterrupt),src/cli/commands/destroy.ts,src/cli/commands/state.ts, plustests/unit/utils/command-interrupt-watch.test.ts,tests/unit/cli/destroy-command-sigint.test.tsandtests/unit/cli/state-destroy-command-sigint.test.ts. Previously neither destroy command registered a SIGINT handler of its own --deploy.tshas had one since issue #1348 -- so the ONLY channel carrying "the user interrupted" fromrunDestroyForStackback to the multi-stack loop wasDestroyRunnerResult.interrupted, a boolean assigned once inside the runner'stryafter its level loop. The runner's outerfinallyre-syncs it (result.interrupted ||= draining) and that line is marked TACTICAL in its own source: it narrows the window rather than closing it. Two gaps survived and neither is reachable from inside the runner at all -- the runner removes its SIGINT listener BEFORE that re-sync and itsreturn, so a signal there is seen by nobody; and everything the loop itself does between two stacks (RUN_FINISHED,eventRecorder.finalize,purgeEventsAfterDestroy) runs with no runner handler armed. In both,--allwent on to delete the NEXT stack after the user pressed Ctrl-C. A command-scoped handler closes the class rather than an instance, because it is armed in every window instead of the ones the runner happens to span. The two flags are both kept and answer different questions:result.interruptedstays the PER-STACK outcome (it decides that stack's state preservation, its summary line and its--purge-eventsskip), while the new one is the COMMAND-level "stop" the loop and the exit code read -- live, at each decision point, since assigning it to a local would re-create the sampled-once channel this issue is about. Escalation is deliberately split: while a per-stack destroy is in flight the watch stays silent and lets the runner's handler own the second-Ctrl-C force-quit, because only that one can release the stack lock best-effort and print the region-qualifiedcdkd force-unlockrecovery command; outside that bracket no runner handler exists and no stack lock is held, so the watch takes the force-quit itself. A guard also runs immediately before each dispatch, so a signal arriving during a stack's own preparation (state read, per-stack prompt, event-recorder start) stops the run before any delete is issued rather than one stack later. The provider-side interrupt watch's last-listener force-quit is unaffected in its remaining population --import/export/scrub/orphan/drift/state refresh-observedstill register no handler -- but it no longer fires during a destroy, which is the improvement: the old behaviour there exited 130 with the lock stranded and without stopping the loop gracefully. Each half is mutation-probed: reverting the loop's live read reds the two between-stacks cases while the runner-reported-interrupt control stays green, and removing only the pre-dispatch guard reds exactly the before-first-stack case.$util.parseJsonno longer echoes a prefix of the input it failed to parse -- in cdkd's MIRROR of the VTL engine (issue #2203) --src/local/vtl-engine.ts, plus tests. The site carried the shape fixed in issue #2189 -- acatchinterpolating the parser's own message, into which V8 embeds a prefix of the PARSED INPUT -- over a population #2189 scoped out. The leak IS closed forcdkd local start-api-- but not by the edit to this file.src/local/http-server.tsis a shim re-exportingstartApiServerfromcdk-local/internal, so the command runs cdk-local's implementation;evaluateVtlhere is reached only from cdkd's ownrest-v1-integrations.ts, whose sole live import iswarnSsrfRiskyUri. The user-facing fix is cdk-local'ssrc/local/vtl-engine.ts(cdk-local PR #556), shipped in cdk-local 0.147.6, and this change bumps cdkd'scdk-localdependency to it -- so the closure lands for cdkd users with this release, and the edit to cdkd's own copy only keeps the fork from drifting back to the leaking shape. Demonstrated rather than asserted: against the built CLI on 2026-08-26,cdkd local start-apiserving a MOCK REST v1 request template that calls$util.parseJsonon a header holdinghunter2-my-passwordanswered502with"reason":"$util.parseJson: the argument is not valid JSON (SyntaxError; argument length 19). The parser detail is withheld..."-- nohunter2, noUnexpected token, in the response body or the server log. The same request before the bump returned the whole password. The premise was proved in its own step first (a VALID JSON header answered200), so the negative is not a confluence point. That cdkd's fork is unreachable at all is a separate root cause, tracked as issue #2228. The distinction is narrower than "cdkd'ssrc/local/is dead code", and the difference matters:ecs-task-runner.tsis a REAL implementation imported live bysrc/cli/commands/local-run-task.ts, so #2189's own fix inecs-secrets-resolver.tsIS on the shipped path. Only the start-api / HTTP dispatch chain is shimmed. In the live copy the argument of$util.parseJson(...)is routinely a value the caller sent --$input.body, or a header via$input.params(...)-- andvtlFailurecopies the message into the 502 RESPONSE BODY, so the prefix travels back over the wire rather than merely reaching a terminal; measured against cdk-local, a 9-character non-JSON header came back whole, because V8 appends...only past its ~10-character window and quotes a SHORT input in FULL. The message now carrieserr.nameplus the argument LENGTH and says outright that the parser detail is withheld, so a later contributor does not helpfully restore it. Reading that length exposed a latent hole the same review caught:coerceis typed=> stringbut RETURNEDundefined, becauseJSON.stringifyreturnsundefined(it does not throw) for a function, a symbol, or an object whosetoJSONyields undefined, and TypeScript hides it becauseJSON.stringify(unknown)selects theanyoverload. It is reachable from an ordinary template --$input.json/$input.path/$input.paramsare own-property FUNCTIONS on the objectbuildVtlInputreturns, so$util.parseJson($input.params)with the call parens forgotten threw aTypeErrorinstead of the diagnosis, and what escapedevaluateVtlwas then not theVtlEvaluationErrorcallers match on.?? ''closes it incoerceand insafeStringify, which carried the same hole. Tests: the two leak shapes (long input quoted as a prefix, short input quoted in full), a table over all four empty-coercion shapes asserting the throw is still aVtlEvaluationError, and the reachable template-level spelling. Both leak cases also assert the parser's PHRASING is absent, closing a mutation that strips only the quoted segment while still leaking the first character and the offset. A whole-input negative from the first cut was DELETED rather than kept: at that length it held with the fix reverted, and a vacuous assertion reads as protection that is not there. Count needles are anchored on their trailing), sinceargument length 4is a substring ofargument length 40, confirmed by a probe that multiplies the reported length by ten.The RIE streaming prelude parse echoes function OUTPUT, not protocol framing -- and only the
JSON.parsefailure is suppressed (issue #2203) --src/local/rie-client.ts, plus tests. The issue proposed leaving this site alone with a comment, on the premise that its prelude parse consumes cdkd's own wire protocol rather than application data. That premise is false, which is what flipped the issue's decision from its option 2 to option 3. The split above the parse scans the response for an 8-NUL run, and the#664block above it records that the commonest handler shape in the wild --streamifyResponseplussetContentType/write, never callingHttpResponseStream.from-- emits no framing at all, so what is scanned is raw function OUTPUT. An 8-NUL run inside binary output therefore matches by COINCIDENCE and handspreludeBytesa slice of application data. Reproduced on the pinned Node 24.15 with a tar stream, whose 512-byte member header NUL-pads everything after the 100-byte name field: the first run sits immediately after the file name, and the parser answersUnexpected token 'c', "customer-d"... is not valid JSON. Like the sibling entry above, this file is cdkd's MIRROR --invokeRieStreaminghas no caller in cdkd'ssrc/(issue #2228) -- so the shipped fix is cdk-local PR #556, released as cdk-local 0.147.6 and consumed by the dependency bump in this same change; this copy is kept in step so the fork cannot drift back. Only theJSON.parsefailure is suppressed, and getting that wrong was a real regression the review caught:parseStreamingPreludethrows four ways, and the other three (empty prelude,prelude is not a JSON object,statusCode must be a number (got <typeof>)) are input-INDEPENDENT. Suppressing them bought no privacy at all while telling a correctly-framed handler emitting{"foo":1}that its valid JSON is "not valid JSON", plus advice to callHttpResponseStream.fromwhich it had already called. The throw is now gated onerr instanceof SyntaxErrorand the other three stay verbatim, with three cases at theinvokeRieStreaminglevel fencing the gate -- they have to be at that level, because the suppression lives at the CALLER and the pre-existingparseStreamingPreludecases cannot see it. The message keeps the byte count before the separator, and leads with the MISREAD rather than blaming the handler, because the unframed shape is one the#664block deliberately SUPPORTS: framing is offered as a way to rule the misread out, not as the diagnosis. Two further review findings are folded in: "scans the WHOLE response" overstated the scan, which stops atSTREAM_PRELUDE_MAX_BYTES(1 MiB) and fails with its own error past that; and the reported size was unfenced between BYTES and UTF-16 code units, since both fixtures were pure ASCII -- four 3-byte characters (12 bytes, 4 code units) now separate them. Count needles are anchored on both sides, since26 bytes beforeis a substring of126 bytes before. Eleven mutation probes red, including the gate probe (wideninginstanceof SyntaxErrortoinstanceof Errorreds all three legibility cases) and the inflated-count probe. The remediation assertion needed anchoring to its whole clause: a baretoContain('HttpResponseStream.from')stayed GREEN with the hint deleted, because the diagnosis sentence above it names the same symbol.A nested stack's secret flow is redacted in BOTH directions — parameters IN and outputs OUT (issues #1903 / #2055) —
src/deployment/deploy-engine.ts,src/provisioning/providers/nested-stack-provider.ts,src/deployment/intrinsic-function-resolver.ts,src/cli/commands/diff-recursive.ts. Four GHSA-p5qg-v9gv-hc7w follow-ups at the same boundary — issues #2086 and #2087 were found by reviewing the first two and cannot be verified apart from them, so all four ship together. Parameters IN (#1903, the actual disclosure). cdkd's redaction rests on the resolver recordingplaintext -> {{resolve:...}} expressionand the deploy engine reading that at its state-save choke point. A nested stack broke the chain: the PARENT resolves the child'sParametersblock, so the child engine received already-resolved PLAINTEXT and its own template spells the consumption as{Ref: <ParamName>}— an intrinsic OBJECT, never a{{resolve:string — so the child'srecordedSecretValuescame out empty and the child'sstate.jsonpersisted the decrypted secret with no expression to redact back to. The #1904 / #1900 PATH-based pass structurally could not help, because it copies a source leaf that IS an expression string and here there is none. The fix is the opposite direction:NestedStackProviderhands the child engine a SEED map (DeployEngineOptions.inheritedSecrets) — the parent's own per-resource bag, which is exactly the secrets resolved while resolving thatAWS::CloudFormation::Stackrow'sParameters. The child engine seeds it into every freshrecordedSecretValues, so the ordinary VALUE-based redaction finds the plaintext wherever the parameter landed, including EMBEDDED in anFn::Sub-built connection string. The map reaches the provider through anAsyncLocalStoragein its own leaf modulesrc/deployment/resource-secrets-scope.ts(withCurrentResourceSecrets/getCurrentResourceSecrets) rather than a new field onCreateContext, deliberately:.claude/rules/providers.mdalready records whySecretMaskingContextcarries a masking FUNCTION and not the bag — a plaintext-keyed map on the shared context makes all ~130 providers a place[...secrets.keys()]can leak from — and exactly one provider needs the pairs. Scoped per RESOURCE, not per stack (#2087). The child engine does NOT pre-seed every resource's redaction map with the parent's bag, which is what the first cut did:redactSecretsForStatesubstring-matches at or aboveMIN_NEEDLE_LENGTH, so a child resource that never referenced the parameter but happened to spellmy-production-bucketwhile the secret wasproductionhadmy-{{resolve:...}}-bucketpersisted — a rewrite the desired side never mirrors (redactParametersForDiffrewrites only the PARAMETERS), so the stack acquired a perpetual UPDATE, or a perpetual REPLACEMENT on a create-only property. The pair is instead recorded at RESOLUTION time, byIntrinsicFunctionResolver.recordInheritedParameterSecrets, when a resource's own{Ref: <Param>}resolves to a value carrying the plaintext — whole-value at any length, or a substring at or above the same floor the redactor uses. That is exactly the set of resources that can carry the plaintext into their persisted state, and it reproduces the parent's own scoping, whereperResourceSecretsis keyed by logical id. The rollback executor binds the same store (#2086).src/deployment/rollback-executor.tsdrives the same providers on the recovery path and bound the seed nowhere, so a rollback that reverted a nested-stack row re-persisted the child's plaintext — a recovery path restoring the pre-fix behaviour is a hole, not a baseline. All four of its provider call sites are now wrapped: both branches ofupdateWithRollbackRetry(the choke point for all four rollback update arms) and the two reverse-replacement replay-CREATEs, each INSIDE the retry thunk so every attempt is scoped.resolveReplayPropshas already re-resolved the journal's{{resolve:...}}back to plaintext at every one of them, so the exact bag was in hand. Standalonecdkd rollbackis unaffected: it builds a destroy-mode context, sorequireDeployContextthrows before any child engine is built. The diff half had to land WITH it, not before.cdkd diff --recursive'sresolveChildStackParametersset neitherskipDynamicReferencesnorrecordedSecretValues, so it DECRYPTED the reference at plan time and printed the plaintext; it now sets the flag. Adding that flag ALONE would have been wrong — the deploy path persisting plaintext was what made the comparison self-consistent, so skipping only on the diff side compares expression-vs-plaintext and reports a spurious perpetual change every run. The same coupling shows up inside the child engine, one layer down: the child's own DIFF context now binds the REDACTED parameter bag (redactParametersForDiff), because once its state holds the expression a comparison that still resolves{Ref: Param}to plaintext reports an UPDATE on every deploy. The PROVISIONING and CONDITION-evaluation contexts deliberately keep the real values — that is what reaches AWS, and substituting an expression into anFn::Equalswould flip a condition. Outputs OUT (#2055). The mirror image, and a third reader of the redacted bag in the #1934 class:NestedStackProviderreads the CHILD's persisted outputs and surfaces each as the parent'sOutputs.<Name>attribute, so a parent property spelled{"Fn::GetAtt": ["Child", "Outputs.DbPassword"]}reached AWS as the literal{{resolve:secretsmanager:...}}token. Re-resolution reuses #1934'sreresolveCrossStackValue/resolverForProducerRegionrather than a fourth copy of the walk, and it lives at the READ site —IntrinsicFunctionResolver.resolveGetAtt's flat-key attribute hit — NOT inbuildOutputsAttributes. That seam choice is load-bearing rather than stylistic: re-resolving at attribute-BUILD time (the easy option, sincereadChildOutputsAsAttributesis already async) puts the plaintext into the parent's recordedattributes, and every CONSUMER resource reading it throughFn::GetAttthen has no entry in its OWNrecordedSecretValues, so the save choke point has nothing to redact back and the consumer'sstate.jsonpersists the decrypted secret — trading one bug for a wider one. At the read site the consumer's context is in hand, so the plaintext is recorded and both the parent's nested-stack row and every consumer's record stay redacted. The producer region is the CHILD's, read back from thearn:cdkd-local:<childRegion>:...physicalIdNestedStackProvidersynthesizes (a secret NAME is regional, so the consumer's region can answer with a different secret); the child's own state record cannot supply it, because its region is part of its state KEY and reading the record to learn the region is circular. Equal to the parent's region today, named apart so cross-region nested stacks inherit the right rule. UnderskipDynamicReferencesthe token is returned untouched, so the parent's diff keeps comparing expression-vs-expression. Tests:tests/unit/deployment/deploy-engine-nested-stack-inherited-secrets.test.ts(persist + embedded-secret + the DIFF-vs-CONDITION context split, with the un-seeded child kept as the discriminator),tests/unit/provisioning/nested-stack-provider-inherited-secrets.test.ts(forwarding on create AND update, absent / empty store, child-region ARN, and that the outputs attribute stays a token),tests/unit/deployment/nested-stack-output-secret-reresolve.test.ts(realAwsClientswith only the leaf SDK client faked, so "which region answered" is observable — two regions holding different values behind one reference),tests/unit/cli/diff-recursive-nested-stack-secret.test.ts(NO_CHANGE on a freshly-deployed tree with a secret fetch made a hard failure, a genuine-change regression guard, and the two arms where a redacted token must not be reasoned about as a VALUE — it is left uncoerced rather than becomingNaNunder aType: Numberchild parameter, and it disables the condition-pruning pass rather than pruning by a verdict deploy will not reach),tests/unit/deployment/intrinsic-resolver-inherited-parameter-secrets.test.ts(the REAL resolver: both recording arms, theMIN_NEEDLE_LENGTHfloor bound to the exported constant,Fn::Sub/Fn::Join/CommaDelimitedListreach, and the #2087 scope control that resolving a DIFFERENT parameter records nothing) andtests/unit/deployment/rollback-executor-nested-stack-secret-scope.test.ts(all four rollback binding sites, the single-shotdisableOuterRetrybranch separately from the retried one because that is the branchNestedStackProvideractually takes). FOUR MORE ESCAPES FROM THE STRING-KEYED MODEL, all closed at the same seam (review round 2). cdkd's secret model is keyed by plaintext STRING end to end —RecordedSecretValuesis a plaintext-keyed map, the recording scan walks strings and string array elements, andredactSecretsForStaterewrites string LEAVES — and two paths escaped it. (a)resolveParametersCOERCES by declaredType, so aType: Number/List<Number>child parameter fed a secret became a JS number before any of that ran: nothing was recorded, nothing was rewritten, and the child'sstate.jsonkept the DECRYPTED value verbatim withcdkd diff --recursivereporting a change forever. It is now REFUSED, naming the parameter (IntrinsicResolutionRefusalError, codeNESTED_STACK_SECRET_PARAMETER_TYPE), rather than patched around: recording on the pre-coercion string would additionally require the redactor to rewrite a NUMBER leaf into an expression string matched byString(n) === plaintext, which both under-covers ("007"round-trips to"7") and over-covers (a numeric secret like8080whole-value-matches every unrelated port, #2087's class on a path whereMIN_NEEDLE_LENGTHdoes not apply) — and a remedy that can silently under-cover is the wrong one for a disclosure path. CDK synthesizes every nested-stack cross-reference parameter asType: String, so no CDK app is affected;cdkd diffWARNS instead of throwing, since a plan must stay best-effort. (b) The child engine is the ONLY placecontext.parametersholds decrypted plaintext, and the resolver's two parameter debug lines print a parameter VALUE —Resolved Ref to parameter(which runs BEFORErecordInheritedParameterSecretsputs anything in the bag) andParameter <name>: using user-provided value.stringifyParameterForLogredacts only on the template author'sNoEcho, which a CDK-synthesized nested-stack parameter never carries, socdkd deploy --verboseprinted the secret.maskSecretsForLognow masks againstcontext.inheritedSecretsas well asrecordedSecretValues, which covers every log seam in the child resolver rather than the two that were found, andresolveParametersmasks its own three lines against the same bag. (c)crossStackSourceKeygained anFn::GetAtt <logicalId> <attributeName>arm, so the nested-stack-output read site above records a #2059 POSITION association instead of passingundefined: without a key its leaves fell to the plaintext-keyed value scan, and a child exportingCur(:AWSCURRENT) andPrev(:AWSPREVIOUS) of one ROTATING secret has both outputs resolve EQUAL during theAWSPENDINGwindow, so both parent properties persisted the survivor's expression andresolveReplayPropsapplied the WRONG stage to the live resource on a rollback. Both sides compute the key from the RAW leaf, and a non-literal attribute name refuses and degrades to today's behaviour. (d)cdkd diff --recursiveskipped condition evaluation whenever ANY bound parameter was token-valued. That is not merely conservative: with the condition map left undefined,resolveIfwarns and takes the FALSE branch for everyFn::Ifin every property value, so a condition-true property diffs as a spurious UPDATE (perpetual, and--failexits 1) on top of the phantom CREATEs. The skip is now scoped to conditions that reference a token parameter. A chained{Condition: X}shape needs no walk of its own and does not get one: the referenced condition is an entry in the sameConditionsmap and the scan visits every entry, which was measured rather than assumed (the first cut's transitive closure was removed once a probe showed deleting it changed no answer). Also corrected:AttributeFetcher.cacheFallbackinsrc/analyzer/orphan-rewriter.tsis a SECOND reader of a cached attribute that may legitimately hold an unresolved{{resolve:...}}since #2055 — it has no resolver context, so undercdkd orphan --forceit splices the token verbatim and now WARNS about exactly that, andbuildOutputsAttributes's "there is exactly one reader" note is corrected to "one RESOLUTION path". Real-AWS arm: the newtests/integration/nested-stack-secret/fixture, whose child carries an unrelated literal CONTAINING the secret plaintext as the #2087 discriminator (an overlap the script now ASSERTS rather than syncing by comment), which runs THREE deploys so the third reachesNestedStackProvider.update— the arm #1903's own comment names, and the one a no-op second deploy never touched — proving from AWS that the update ran before drawing any conclusion from the resulting state, which scans both deploys'--verboseoutput for either plaintext, and which checks the perpetual-UPDATE class by the EXIT CODE ofcdkd diff --recursive --failrather than by its text.The
--remove-protectionflip record is now dropped when the delete fails TERMINALLY, so a later destroy in the same process cannot re-enable a guard it never touched (issue #2244) —src/provisioning/providers/dynamodb-table-provider.ts,src/provisioning/providers/dynamodb-globaltable-provider.ts,src/provisioning/providers/dynamodb-delete-budget.ts,tests/unit/provisioning/providers/dynamodb-flip-record-terminal-release.test.ts,tests/unit/provisioning/providers/dynamodb-remove-protection-compensate.test.ts,tests/unit/utils/elapsed-budget.test.ts.ProtectionFlipRegistryentries are RETAINED on a throw by design — that is what carries "an earlier attempt already flipped the guard off" across the re-entereddelete()issue #1978's round 2 fixed — and until now the ONLY things that dropped one wererelease()on the success path and on the NotFound branch. A TERMINAL failure therefore left a record behind for a full reuse window, and issue #2211's SLIDE moved that window's clock from the FIRST acquire to the LAST, lengthening exactly this retention. Inside the window a second delete of the sameregion\0namekey IN THE SAME PROCESS — a rollback, a sibling stack, a re-run destroy, or two state records naming ONE physical table after acdkd importadopted it into a second stack, all reaching the same provider INSTANCE through theProviderRegistrythat served them (destroy-runner.tsbuilds a FRESH registry when a stack's region differs, so the sharing is per registry rather than per process, which is also why the key is region-qualified) — inheritedflippedOffByThisRun: trueand answered its own terminal failure with anUpdateTable(DeletionProtectionEnabled: true)the user never asked for. The fix is one guardedrelease()indelete()'scatch, gated onisTerminalDeleteFailure(error)AND on what the compensation reported. The predicate is the same one the compensation consults — the one that MIRRORSdestroy-runner.ts's re-entry condition, so it is already an assertion that no second attempt is coming — but not the same GATE: the compensation ANDs it withflippedOffByThisRunand!deleteAccepted, so the release also fires on the arms where the compensation early-returned having done nothing, which is intended (a record with nothing to undo has nothing left to serve). The release sits after acatchwhose outcome defaults tofailed, so an unknown outcome RETAINS the record. It is acatchrather than afinallybecause afinallyREPLACED the delete error on its way out when a logger threw inside the compensation, sothrow errorwas never reached and the caller saw the logger's message instead of the delete's; measured with a probe that madelogger.warnandlogger.errorthrow. The three routes out of thatcatchanswer differently, and only one of them is obvious. RETRYABLE (a throttle, and the attempt-cap exhaustion that ends a long sequence of them): the predicate is false, nothing is released, and the retained-on-throw contract stands unchanged — known narrowing 1 still leaves the guard off when the outer loop's cap gives up, because the provider cannot see which attempt is the last. INTERRUPT (Ctrl-C):isInterruptedWaitErrormakes it terminal, so the release fires once the compensation has landed, and this is the route that most needs it — the compensation has just put the guard BACK ON, so a retained latch describes a world that no longer exists while the process can still outlive the abort long enough for a rollback to reach the same key. It now has its own case; a claim in a comment that no test composes is one a refactor can quietly drop. DEADLINE (--resource-timeout): the release does NOT fire and CANNOT, becausesrc/deployment/resource-deadline.tsrejects the OUTER promise without cancelling what it wraps, so noResourceTimeoutErrorever enters thiscatch(known narrowing 2, the same non-cancelling shape issue #1955 documents); the record stays latched behind an attempt that is still polling, which is correct, and that attempt's eventual settle reaches either the success release or this one. The record is NOT released when the compensatingUpdateTableitself FAILED, and that arm is why the release reads the compensation's OUTCOME (not-applicable/restored/failed) rather than only the predicate. There the guard really is off and cdkd really is the one that turned it off, so the retained record is the only in-process memory that a re-enable is still owed — and a later delete of the same key retrying it is the action cdkd OWES, not the unrequested one this change prevents, which is about a guard already put BACK. Releasing there would make that later delete observe the guard already off, recordflippedOffByThisRun: falseand compensate nothing, leaving a table cdkd stripped still stripped; and it is reachable by exactly the route that justifies the release itself, since two state records can name one physical table oncecdkd importhas adopted it into a second stack. The delete ALLOWANCE is deliberately left alone, and NOT because a re-entry might still spend it — this arm's premise is that no re-entry is coming. It is out of scope here: the two registries differ in what an inherited entry can DO (an inherited allowance can only make a later delete give up sooner, never issue a write nobody asked for), andElapsedBudgetRegistry's window is FIXED rather than sliding, so it ages out from its own creation regardless. Applied to BOTH providers. The lines this change ADDS are byte-identical on the two halves, but the wrappers themselves are not and never were — theGlobalTableone carries a twelve-line header of its own, callsdeleteGlobalTableResource, and passestypeLabel: 'GlobalTable'— so what is shared is the mechanism, kept in step deliberately: the compensation suite is adescribe.eachover the pair, and a one-sided fix is the half-applied twin issue #1978 asked specifically to avoid. Two paths in the same mechanism gained tests (the issue's second item). Attempt-cap exhaustion now pins that the registry holds ONE record per key rather than one per attempt, that nothing compensates between retries, and that the post-cap record is not INHERITED by a later delete once the window has elapsed. Four further cases in the same suite close the gaps a review found in the first revision: the Ctrl-C route (the production comment calls it "the route that most needs it", and nothing composedisInterruptedWaitError-> release), a REGION-QUALIFIED key (every other case leftexpectedRegionundefined, so a wrong region source in the release stayed green), a throttle sequence that ENDS terminally and must still re-enable (the over-release direction, fenced black-box inside this file rather than only by its white-box registry-size line), and a compensation that FAILED, whose record must be retained so a later delete can retry the re-enable. The suite lives intests/unit/provisioning/providers/, beside the sibling it shares its subject with. (It is NEW against main; an intra-branchgit mvis why the diff shows no deletion.)ElapsedBudgetRegistry'sreuseWithinMsgains four cases in the primitive's own suite, the load-bearing one asserting that this window measures from CREATION and does NOT slide — the deliberate divergence fromProtectionFlipRegistry, which went sliding for #2211 and is otherwise a look-alike with the same key and the same window constant. One claim in the issue is FALSE against the tree and is corrected rather than inherited:reuseWithinMswas not untested —tests/unit/provisioning/dynamodb-delete-budget.test.ts("a RETAINED allowance goes stale once its deadline has certainly fired") passes it and would already red on a slide. What was missing is a fence in the file someone EDITINGsrc/utils/elapsed-budget.tsopens, since a contract tested only through one consumer's suite is one a refactor of the primitive can break without ever reading it. Every new case is mutation-proven, and the discriminator is never "the delete threw" — that is true of the broken code too. Four probes on the release itself, each run against both providers and each restored with the restore verified in the same command. DELETING the release reds all 12 cases in the new suite. Dropping the TERMINAL predicate (if (outcome !== 'failed')) reds 4 there — the attempt-cap case's registry size (expected +0 to be 1) and, black-box, the throttle-sequence case that must still re-enable (expected [] to have a length of 1 but got +0) — plus 4 indynamodb-remove-protection-compensate.test.ts, which is the #1978 round-2 regression caught from the other side. Dropping the OUTCOME gate (the shape this change's own first revision shipped, releasing even after a failed re-enable) reds exactly the two new failed-compensation cases black-box (expected [ UpdateTableCommand ] to have a length of 2 but got 1) and the two #2224 registry assertions in the sibling suite. Keying the release WITHOUT the region reds only the region-qualified case, black-box (2 re-enables where 1 was expected) — until it was added, every case passedexpectedRegion: undefinedand a wrong region source in the release stayed green. A fifth probe, indynamodb-delete-budget.ts, makes the ResourceNotFound arm reportnot-applicableinstead offailedand reds 3: both providers' #2224 registry assertions and the direct-unit outcome assertion. What is NOT probed, and is said rather than implied: thecatchplus the'failed'default is defence in depth with no reachable red for the RELEASE, becausecompensateRemovedDeletionProtection's whole contract is that it does not throw — the structure exists so "unknown outcome" keeps meaning "retain" if that ever changes. Making the reuse window permanent reds the not-inherited assertion; and the fourreuseWithinMscases red under, respectively, making the bound non-optional, removing the window, flipping its boundary to>=, and unifying it into a sliding window.
Recently Implemented (2026-08-25):
cdkd drift: a per-resource read or comparison failure no longer sinks the whole run, and a stack in which nothing was compared no longer prints a green check (issues #2151, #1945, #2154) --src/cli/commands/drift.ts, plustests/unit/cli/drift-per-resource-failure.test.tsand updates to three existing drift suites. Previously a throw from ANY per-resource call in the detection loop propagated out of the loop and out of the command: no summary line, no per-resource report, and every other resource in the stack -- under--allevery remaining STACK -- left unchecked, over one bad resource. Measured live on 2026-08-21 against the committedcloudwatch-anomaly-detectorfixture, where the Cloud Control fallback threwUnsupportedActionExceptionand the stack's ordinary SQS queue was never looked at, with exit1-- the same code that means "drift detected", so a CI gate reported drift on a run that compared nothing. The two issues reported two sites (#2151 the Cloud Control fallback, #1945calculateResourceDrift), but the defect is a CLASS: the reachable sites areprovider.readCurrentState,ccApiFallback.readCurrentState,getDriftUnknownPaths/getDriftUnorderedPaths,canonicalizePrincipalUniqueIds,canonicalizeIpProtocols,provider.canonicalizeDriftProperties,calculateResourceDriftandredactDriftChanges, so ONE guard wraps the whole per-resource body rather than a catch per reported call. This restores the symmetry the surrounding code had already chosen -- the provider lookup, the deny-list short-circuit and (since #1914) the dynamic-reference resolution all already degraded to a per-resource outcome and continued.NotComparedCausegainsreadFailed, and the taxonomy question #2151 raised (read-failure vs no-read-path) is settled by whether a RE-RUN CAN CLEAR IT:isNoReadHandlerErrorroutesUnsupportedActionException/TypeNotFoundException-- matched through the cause chain, sinceCloudControlProvider.handleErrorre-wraps them, and bounded at depth 10 so a self-referentialcausecannot hang the guard -- to the existingunsupportedoutcome at exit0, because that is the same condition the fallback otherwise signals by returningundefinedand AWS picks which spelling it sends; everything else isreadFailedat exit2, distinct from the1that means drift.outcomeExitSignalis rewritten as an EXCLUSION ofunresolvedTokenrather than an inclusion list, so a cause added later defaults to the non-zero side -- the silent-default #2135 made impossible for a VARIANT was still reachable one level down at the CAUSE.DriftComparisonRefusedErroris renamedDriftComparisonIncompleteError(private; no import site outside the file) sincerefusedis no longer its only cause.--jsonnotComparedentries gain acausekey (refused/unresolvedToken/readFailed) and theirreferencesUnresolvedstops being the literaltrue-- it isfalsefor areadFailedentry, whose references are beside the point;notCompared.length === 0remains the documented "everything was actually checked" predicate and is unaffected.notComparedOutcomesnow returns the cause beside each outcome, because it is where the invariant is established and every reader otherwise needs an unreachable??default. The human report's block heading stays BYTE-FOR-BYTE identical when noreadFailedentry is present and only switches wording when one is, so the widened population does not move output for stacks that do not have it.--accept/--revertneed no filter: both iterate the drifted outcomes only, so areadFailedresource is excluded from remediation by its outcome kind -- #1945's second open question, answered structurally. Separately, #2154: the glyph follows "was everything actually compared", so a stack withchecked === 0and at least one outcome now prints⚠ ... NOTHING was comparedinstead of✓; this covers theskipped-only (all-Custom::*) stack, which #2154 flagged as its own user-visible call, and the EXIT CODE is untouched -- flipping it would fail such a stack's CI forever, the hazardunresolvedTokenis already excluded on. A stack with NO resources keeps the✓. Every fence in the new suite is mutation-probed: removing the guard reds 8 of 12, forcingisNoReadHandlerErrorfalse reds 2 and true reds 6, mappingreadFailedtononein the exit signal reds 4, disabling the #2154 branch reds 1 here and 1 indrift.test.ts, dropping the empty-stack conjunct reds 1, and removing the cause-chain walk reds 1.AWS::Regionand theFn::GetAZsregion filter now fold at their SOURCE, closing the two region reads that could be wrong SILENTLY (issues #1882 and #1887) --src/deployment/intrinsic-function-resolver.ts, plus tests.effectiveAccountInfoRegionreturnedoverrideRegion || process.env['AWS_REGION'] || 'us-east-1'unfolded, and it is the source ofaccountInfo.region-- which is whatAWS::Regionreturns, what everyFn::Subin a USER template interpolates, and whatresolveGetAZssends to EC2 as aregion-namefilter when the template names no region. It now folds throughcanonicalizeRegiononce, at the read, rather than at each of those three consumers. The decision issue #1882 was blocked on was settled by measurement rather than by argument, and the measurement dissolved the question instead of answering it. That issue heldAWS::Regionraw pending a live CloudFormation A/B, on the reasoning that it is CFn's own passthrough and folding changes a value the template author reads back directly. The A/B was run on 2026-08-25: a non-canonical region never reaches CloudFormation at all, because SigV4 scopes a credential to the region STRING and the service compares it case-sensitively --STSClient({region:'US-EAST-1'}).send(GetCallerIdentity)andCloudFormationClient({region:'US-EAST-1'}).send(ListStacks)are both refused withSignatureDoesNotMatch: Credential should be scoped to a valid region, as is the mixed-caseUs-East-1. So the two obvious routes are closed BEFORE this function ----region/AWS_REGIONfold at the CLI boundary (issue #2065), and a CDK app declaringenv: { region: 'US-EAST-1' }fails atapp.synth()withCloudAssemblyError: Unable to parse environment specification, sinceEnvironmentUtils.parseis case-sensitive (measured on aws-cdk-lib 2.244.0). What is NOT closed is the route that makes this fold more than tidiness, and an earlier draft of this entry wrongly called it unreachable: cdkd's ownparseEnvironmentaccepts any region text, so a Cloud Assembly carrying a raw region -- hand-authored, from a non-CDK toolchain, or acdk.outleft by a synth that threw AFTER writing the manifest -- reachesstackInfo.regionunfolded anddeploy.tspasses it on as the resolver's region. That deploy SUCCEEDS, becauseAwsClients' constructor folds the region its clients sign with, so SigV4 never sees the raw spelling; every${AWS::Region}a user'sFn::Subinterpolates then inherits it, producingarn:aws:s3:US-EAST-1:..., which no IAM policy matches, and persisting it beside the canonical ARNs cdkd itself builds since issue #1850. Folding removes that self-contradiction. Upgrade consequence, stated because #1850's own entry states it for its fold: a stack deployed that way keeps the raw spelling in its recorded properties, so the next diff of a property interpolating${AWS::Region}sees a change, and where that property is create-only it classifies as a REPLACEMENT -- deliberate, since the recorded value is unusable and converging it is the point. State KEYS are unaffected; they are built fromstackRegion, which this does not touch. The EC2 filter is the more dangerous of the two consumers because it fails SILENTLY rather than loudly: EC2 matchesregion-nameliterally and answers a non-matching filter with success and an EMPTY list, measured live the same day asregion-name=us-east-1 -> 6 AZsagainstregion-name=US-EAST-1 -> 0 AZs, and the wrong filter therefore yields no zones at all. What happens NEXT needs stating precisely, because issue #1887's own wording is out of date and this lane repeated it before checking: since issue #1957 an empty answer is REFUSED with a throw rather than propagating into an out-of-rangeFn::Selector a silently empty subnet list. The surviving defect is thus not silence but a WRONG FAILURE -- a deploy that should have resolved the region's zones instead dies, blaming an unenabled region or a foreign endpoint, neither of which is what happened. The same value keyscachedAvailabilityZones, so two spellings were two entries for one region. The consumer-sidecanonicalizeRegioncalls this subsumes --constructAttribute's destructure fold,s3-endpoints.ts's entry fold -- are deliberately LEFT in place, since double-folding is a no-op. Onlys3-endpoints.ts's is still reachable from a caller that does not come through here (S3BucketProvider.buildAttributeshands its3Client.config.region());constructAttributehas a single caller which always passesgetAccountInfo(this.resolverRegion), so its fold is now genuinely redundant and is kept as defense in depth. An earlier draft claimed reachability for both. TWO sibling issues were audited and found ALREADY FIXED rather than fixed again, and are closed with that evidence alongside this change: issue #1881 (six SDK providers building ARNs fromclient.config.region()) and issue #1888 (the S3CreateBucketLocationConstraintcomparison, viagetRegion()->s3Client.config.region()). Both read the SDK CLIENT's own resolved region, andAwsClients' constructor folds that for a bag CONFIGURED with a region --tests/unit/utils/aws-clients-region-fold.test.tsfences it down to the CONSTRUCTED client rather than only the reported config. The carve-out matters, and an earlier draft of this sentence dropped it: a region-LESS bag resolves from the SDK's own chain instead, so for that shape the coverage comes from the CLI boundary folding the env vars (issue #2065) rather than fromAwsClients, andcdkd gcdeliberately skips that boundary. That draft also said THREE while naming two, having counted issue #1887, which this change CLOSES rather than finds already fixed. Tests: the pre-existing case pinningAWS::RegionRAW is flipped to assert the fold, with the measurement recorded beside it; both polarities (a mis-cased region folds, an already-canonical one is byte-identical); theFn::Subpath asserted separately from the bareRef, sinceFn::Subsubstitutes rather than returns and a fold covering only theRefwould leave issue #1882's reported symptom broken; and the EC2 filter asserted on the input cdkd SENDS rather than on the AZ names it gets back, which come from the mock and would look correct under either spelling. All mutation-probed against removal of the fold. Three review rounds' findings are folded in rather than deferred. The fold is written over the wholeoverrideRegion || AWS_REGIONexpression, so it has TWO input paths, and every case written with the fix supplied an override -- the resolver handsresolverRegionin explicitly, and that is captured in the constructor and always non-empty. Two reviewers independently measured the consequence: mutating the source to fold only the override left 400 tests green. The env arm is reachable (CustomResourceProvider.resolveSyntheticStackIdpassesundefinedfor a region-less client bag, andcdkd gcdeliberately skipsfoldRegionOption), and reaching it in a test needs a directgetAccountInfo()with no argument, since a region-less RESOLVER still routes through the override. Review also found the fold REGRESSING a guard:Fn::GetStackOutput's self-reference refusal comparesregion-- folded whenever the template names one, and{"Ref": "AWS::Region"}is such a case -- againstresolverRegion, which keeps the caller's spelling because it keysgetState/saveStateand must not move. For a mis-cased resolver region the test became'us-east-1' === 'US-EAST-1', the refusal stopped firing, and acfnFallbackread could land on a same-named CloudFormation stack. Round 2 folded ONE side, which was not enough -- see the round-3 paragraph below. AndresolveSyntheticStackId(src/provisioning/providers/custom-resource-provider.ts) turned out to be the ONLY reader ofaccountInfo.regionin the repo with no localcanonicalizeRegion-- the cloud-control, SSM and AppSync ARN builders all fold at the interpolation -- so theStackIdhanded to a user's Custom Resource handler, which routinely parses it for the region, was the one place the raw value escaped to user code; both its arms (pinned bag, ambient env) now have cases, because a fold written over only the first leaves the second raw. Four source comments in three files that calledaccountInfo.region"folded nowhere" and claimedcdkd deploy --region US-EAST-1SUCCEEDS are corrected in place. A THIRD round then found that the guard fix had reproduced its own bug, mirrored, and the correction had shipped a second false claim -- both worth recording because the pattern is the point.regionreaches that comparison folded when the template names aRegionand RAW when it does not (it defaults toresolverRegion), so folding one operand repaired the first branch and broke the second: a mis-cased resolver region with noRegionargument resolved its OWN stack's output instead of refusing, reproduced live by two independent reviewers who then DISAGREED on the remedy. Tracing the variable settled it --regionis passed on togetSameAccountStackState/getCrossAccountStackState/lookupCfnStackOutputs, where it is a state-key segment, so folding it at the initializer (one reviewer's proposal) would move the key. Both operands now fold AT THE COMPARISON, which is the only place that normalizes without changing what is looked up. A three-way probe proves the pair of cases is complementary rather than redundant: folding the right operand alone reds only the no-Regioncase, folding neither reds only theRegioncase, and folding the left alone reds both. The comment correction, meanwhile, replaced "folded nowhere" with "dies at the first AWS call" -- also false, and contradicted by the JSDoc one commit earlier saying such a deploy SUCCEEDS becauseAwsClientsfolds first. The flag is CANONICAL before it reaches anything (foldRegionOption, issue #2065); it never dies because a raw spelling never gets that far. Round 1's defect class, re-shipped inside the fix for it. One self-inflicted test bug is recorded for the same reason: making the AZ-cache assertion discriminate by priming twomockResolvedValueOnceanswers left the SECOND unconsumed -- precisely because a cache hit means there is no second call -- and it was consumed by an unrelated later test, the leakvp run test:once-leakexists to catch (issue #1618). One primer, consumed by the one call, asserting the positive marker. The standing hazard behind all of it --resolverRegionraw by design while everything derived from it folds, with nothing making that visible at a comparison site -- is issue #2209 rather than a fourth round here, since taking a structural fix late in a cascade is how a fourth round happens. A fourth review round then confirmed the cascade was over -- it enumerated all eight remaining region-shaped comparisons in the resolver and found only one still carrying the guard's shape, pre-existing and informational-only, which is folded into #2209 as a checklist row rather than minted as its own issue. It also found a FOURTH copy of the corrected claim, incloud-control-provider.ts: the round-3 sweep had grepped the exact phrase the three known copies used rather than the SHAPE, so a differently-worded copy survived -- the repo's own "grep for a shape, not a name" lesson landing inside the fix for the same class. And it found the one thing none of the new cases fenced: the DESIGN DECISION the fix rests on. Folding at the initializer instead, with a one-sided comparison, satisfies both self-reference cases and kept the full suite green at 777 files / 16009 tests while silently moving the state key for a mis-cased resolver region fromcdkd/<stack>/US-EAST-1/state.jsonto.../us-east-1/. A non-self-reference case now asserts the producer is looked up under the UNFOLDED spelling -- self-reference throws before the lookup, so the guard's own cases structurally cannot observe which key it would have used -- and it is the single case that reds under that mutation. Scope stated precisely, because the first draft of this entry over-claimed it as "the last unfolded region read": about twenty-five provider sites still readprocess.env['AWS_REGION']raw, but every one of them feeds SDK CLIENT construction (private readonly providerRegion, and the|| process.env['AWS_REGION'] || 'us-east-1'tail of two ARN builders, reachable only when the client resolved no region at all). A raw region there fails LOUDLY at the first call -- that is what the SigV4 measurement above shows -- so it is a different class from the two fixed here, which produce a plausible wrong VALUE and no error. Those provider-built clients are the same population issue #2081 describes from a different angle (they escape agetAwsClientsmock), but #2081 is about test isolation and says nothing about region case, so it is a pointer to the population and not a claim that it tracks this defect. The first cut of the twoFn::GetAZscases was VACUOUS and the probe is what caught it: they wroteprocess.env['AWS_REGION']around an already-constructed resolver, butresolverRegionis captured once in the constructor and then passed togetAccountInfoas an explicit override, which short-circuits the env read -- so 230/230 passed with the fold deleted. Handing the mis-cased region to the constructor is what made them discriminate.cdkd local run-taskno longer echoes a secret's plaintext prefix when a:json-key:reference points at a non-JSON secret (issue #2189) --src/local/ecs-secrets-resolver.ts, plus tests. ThejsonKeybranch interpolated the rawJSON.parsefailure into its own error message, and V8 embeds a prefix of the PARSED INPUT inSyntaxError.message-- the parsed input being the secret itself. Reproduced on the pinned Node 24.15:JSON.parse('supersecretpassword12345')yieldsUnexpected token 's', "supersecre"... is not valid JSON, so the first ten characters reached stderr and any surrounding log capture. A short secret is a second, distinct leak shape the issue did not name: V8 appends...only past its prefix window, soshortpwwas quoted in FULL. Reaching it needs no hostile input -- a plain-string secret plus a:json-key:ValueFromis an ordinary user mistake. The message now carrieserr.name(input-independent) instead oferr.message, and says outright that the parser detail is withheld so a later contributor does not helpfully restore it; the container, env var and requested json-key already make it actionable, which is also the shapesrc/deployment/intrinsic-function-resolver.tshad already settled on for the equivalent{{resolve:secretsmanager:...:SecretString:jsonKey}}path. The sweep the issue asked for is recorded rather than assumed: secret material enterssrc/local/in exactly two files (ecs-secrets-resolver.tsandecr-puller.ts), and a broad grep over every catch-bound.message/.stack/.stderr/String(err)interpolation went 58 sites to 57 -- one site removed, the rest verified false positives with the reason recorded per site. The two SDK-send interpolations in the same file are deliberately untouched: they consume an ARN or a parameter name, no secret value exists on their failure path, and they are load-bearing IAM / network diagnostics.ecr-puller.ts'sdocker loginfailure was checked againstsrc/utils/docker-cmd.tsrather than assumed -- the password goes over--password-stdinandSpawnError.messageis built from child stderr/stdout only.rie-client.tsandvtl-engine.tscarry the same SHAPE over a different population (a Lambda streaming-response prelude and a VTL$util.parseJsonargument -- application payload, not secret material cdkd resolved), so they are deliberately out of this fix's one-root-cause scope; tracked separately. Tests: two cases, each pairing the negative assertion with positives on the context that must survive, since a bare "the secret is absent" is satisfied by any unrelated rejection. Note the negative had to target the ten-character PREFIX rather than the whole secret --not.toContain(fullSecret)passes WITHOUT the fix, because V8 only ever emits the prefix. Both mutation-probed: restoringerr.messagereds both and reverting restores green. A live arm was added too, because no fixture reached the branch --EnvTaskDefreferences the whole secret (no json-key) andMySecret's value IS valid JSON, so both halves of the precondition were missing and any integ run would have been a regression net for a different issue. The arm asserts the POSITIVE markers first (a non-zero exit, and a message naming the container, the env var and the requested key) because "the plaintext is absent" alone is a confluence point any early death satisfies; the premise is proved in its own step, so an edit that makes the secret parseable fails loudly rather than silently disarming the arm. Real AWS, us-east-1: rc=1, no plaintext, destroy 15 deleted / 0 errors, 0 surviving object versions. The first real-AWS run found a bug in the arm itself -- this fixture'scleanup()ends withexit "${rc}", so calling it from the success path terminated the script and the state-version sweep never ran; the log simply stopped after the destroy. The sweep now lives insidecleanup, which also widens it to every exit path. Review of the arm then found the helper was sourced RELATIVELY before thecd, so the repo-root invocation the script's own header documents aborted at that line; it is now sourced by absolute path, asdocdb-neptunealready does for the same reason. Two assertions were strengthened: the container-leftover count is taken BEFORE thedocker rm -f(counting after it cannot fail), and the refusal now has to match the whole contiguous message rather than four loose tokens that occur elsewhere. Follow-up: the sweep lint has nogenerateSecretStringpattern, so four other fixtures seed generated credentials into versioned state unswept (issue #2212); this fixture was in that class until this PR closed it incidentally. A second follow-up, #2225, records a house-wide convention bug the review surfaced: the purge mode is gated on the SCRIPT rc while the destroy inside cleanup is piped throughtailwith|| true, so a run whose assertions passed and whose destroy then failed takes theallbranch and deletes the state.json a latercdkd state destroywould need.cdkd --versionno longer imports the command tree, dropping it from ~1020 ms to ~50 ms (measured 46-52 ms across builds) (issue #2002) --src/version.ts(new),src/cli/index.ts,src/cli/program.ts,src/state/deployment-events-store.ts,.claude/rules/layout-misc.md, plus tests. Issue #2002 was filed as a test flake:tests/unit/cli/version.test.tsspawns the built CLI and was measured at ~3.96 s against vitest's 5 s default timeout, so it failed on the first full-suite run after a build -- the moment acheckmarker is about to be recorded, and the hardest shape to attribute because the testskipIfs itself whendist/is absent. PR #2186 had already raised that one test's budget to 30 s, which removes the red without touching the cost; the issue explicitly asked for the measurement FIRST ("~4 seconds is a long time forcdkd --version... worth knowing before the timeout is simply raised"), and this is that measurement plus the fix it pointed at. Measured on the pinned Node 24.15 with a phase-instrumented build: ~1020 ms total, of which ~48 ms is Node startup and ~3 ms isbuildProgram()-- the remaining ~970 ms is spent before any cdkd statement executes. A CPU profile attributes it to Node's module loader (readFileUtf8336 ms,wrapSafe/compile 296 ms,internalModuleStat183 ms,readPackageJSON112 ms,detectModuleFormat86 ms), not to cdkd doing work at import time:src/cli/program.tsstatically imports all 20 command modules, anddeps.neverBundleinvite.config.tskeeps@aws-sdk/*external, so resolving and compiling that graph is the whole cost and every command pays it.index.tsnow answers a bare version flag from the dependency-freesrc/version.tsBEFOREawait import('./program.js'); the dynamic import is load-bearing, since a static one is hoisted and would evaluate the same graph regardless of what the function decides. The built entry chunk goes from 4.1 MB to 2.5 KB. The predicate is deliberately narrower than "the argv contains a version flag", and the reason is conservatism rather than a measured disagreement: an earlier draft of this entry claimed commander would consume a standalone flag as an option value, and a review measured the opposite -- against commander 12.1.0,cdkd -c --version,cdkd deploy -c --versionandcdkd --profile -Vall print the version and exit 0. The narrow rule is still right for two reasons that survive the correction: it does not DEPEND on that precedence, which is commander's behaviour and can change across a major, and a wider rule would be a second spelling of commander's parse -- the two-spellings-of-one-question failure this repo has hit repeatedly. Every other shape falls through to the unchanged commander parse, verified byte-identical across--version/-V/deploy --version/--version deploy/local --version/state --version/--help; only the first two take the fast path, andcdkd deploy --helpis unchanged at ~1000 ms (the broader lazy-loading of the command tree, which would move that number too, is NOT in this change). Thetypeof __CDKD_VERSION__guard had been spelled three times in two different forms (=== 'string'inprogram.ts,!== 'undefined'indeployment-events-store.ts) with two copies of the'0.0.0-dev'fallback;src/version.tsis now the single spelling and the other two delegate. Tests: 14 unit cases on the predicate and the fallback, an ARTIFACT fence asserting the entry chunk reaches the command tree only through a dynamic import and stays under 64 KB of BYTES (statSync().size, notentry.length-- the chunk carries non-ASCII), and a WIRING fence that is the one actually covering the branch this change adds. The first two do not fence the fix, and a review measured that: deleting theifblock fromindex.tswhile KEEPING the dynamic import left every test green, because the chunk stays small and the predicate's own tests constrain a function nothing is obliged to call -- the shipped behaviour change had no fence at all. The wiring fence copies the built entry plus its static chunk into a tmpdir, replaces the dynamically-imported command-tree chunk with a module that THROWS on evaluation, and asserts--versionstill answers the right version while--helpfails with that throw -- the positive marker plus the negative control that proves the stub was really wired in, since a sandbox that silently resolved the real chunk would otherwise look identical. Three wrong fences were tried and ruled out, and are recorded at the test: an elapsed-time assertion cannot separate ~47 ms from ~1020 ms on a loaded machine; a source regex forisVersionOnlyInvocationfails because rolldown renames the exports and a name match would not prove the branch PRECEDES the import; and importingsrc/cli/index.tsfrom a unit test runsmain()as a side effect. Five mutations probed RED and restored GREEN: the naiveargv.some(isVersionFlag)spelling reds 5 predicate cases, widening the accepted flag set reds the exact-set case, dropping thetypeofguard reds the sentinel case, restoring the static import reds the artifact fence, and deleting the fast-path branch while keeping the dynamic import reds the wiring fence. A second review round then found that fence was INERT IN CI, which is the same defect one layer out:.github/workflows/ci.ymlranvp run testBEFOREvp run build,dist/is gitignored and there is no artifact restore, so the whole file green-SKIPPED on every PR (measured withdist/absent:Test Files 1 skipped (1) / Tests 3 skipped (3)) -- and the wiring fence is the only coverage of the shipped branch. CI now builds first, AND the test asserts loudly rather than skipping whenCDKD_EXPECT_DISTis set, whichci.ymlsets on the build job's test step ALONE, so a future reorder cannot silently re-disarm it. Keying that on a bareCIwas the first attempt and it reddenedonce-leak-detectandruntime-compat, which are separate jobs that legitimately never build --CIsays "a CI job", and what the assertion needs to know is "a build ran"; the comment and the assertion are deliberately redundant, because the comment alone is what failed here. Three smaller findings from the same round: the sandbox now pins{"type":"module"}rather than relying on Node's unflagged ESM syntax detection (under--no-experimental-detect-module, whichruntime-compatexercises, it otherwise fails to load); a claim that rolldown renamesisVersionOnlyInvocationin the entry was wrong (only the chunk's own exports are minified -- the name survives twice, and the argument rests on the branch ORDER instead); and areaddirSync(sandbox)guard labelled anti-vacuity was itself vacuous, sincewriteFileSynchad created the file two statements above, so it is replaced by a note pointing at the stderr check that actually does that job. A final test review then added two things it surfaced: the flag-set-but-unbuilt failure now names the CAUSE (a step-order regression in ci.yml) instead of surfacing a bare ENOENT from inside a test body, and a comment records that the sandbox living intmpdir()-- specifically its ABSENCE of a node_modules -- is load-bearing. That absence is what makes the fence also catch "somebody added a static import to index.ts": measured, adding one left the entry chunk at 2.5 KB, well under the byte bound, and regressed--versionto ~0.4-0.7 s, with only this test going red.- A
--remove-protectionflip is now UNDONE when the delete then fails terminally, so a failed destroy no longer leaves a live DynamoDB table with its deletion guard silently stripped (issue #1978) --src/provisioning/providers/dynamodb-delete-budget.ts,src/provisioning/providers/dynamodb-{table,globaltable}-provider.ts,docs/cli-reference.md, plus tests.cdkd destroy --remove-protectionflippedDeletionProtectionEnabledtofalse, waited for ACTIVE (swallowing wait failures by design), then issuedDeleteTable. When AWS refused that delete terminally the run ended loudly -- and the SIDE EFFECT was silent: the table was still there with the guard the user had opted into now removed, and the error named the delete, not the protection. This is not data loss; it is the removal of the control that prevents data loss, left in place after the operation that justified it did not happen. Each provider'sdelete()is now a thin wrapper around the original body, holding aProtectionFlipRecordand calling one sharedcompensateRemovedDeletionProtectionfrom itscatchbefore re-throwing. Three properties are load-bearing. It never masks the original error: the re-enable sits in its owntry, never throws, and a failure goes tologger.errornaming the physical id and the exactaws dynamodb update-table --deletion-protection-enabledcommand, while theProvisioningErroris re-thrown byte-identical -- deliberately un-annotated, becauseisRetryableTransientErrorSUBSTRING-matches the message and splicing text into it can flip a terminal failure into a retryable one. It compensates only what THIS run changed: aDescribeTableruns BEFORE the flip andflippedOffByThisRunis LATCHED only after AWS accepts theUpdateTableand only when the observation saw the guard ON, so a table whose protection was already off is left alone; if the observing describe fails, the record staysfalse(do not know, so do not touch). Reading the pre-flip value from state was rejected -- state can be stale, and the issue asks for the OBSERVED value. The record SURVIVES a re-entereddelete(), which is the issue's retry-then-fail case and the one a per-call record structurally cannot see:destroy-runner.tsre-invokesdelete()for a retryable failure (up to 4 calls under one per-resource deadline), and attempt 2's pre-flip describe reports the guard already OFF because attempt 1 turned it off -- so a fresh record read "nothing to undo" on exactly the attempt that ends the delete, and a two-call probe measureddeleteCalls=2, reEnableCount=0. The record therefore comes from aProtectionFlipRegistrykeyed with the samedeleteBudgetKey(physicalId, expectedRegion)and the same reuse window as the neighbouring delete allowance -- the identical re-entry the #1955 budget already spans -- and it LATCHES rather than assigns, since a later attempt's observation would otherwise erase the earlier one's; it is released on the success and NotFound paths beside the budget. A provider INSTANCE FIELD was rejected for the budget's own reason (providers are singletons serving a level's tables concurrently), and||=at the assignment alone changes nothing while the record is still per-call. It never re-guards a table AWS is already deleting:deleteAcceptedis recorded the instantDeleteTableresolves, because a throw after that point is a WAIT giving up --waitForTableGone'sTable X did not disappear within Nsmatches no retryable pattern and is therefore TERMINAL by this module's own predicate, so ungated the compensation issued anUpdateTable(true)against aDELETINGtable and logged that it was "LIVE with its deletion protection still off", both false. The Ctrl-C route recorded on the issue is covered by the same mechanism, because the compensation hangs off the METHOD boundary rather than off theDeleteTabletry: any throw out of the post-flip region reaches it, including the interruptible waits go-to-k/cdkd#2053 introduced.isTerminalDeleteFailuretestsisInterruptedWaitErrorFIRST and that clause is not redundant -- the interrupt message embeds user-chosen names, andDependencyViolationis both a legal table-name fragment and aRETRYABLE_ERROR_MESSAGE_PATTERNSentry, so a purely message-based classification reads a Ctrl-C as a throttle and skips the compensation. The terminal-only gate mirrorsdestroy-runner.ts's re-entry condition exactly so the two cannot disagree; three known narrowings are documented in code: a genuinely retryable failure that exhausts the outer loop's attempt cap still ends with the guard off (the provider cannot see which attempt is the last, and compensating on every attempt would mean anUpdateTablepair per retry against a table AWS is already throttling); the per-resource DEADLINE route is unreachable by design, sinceresource-deadline.tsrejects the OUTER promise without CANCELLING what it wraps, so noResourceTimeoutErrorever entersdelete()'s catch -- fixing that means making the deadline cancel, a change every provider shares, so it is recorded rather than attempted here; and the compensatingUpdateTableis deliberately left UNBOUNDED, since it is one control-plane round trip with no polling, the SDK's own timeout config applies to it, and a timeout short enough to be felt during a Ctrl-C teardown would mostly convert successful restores into the "could NOT re-enable" line. Deferring the flip to immediately beforeDeleteTablewas considered and NOT taken: it narrows the window rather than closing it (the ACTIVE wait inside it is itself interruptible, which is the recorded Ctrl-C route), on GlobalTable it would have to move past the replica teardown -- a real destroy-path behaviour change whose AWS semantics are unverifiable without a live run -- and the budget arithmetic indynamodb-delete-budget.tsprices the flip's ACTIVE wait as the first term on the path. Both providers are one root cause with two sites and share one helper rather than two symmetric copies; a single suite runs every case against both viadescribe.each, so a half-applied fix fails visibly. Tests: 10 cases x 2 providers, each asserting WHICHUpdateTablewent out with WHAT input (this path issuesUpdateTablefor the flip-off, the replica teardown and the compensation, so "an UpdateTable happened" is true of every case including the ones that must not compensate), plus one GlobalTable-only case for the accepted-delete gate (the siblingTableprovider has no step after an acceptedDeleteTablethat can throw --deleteTableWithIndexBusyRetry's body ends there -- so that half is fenced type-independently by four directProtectionFlipRegistry/compensateRemovedDeletionProtectioncases instead). The re-entry regression is driven as two realdelete()calls on one provider instance rather than by reaching into the registry, since the re-entry IS the thing under test. Sixteen mutation probes, every one of them executed and its verdict read: replacing the keyed record with a per-call one and replacing the latch with an assignment each red the re-entry case per provider (4); settingobservedProtectionOn = truein the failed-observation catch, and dropping the observation gate outright, each red the failed-observation case per provider (4); dropping thedeleteAcceptedassignment or its gate reds the accepted-delete case (2); moving theflippedOffByThisRunwrite above the flipsendreds the rejected-flip case per provider (2); adding an unconditionalDescribeTableto either delete path reds the per-provider describe-count assertion (2) -- which is what makes the "the pre-flip observing DescribeTable must not appear either" comment an assertion rather than a claim, and it additionally reds the cases whose fixture keys behaviour off the describe INDEX, which is expected coupling rather than a second finding; and dropping the compensating call reds 11 of the 25 when the drop isopts.reEnable()INSIDE the shared helper (10 when it is dropped at the two provider call sites instead -- stated because a bare count is unreproducible without saying where the mutation went). One further gap is known and filed rather than fixed: the flip registry's reuse window is FIXED rather than sliding, so a retry sequence that outlivesDELETE_BUDGET_REUSE_WINDOW_MS-- reachable with a--resource-timeoutpast 30 minutes -- drops the entry and reproduces this issue's residue through the very mechanism added to prevent it (issue #2211). A second, cosmetic residue is filed as #2224: on a region-mismatch RNF race the compensation fires against an already-deleted table and logs at error level that the table is LIVE.resolveDynamoDbDeleteBudgetClock()is threaded intoprotectionFlips.acquirefor that fix's benefit: without the injected clock the window cannot be driven from a test. The one probe that does NOT red is stated rather than hidden: removingflip.deleteAccepted = truefrom theTableprovider leaves the suite green, because nothing on that type can throw after an accepted delete today -- it is carried for symmetry with the sibling and becomes reachable the moment that provider grows a post-delete wait, which its twin already has. The retryable-errors check also caught a live test bug: the first terminal-refusal fixture used anAccessDeniedExceptionreadingnot authorized to perform, which IS aRETRYABLE_ERROR_MESSAGE_PATTERNSentry, so the test was exercising the wrong arm; it was replaced with the issue's ownResourceInUseExceptionexample, verified against the real pattern list. Follow-up: the same uncompensated flip exists on ~10 other providers plus the generic Cloud Control protection patch, tracked separately.
Recently Implemented (2026-08-24):
cdkd local run-task: a refused secret name is now always legible in the warning, and the good-name rule is one pattern instead of a clause list (follow-up to issue #2183) --src/utils/docker-cmd.ts,src/local/ecs-task-runner.ts,src/local/ecs-network.ts,src/local/docker-runner.ts,src/cli/commands/local-run-task.ts, plus tests and three integ fixtures. The collision warnings rendered each dropped key with a barejoin(', '), so an EMPTY key rendered as nothing and the message named no secret at all -- the exact "opaque error naming no secret" outcome the empty-key guard was added to prevent; all three warning sites (ecs-task-runner.ts,ecs-network.ts, anddocker-runner.tssince issue #2184) now render keys withJSON.stringify(which also escapes control characters, so a hostile secret NAME can no longer forge a log line break). Only the CONTAINER site is fenced -- reverting it leaves a test red; the SIDECAR warning is unreachable by construction (sidecarEnvcarries four hardcoded literals andSENSITIVE_ENV_KEYSis disjoint from the denylist, which a constants test pins), so nothing asserts it and reverting that half stays green. Stated rather than implied.isMalformedEnvKeyis now literally the complement of a singleWELL_FORMED_ENV_KEY = /^[^=\0]+$/rather than three||-ed bad-shape clauses, so a further bad spelling needs no further clause (the repo's enumerate-bad-shapes-loses-the-race lesson, which the previous JSDoc claimed while the body still enumerated); behaviour is unchanged, verified equivalent across empty /=/ NUL / newline / carriage-return / leading- and trailing-space names. The positive rule makes a NEW failure direction possible that the bad-shape clauses structurally could not have -- OVER-refusal -- so a table of legitimate names (lowercase, dotted, hyphenated, mixed-case, leading-digit) now asserts they are DELIVERED: a tightened rule such as/^[A-Z_][A-Z0-9_]*$/silently dropped every one of them with the whole suite green before that table existed. The sidecar warning deliberately does NOT name the malformed cause:partitionSensitiveEnvonly collides keys in the sensitive set, and the sidecar passesSENSITIVE_ENV_KEYS(three well-formed AWS credential names), so a malformed name is unreachable there. Records thelocal-run-task-from-statereal-AWS run that gated issue #2183's merge in the integ ledger.cdkd local run-taskno longer leaks AWS credentials or resolved Secrets Manager / SSM secret values on thedocker runargv (issue #2183) --src/local/ecs-task-runner.ts,src/local/ecs-network.ts,src/utils/docker-cmd.ts, plus tests. The ECS local-run path wrote every sensitive env var as-e KEY=value, exposing it in/proc/<pid>/cmdlineto any local process; the LambdarunDetachedpath already avoided this via docker's value-less-e KEYform (value read from the spawn env). Sensitive keys -- the AWS credential set plus each resolved secret NAME -- are now emitted value-less and the value travels through the spawnenv.--env-filewas rejected because docker truncates a multi-line value (PEM keys) at the first newline while the env channel preserves it.dockerSpawnEnvWithSensitivekeeps the docker client's own critical vars authoritative (matched CASE-INSENSITIVELY for Windows), so a user-controlled secret NAME cannot hijack the client: the exact denylist covers connection / TLS / proxy vars,PATH/HOME/USERPROFILE, the trust vars (SSL_CERT_FILE/SSL_CERT_DIR/GODEBUG), the non-prefixed loader vars (GLIBC_TUNABLES/GCONV_PATH/BASH_ENV), the closed ssh exec/trust set enumerated EXACTLY (SSH_AUTH_SOCK/SSH_ASKPASS/SSH_ASKPASS_REQUIRE/SSH_SK_HELPER/SSH_SK_PROVIDER/SSH_PKCS11_HELPER/SSH_AGENT_PID— anSSH_PREFIX was the #2186 round-3 blocker, since it dropped realistic secrets like GitLab CI'sSSH_PRIVATE_KEY), and the AWS credential-helper family the client'sdocker-credential-ecr-loginreads with the operator's real credentials in scope (AWS_ENDPOINT_URL/AWS_CA_BUNDLE/AWS_PROFILE/AWS_CONFIG_FILE/AWS_SHARED_CREDENTIALS_FILE/AWS_WEB_IDENTITY_TOKEN_FILE/AWS_CONTAINER_CREDENTIALS_FULL_URI/AWS_ROLE_ARN/AWS_EC2_METADATA_SERVICE_ENDPOINT); theLD_*/DYLD_*dynamic-loader families and the per-serviceAWS_ENDPOINT_URL_*endpoint family (aws-sdk-go-v2 honours it, soAWS_ENDPOINT_URL_ECRwould walk around the exact entry — #2186 round 4) are additionally caught by PREFIX inisDockerClientEnvKey, fail-closed on the whole prefix. A sensitive key of a MALFORMED shape (empty, or containing=/ NUL —isMalformedEnvKey, defined POSITIVELY as the complement of a good env name rather than by enumerating bad spellings) is refused fail-closed on the same collision path (#2186 rounds 4-5): the denylist matches the whole key while the OS parses the environ NAME as everything before the first=, so a secret namedPATH=/tmp/evil:would otherwise poison the docker client's ownPATH(the poisoned duplicate wins — measured) and hand code execution to whatever the client execs off it, and an empty key would emit an opaque-e '';dockerSpawnEnvWithSensitivecarries a belt-and-braces copy of the guard, which the follow-up #2187 is set to adopt forrunDetached(issue #2184 — that path spread its passthrough env directly until issue #2184 closed it). Case-insensitive Windows env handling (a case-differing host alias of a passthrough key; two sensitive keys differing only by case) is deliberately NOT done here — it needs the Windows-critical vars on the denylist first and a real Windows execution path (CI is ubuntu-only), tracked in issue #2190. The argv split lives in the sharedpartitionSensitiveEnv, which -- per the #2186 review blocker -- drops a colliding key ENTIRELY (no-eflag at all): emitting a value-less-e DOCKER_HOSTfor a key the spawn env refuses to set would make docker resolve it against the client's own env and hand the container the HOST's value (the host'sHTTPS_PROXYcredential, or a macOSPATHinside a Linux image). The dropped key is reported incollisionsand WARNED. Tests: per-container secret isolation (two containers, two secrets, each carries only its own -- a cross-wired lookup was previously invisible), the collision-drops-entirely path against a container secret namedDOCKER_HOST, a table-driven membership+behaviour fence over all 47DOCKER_CLIENT_ENV_KEYSspelled as LITERALS (a set-driven table cannot detect a deletion) plus the same literal fence overDOCKER_CLIENT_ENV_PREFIXES(exported for exactly this — a hardcoded copy made the anti-shadowing fence one-directional), the collisionlogger.warn, and confluence-point controls; all mutation-probed. Follow-ups: the siblingrunDetachedhijack (issue #2184) and the podman / containerd connection vars (issue #2188).cdkd local invoke(runDetached) closes the same docker-client hijack as go-to-k/cdkd#2183, for the Lambda / AgentCore path (issue #2184) --src/local/docker-runner.ts, plus tests.runDetachedalready kept secret VALUES off the argv (value-less-e KEY+ spawn env), but a template-controlled SecureString NAME (--from-cfn-stack) colliding with a docker-client var (DOCKER_HOSTetc.) still emitted a value-less-e KEYthat docker resolved against the client's OWN env, handing the container the host's value. It now partitions through the sharedpartitionSensitiveEnv(from go-to-k/cdkd#2183), which drops a colliding key ENTIRELY and reports it incollisionsfor the warn. Tests: asensitiveEnvKeys-extended run with aDOCKER_HOSTsecret asserts no-eflag for it, the client's ownDOCKER_HOSTstays in the spawn env, a non-colliding secret still reaches the container, and the drop is WARNED; mutation-probed.
Recently Implemented (2026-08-25):
- The exports index and the
Fn::ImportValuestate scan now bind only to names a producer actually EXPORTS, so a same-named plain Output in another stack can no longer shadow a real export (issue #2193) --src/types/state.ts(schema v9),src/deployment/deploy-engine.ts,src/state/export-index-store.ts,src/deployment/intrinsic-function-resolver.ts,src/cli/commands/local-state-loader.ts,src/cli/commands/import.ts,src/cli/commands/destroy-runner.ts,docs/state-management.md,docs/cross-stack-references.md,.claude/rules/state-schema.md,tests/integration/schema-v8-to-v9-migration/**(new).state.outputshas always held plain Output names ANDExport.Namealiases in one bag, and nothing in the record said which was which -- so the exports index (updateForStack, fed the whole bag by the deploy engine, andrebuildFromStateBackend) the resolver'sstate.jsonfallback scan (exportName in state.outputs), and thecdkd localcommands'--from-statefallback scan inlocal-state-loader.ts(the same spelling) treated EVERY key as an export. A plainCfnOutput('VpcId')in an unrelated stack was indexed as the producer of exportVpcId,applyStackUpdateoverwrote the real producer's entry with no check and no log (last writer wins), and a consumer'sFn::ImportValue: VpcIdbound to whichever stack deployed most recently: a deterministic silent wrong value, and a value CloudFormation would never hand out, since its export namespace is separate from its output names and it refuses a second producer of one name. The intra-stack version of the collision was already refused by issue #1919'sisExportAliasCollision; the cross-stack version had no guard, and no reader COULD have one, because the record did not carry the information. Schema v9 addsStackState.exportNames-- exactly the alias keysresolveOutputswrote, deduplicated -- and ONE predicate,importableOutputKeys(state), that all four readers now go through. The discriminator is the FIELD rather than the version:undefined(a pre-v9 record, or a v9 partial save carrying a pre-v9 bag forward) keeps the legacy every-key rule so no existing reference breaks on upgrade, and[]means the stack is known to export nothing -- so unlikeimports/outputReadsan empty array is WRITTEN, never omitted. A save that re-resolves outputs (success path, no-change refresh) always writes the set; the five failure-path saves that carryoutputs: currentState.outputsforward spreadexportNamesCarriedFrom(currentState)beside it, and the existing source-scan fence indeploy-engine-cross-stack-read-writers.test.tsnow derives BOTH rules from the file rather than trusting an enumeration. The no-change deploy path persists the set and re-feeds the index with the exports only whenever the EFFECTIVE export set changed while the outputs values did not (animportableOutputKeys(currentState)-vs-resolved-set comparison, kept out of the outputs-value-changed branch) -- this both evicts the plain-name entries a pre-v9 deploy published (a producer whose template never changes would otherwise pollute the index forever) AND handles a self-named export toggled on a v9 record, whereExport.Nameequal to the output key rewrites the same key with the same value so the bag is byte-equal butexportNamesflips (the #2194 review blocker: without it, adding such an export never lands and the consumer's Fn::ImportValue hard-fails; removing it leaves a phantom export served forever). Rebuild keeps the higher-lastModifiedproducer on a name collision so the duplicate-producer warning's "deployed last" wording holds on that arm too. Two stacks both exporting one name keep the index's latest-writer policy but now WARN on update and on rebuild (the warning could not exist before v9: it would have fired on every plain-output collision). The version bump rather than a bare optional field is deliberate: a v8 binary rewriting a v9 record would silently DROP the field and regress the stack to the every-key rule, and the bump makes it fail loudly instead, which is the contract every prior bump relied on.cdkd importover an existing record carries the set with the outputs it already carried. Tests: a v8→v9 unit contract intests/unit/state/schema-v8-to-v9-migration.test.ts; the index store's ingestion + collision-warning cases; the resolver scan's and the local loader scan's decoy-first / plain-name-refused / legacy-still-served cases; the destroy snapshot'sexportNames: []beside its emptied bag; the deploy engine's persisted set, no-change backfill, and failure-path carry (absent stays absent, never[]); six existing collision tests re-pinned to "a refused alias publishes NOTHING to the index". The newschema-v8-to-v9-migrationinteg deploys Producer (exportsX), Decoy (plain output namedX, different value) and Consumer (Fn::ImportValue X) under the last v8 binary and asserts the Consumer received the DECOY's value -- the bug on real AWS -- then re-deploys under v9 and assertsversion: 9, the producer's set, the decoy's[], the transitional warning, and the Consumer rebound to the PRODUCER's value.
Recently Implemented (2026-08-23):
- The provider-side secret walk is now ONE shared function, the composite-id refusal masks the value it quotes, and the cross-provider audit issue #2176 asked for is recorded (issue #2176) --
src/provisioning/masked-retry-logger.ts,src/provisioning/composite-id.ts,src/provisioning/providers/{ssm-parameter,dynamodb-table,sns-topic,cognito,elbv2}-provider.ts,docs/provider-development.md,.claude/rules/providers.md. cdkd resolves{{resolve:secretsmanager:...}}and{{resolve:ssm:...}}(including an SSMSecureString, which is classified secret by parameter TYPE rather than by spelling) BEFORE calling a provider, so a property value is PLAINTEXT by the time a provider interpolates it into a message. The literalssm-secure:spelling is deliberately NOT resolved by cdkd and is out of scope -- verified live during this work, where anssm-securereference loggedUnsupported dynamic reference serviceand reached the provider unresolved, matchingdocs/scenario-coverage.md.DeployEnginemasks its three sinks -- the error line, the durabledeployments/{runId}.jsonlevent viamaskSecretsInEvent, and the re-thrown cause viamaskSecretsInError-- but only at the MESSAGE level, and that has exactly two holes, both measured on this tree rather than argued:JSON.stringifyescapes"/\/ newlines, so{"user":"admin","pw":"hunter2"}interpolated as${JSON.stringify(value)}came through a message-level mask COMPLETELY unchanged; and a finished message is longer than the value inside it, so it reaches onlymaskSecretsInText's SUBSTRING arm and a 3-character secret survivedValue 'abc' at 'pin' failed ...intact. Masking the leaves first closes both. The walk that does that had been copied SIX times (elbv2,cognito,sns-topic,dynamodb-table,dynamodb-globaltable,apigatewayv2) --elbv2-provider.tscarried a standing note that "a THIRD site is the point at which this should move into../masked-retry-logger.ts", which had fired and been missed -- and FOUR of the six had already DIVERGED by losing the depth cap. All six now delegate to one exportedmaskDeep, whose cap SUBSTITUTES a mask marker for a subtree it declines to descend into rather than returning it raw -- returning it raw is silent disclosure, substituting is silent truncation. Two of the six were nearly missed a second time inside this very fix, because the sweep grepped for the known copies' spellings (maskDeep,MASK_WALK_MAX_DEPTH) rather than the walk's SHAPE; the survivors are spelledmaskLeaf/maskLeafValueand declare no named constant. Three independent reviewers caught that, which is the same failure mode #2176 names ("a per-provider fix that misses siblings just moves the hole") landing inside the fix for it.compositeIdSeparatorRefusal/packCompositeIdquote the offending segment VALUE back, on a THROWN arm reaching the durable store and anonRefusalarm reaching the terminal; they now take a masker and mask that value RAW (reaching the whole-value arm, which has no length floor) -- fixed once rather than per caller, and THREADED at all 15 deploy-path call sites across seven providers (glue,route53,s3-tables,appsync,ec2,apigateway,lambda-event-invoke-config). The remaining 7 of the 22 sites areimport()paths, which have noCreateContextto thread and no secret bag to build one from; they keep the back-compatible identity default. Two of the 15 (ec2-provider.ts'screateRoute/createSecurityGroupIngress) were threaded a review round later than their siblings and were briefly mis-described as import paths -- their helpers take a CALLBACK rather than a context, so the thread stopped one layer short of the dispatcher that holds one, andcreateSecurityGroupIngress'sipProtocolis the one segment the file documents as accepting an arbitrary template string. Their UPDATE twins are threaded too, via a newUpdateContextonEC2Provider.update: those arms delete-and-RECREATE, so the masker has to reach the re-create that packs the id, and.claude/rules/providers.mdnotes the update arm is the one a multi-destination bag actually reaches -- closing only the create half would have left the reachable half open.ssm-parameter-provider.tsis threaded as the reference for the one-masked-sink shape, chosen because it is the provider whoseValueproperty IS the secret (that property reaches no message site, and neither doessecretsmanager-secret-provider.ts'sSecretString; both were checked specifically).dynamodb-table-provider.tshad a singlewarncall masking one argument and leaving the one beside it raw -- the drift a per-site rule produces, which is why the docs now prescribe a sink. Stated precisely because the first draft over-claimed it: that particular argument is a no-op BY CONSTRUCTION (coerceWarmThroughputadmits onlytoFiniteNumberresults, so the walk can never change it), and it is masked for consistency and future-proofing rather than to close a leak. The audit itself is the part the issue called the actual work: everycreate()/update()message site across all ~80 providers was traced back to thepropertiesbag, giving ~256 sites in the provider's ownthis.logger.*(which NO engine sink covers), ~50 thrown sites (message-level covered, residual only) and ~244 AWS-echo sites; the per-site remainder is issue #2177 with its counts and the honest risk note that almost all of them are resource names rather than values users put a dynamic reference in. A mechanical fence was drafted and CALIBRATED against the tree, which is what showed the naive syntactic rule false-positives on correct code (asg-provider.tsandcloudfront-distribution-provider.tsmask each element UPSTREAM of theJSON.stringifycall); it needs the dataflow-aware compiler-API shape the two existing critics use, tracked as issue #2178. Also corrected: #2176's premise named ACM as persisting a resolved secret todeployments/*.jsonl, and ACM'screate()interpolates no property value at all --perResourceSecretsis populated BEFOREresolve()and before the provider call, so that event IS message-level masked and the residual there is the sub-4-character case only. Tests: 19 new cases acrosstests/unit/provisioning/masked-retry-logger-mask-deep.test.ts,tests/unit/provisioning/composite-id-secret-masking.test.tsandtests/unit/provisioning/ssm-parameter-provider-masked-warn.test.ts, each asserting the POSITIVE mask marker rather than only the plaintext's absence (which any failure also satisfies), with a negative control that a non-secret value is left alone; both mutation-probed -- removing the composite-id mask reds 4 of 6, disablingmaskDeep's leaf masking reds 18 cases across the converged providers' own existing suites, reverting the depth cap to failing open reds 2, and reinstating the exactupdate()sink bypass a reviewer found reds the SSM fence written for it. - An ACM certificate whose
ISSUEDwait fails is now DELETED instead of orphaned, so repeated attempts stop accumulating certificates (issue #2169) --src/provisioning/providers/acm-certificate-provider.ts,src/provisioning/providers/idempotency-token.ts,tests/integration/acm-certificate/verify.sh,docs/{troubleshooting,cli-reference,provider-development}.md,.claude/rules/providers.md.RequestCertificatematerializes the certificate immediately andcreate()then polls forISSUED(60 x 10s), a wait a DNS-validated certificate whose validation records are not live yet exhausts by construction. Both throw paths threw a plainErrornaming the ARN only in the message TEXT, andcreate()'s catch passedundefinedforphysicalId, so the certificate stayed in AWS with nothing naming it: absent from state, invisible tocdkd state show, unreachable bycdkd destroy, and re-requested from scratch by the nextcdkd deploy-- one more orphan per attempt.create()now retires the certificate it requested in its own catch and re-throws the ORIGINAL error, with the ARN on it asphysicalId(the reporter's own ask, and a pathcreate()'s wrap used to drop for a non-CdkdErrorfailure after the request). A cleanup that FAILS appends a line naming the survivor and the exactaws acm delete-certificatecommand rather than replacing the diagnosis. The replacement path is covered by the same change, sinceupdate()re-creates through thiscreate(): a replacement whose wait fails now aborts with the OLD certificate still live, still in state, and nothing orphaned. Deleting rather than KEEPING the remnant is the load-bearing decision, and it was the reverse of this fix's first shape. Recording the certificate in state (the shape the issue's own wording suggests) does not work: the recorded properties ARE the template's, so the next deploy diffs the resourceNO_CHANGE,cdkd deployprints "No changes detected" and exits 0 over a certificate that is still unusable, and the CloudFront / ALB consuming it fails with no explanation -- a loud failure turned silent, which is worse than the orphan. Three independent review agents caught that before it shipped. Deleting is safe for ACM specifically, from AWS's own docs rather than by inference: the DNS validation CNAME is derived from the domain and the account, not from the certificate, and you can "replace a deleted certificate" without repeating validation -- so the CNAMEs a user adds after the failure validate the retry's certificate. A user who WANTS the certificate to outlive a failed deploy already hasCDKD_NO_WAIT=true, which returns success with it recorded in state. Separately,RequestCertificatenow sends a retry-stableIdempotencyToken(#2039's helper, which gained acharset: 'alphanumeric'option because ACM documentsPattern: \w+and a 32-char max that the defaultcdkd-<hex>spelling violates), closing the in-process window where a transient 5xx whose request had landed madewithRetrymint a second certificate; the token is RELEASED when the cleanup deleted the certificate (ACM would otherwise answer the retry with the deleted ARN) and KEPT when the cleanup failed (there the survivor is what a retry should get). One latent bug had to be fixed for the delete to be safe, and it is the kind that only becomes critical in combination:waitForCertificateIssuedlatchedvalidationOptionsLoggedon having CALLED the printer, butlogValidationOptionsemits a record line only for a domain that already has aResourceRecord. The firstDescribeCertificatefires seconds afterRequestCertificate, when ACM commonly has not filled that in yet -- so the header printed with nothing under it, the latch closed, and the CNAMEs were never shown on any later poll. That was survivable while the certificate outlived the failed deploy (the user read the records from the ACM console); with the certificate now deleted, the poll output is the ONLY copy of them and losing it is a dead end. The printer now reports whether it emitted anything and the latch follows that. Related message fix: the timeout error used to advise raising--resource-timeout, which cannot make this wait longer -- it is the engine's deadline wrapped around the provider and can only cut the 10-minute poll cap short. It now namesCDKD_ACM_POLL_ATTEMPTS/CDKD_ACM_POLL_INTERVAL_MS, anddocs/troubleshooting.mdsays so too. Tests: 23 new cases intests/unit/provisioning/acm-certificate-provider.test.tsand 2 intests/unit/provisioning/idempotency-token.test.ts, each mutation-probed (17 mutations across three review rounds; the round-2 pass found the latch fix above shipping unfenced and aphysicalIdassertion that passed with the fix reverted); plus a new phase 0 in the ACM integ that forces a real timeout in seconds viaCDKD_ACM_POLL_ATTEMPTS/CDKD_ACM_POLL_INTERVAL_MSand asserts the ACCOUNT holds zero fixture certificates after one failed deploy and still zero after two -- counting the account rather than reading state on purpose, since state is exactly what the pre-fix code failed to write. - The stack lock is now RENEWED while an operation runs, and released conditionally, so an operation outliving its TTL no longer loses mutual exclusion (issue #2168) --
src/state/lock-manager.ts,docs/state-management.md,docs/troubleshooting.md,tests/unit/state/lock-renewal.test.ts(new),tests/integration/stack-lock-renewal/**(new). The lock's TTL was stamped once at acquisition and never updated --grepfound no heartbeat on any path -- so the 30-minute default measured how long the OPERATION had been running rather than how long its owner had been silent. Any operation slower than that silently stopped being mutually exclusive while it was still running, which needs nothing exotic to reach:AWS::FSx::FileSystem,AWS::EMR::Clusterand Custom Resources each wait up to an hour on their own, and a large enough stack exceeds 30 minutes in aggregate regardless of resource type. Worse, the damage cascaded:acquireLocktreats an expired foreign lock as free and reaps it, so a second process took over while the first was still writing; the first then finished and, becausereleaseLockwas an owner-blind unconditionalDeleteObject, deleted the SECOND process's lock, letting a third in. The holder now re-writesexpiresAtat most every 2 minutes (or every quarter of the TTL, whichever is shorter) via a conditionalPutObjectcarryingIfMatchwith the ETag it last wrote, so the default TTL tolerates fourteen consecutive missed renewals while a process that has already LOST the lock cannot resurrect its own expiry on top of the new owner's; the heartbeat isunrefed, so it can never hold acdkdprocess open.releaseLockcarries the sameIfMatch, and aPreconditionFailedthere means the lock present is somebody else's -- cdkd leaves it and warns rather than raising, since the operation has already finished and the caller has nothing to do about it. Any other failure falls back to the unconditional delete, so a bucket policy denying thes3:GetObjectan ETag-conditional delete requires cannot strand a lock;cdkd force-unlockstays unconditional by contract, since it exists to remove a lock the caller does not own. The expired-lock takeover inacquireLockis now conditional too, on the exact bytes it judged expired -- with renewal live, an unconditional delete there would discard a lock whose renewal merely lost a race with the read -- and it logs atwarnnaming the previous owner, because a lock that reaches its deadline now genuinely means an absent owner and, on the remaining chance it does not, two writers are about to share a stack. All 20 added guards are individually mutation-probed; three came back GREEN on the first pass and each was a real gap rather than a missing assertion (two guards whose effect was visible only on the TIMER, and one probe that was itself a no-op becauseforceReleaseLockhad already dropped the map entry the mutation read). Independent review then found that the first cut of the conditional release re-opened the hole it closes: it dropped the condition for any failure that was not 412/404, and S3 answers a concurrent operation on the key with409while a503may mean the conditional delete already SUCCEEDED with the response lost -- so an unconditional retry there deletes whichever lock exists by then, which under load is the NEW owner's. The fallback is now confined to the one class that says nothing about ownership: the endpoint or the policy will not EVALUATE the condition (403, since an ETag-conditional delete additionally needss3:GetObject;501from an S3-compatible endpoint that has not implemented the header). Everything else raises, as release always has. Five further defects of the same family came out of that round:isForeignLockErrormatched only the error NAME, so a bareS3ServiceExceptioncarrying 412 skipped "leave it alone" and reached the destructive fallback;isGoneErroracceptedNoSuchBucket-- also a 404 -- as "the lock is gone", so one bucket-level error would kill a heartbeat and then refuse to release a lock still held; a SECONDreleaseLockfor the same key was owner-blind (reachable, because the force-quit paths fire an un-awaited release while the mainfinallymay be mid-release), and the entry is now tombstoned rather than dropped; a renewal returning no ETag CLEARED the cached one, silently making the next release unconditional -- the opposite of what its own comment claimed -- and now re-reads to recover the handle and otherwise keeps the last known-good value; andisLockExpiredtrusted anexpiresAtthat arrives from the bucket unvalidated, soInfinity/NaN/ a string pinned the stack permanently (a non-finite deadline now counts as expired, which grants no new power and is the recoverable direction). A renewal 412 is also no longer taken at face value -- a conditional PUT S3 applied but whose response was lost leaves the cached ETag one version behind, so cdkd reads once and adopts the object when it is byte-for-byte its own write, rather than declaring a lock it still owns lost. Across both rounds all 45 added guards are individually mutation-probed; the second round's own probes caught four clauses of one conjunction that a single "somebody else" fixture left unfenced -- this repo's own recorded failure of a fixture that trips every clause at once fencing none.
A third round on that fix delta found one more regression of its own making and seven smaller ones. Narrowing the fallback meant releaseLock now RAISES where it used to silently succeed -- and destroy-runner's main finally was the one call site of thirteen that did not wrap it, so a throttled release would have replaced a real destroy error and, on a successful destroy, aborted a cdkd destroy --all run at the first stack over a lock that lapses on its own. It now warns like its three siblings and deploy-engine.ts, and the pre-existing test that pinned the old propagate-the-throw contract was moved to assert what it was written to fence (the SIGINT handler not leaking) rather than a throw that no longer happens. The 403 branch also re-checks ownership by hand before dropping the condition, because S3 authorizes BEFORE it evaluates a precondition: a policy scoping s3:GetObject away from lock.json turns a genuine 412 into a 403, so the escape hatch would have deleted the lock of whoever took over -- it now skips the read while its own deadline is still ahead and refuses only on a lock it can read AND positively attribute to somebody else. The remaining six: the expired-lock takeover delete had no such escape hatch at all, so the very policy the predicate exists for made an expired lock unreclaimable except via force-unlock; the 412-status backstop had been applied to one of three call sites; the release tombstone was claimed AFTER two awaits (so it did not close the force-quit race it was added for) and BEFORE the delete (so a FAILED release recorded itself as done), and is now an in-flight promise claimed synchronously, which gives a second caller the first one's actual outcome; the keep-stale-ETag path blamed "another cdkd process" for a conflict with this process's OWN write; adoptOwnWrite compared a displaySafe-sanitized owner against a raw one, so a $USER or hostname carrying a stripped codepoint would make adoption permanently impossible and the process would disown its own lock; and warnedPastExpiry never re-armed, so a second expiry episode was silent. Test review then found that isConditionUnsupportedError's five clauses were all unfenced for the same reason adoptOwnWrite's four had been -- every fixture tripped several clauses at once -- on the one predicate that authorizes dropping the ownership condition; each clause now has a fixture that trips only it. That failure mode recurred three times in this work, which is why the disjoint-fixture helper is now named and commented as the rule rather than the exception. All 63 guards across the three rounds are individually mutation-probed.
A fourth round on that delta found four more, three of them in the guard the third round had just added. The ownership re-check skipped its read while the process's own deadline was still ahead -- unsound, because forceReleaseLock deletes REGARDLESS of expiry (that is its contract), so a user running cdkd force-unlock mid-operation is a legitimate takeover no deadline can rule out, and cross-machine clock skew reaches the same state with nobody running anything. It also answered "proceed" when the read FAILED, which left it inert in its own primary case: the documented trigger for the 403 is a policy granting s3:DeleteObject without the s3:GetObject a conditional delete needs, under which the check's own read fails too. And the escape hatch the third round added to the expired-lock TAKEOVER was removed again: the IfMatch there is what makes concurrent reaping safe -- first wins, second gets a 412 and reports contention -- so dropping it lets BOTH reapers delete-then-acquire and leaves two holders. Separately, releaseLock had dropped async when it gained its synchronous claim, which let a throw out on the CALLER's stack; ten call sites attach only a .catch(), two of them void releaseLock(...).catch(...) inside a SIGINT force-quit handler where a synchronous throw is an uncaughtException that kills the handler before its recovery banner prints -- and stdout is a measured synchronous EPIPE source in this repo under --verbose | head. An async body runs synchronously to its first await, so restoring async keeps the claim synchronous while turning every throw into a rejection. Test review then found that the two tests rewritten in round 3 had been GUTTED by round 3's own fix: with the release caught, nothing in that inner try could throw, so the finally holding the SIGINT-handler removal became indistinguishable from code inlined at the end of the try -- a mutation doing exactly that passed all 2827 tests/unit/cli tests. It is now fenced through the EPIPE path, which is the only live one left.
The new stack-lock-renewal integ has two arms because neither substitutes for the other: a probe drives cdkd's own LockManager against the real VERSIONED state bucket, which is what settles the S3-side premises the unit suite has to mock away (a PutObject ETag being accepted verbatim as an If-Match, and If-Match on DeleteObject being evaluated against the current version of a versioned object), reproduces the cascade itself, and settles empirically what S3 answers for a conditional delete against an object that is already gone (NoSuchKey / 404, not 412 -- the SDK model documents that outcome for IfMatchLastModifiedTime / IfMatchSize but not for plain IfMatch); and a real cdkd deploy whose custom resource sleeps past the renewal cap, with the lock polled throughout, settles the CLI-side premises no probe can -- that the CLI renews during a deploy, and that the process still EXITS afterwards.
Recently Implemented (2026-08-22):
The lock-contention refusal now names WHO holds the lock, and its recovery command resolves to the lock it is actually about (issues #2170 / #2171) --
src/state/lock-contention-message.ts(new),src/cli/commands/destroy-runner.ts,src/cli/commands/import.ts,src/cli/commands/orphan.ts,src/cli/commands/state.ts,src/cli/commands/drift.ts,src/cli/commands/export.ts,src/deployment/deploy-engine.ts,docs/troubleshooting.md. Follow-up to #2161, which made nine sites refuse on a held lock but left the refusal itself thin. Three things were wrong with it and only the first is cosmetic. The message asked the user to decide "is another process active?" while printing no owner, no operation and no expiry -- and the population that reaches it makes that consequential, becauseacquireLockreaps an EXPIRED foreign lock and retries, so every message a user sees describes a LIVE lock, in practice a runningcdkd deploy; the likeliest response,force-unlockthen re-run, reproduces #2161's exact harm by hand. The recovery command carried only--stack-region, butcdkd force-unlockre-resolves the state bucket from the AMBIENT profile, so aftercdkd destroy --profile prodthe suggested command resolved the DEFAULT profile's account and would force-delete a same-named stack's lock in the wrong AWS account entirely. And the nine sites had drifted to three spellings of the same sentence, so a user grepping CI logs for one found two of three. All nine now build their text in one place, which is also what makes the holder lookup a single best-effortgetLockInforather than nine of them; a failed or absent read degrades to the previous evidence-free wording rather than replacing the contention with an S3 error. #2171 closes three pre-existing siblings of the ignored-lock family that a security-lens review of #2165 turned up:destroydeleted a 0-resource stack's state record with NO lock at all -- a record reads as empty for exactly one interval that is not idle, the start of a concurrent deploy -- and now takes the lock and RE-READS, since emptiness has to be re-established under the lock rather than inherited from the caller's snapshot;cdkd state orphanforce-releases whatever lock is present, which stays deliberate (a stuck lock must never make a state record unremovable) but now WARNS naming the owner when the one it destroys is still live; anddeploy-engine.tsstill had the EPIPE lock-strand that #2161 fixed indestroy-runner.ts, whererenderer.start()sat after the acquire and outside the releasingtry, socdkd deploy | headstranded the lock for its full TTL. Thedrift --revertwindow between its acquire and itstryis closed the same way. Every guard is fenced by a mutation-probed test, and the #1348 handler-ordering pin was widened from FILE to SITE -- it caught this change's own first cut, which added a lock acquisition with no SIGINT handler, and a file-levelindexOfwould have gone quiet again as soon as the two sites happened to be ordered coincidentally.cdkd destroy/import/orphan/drift --accept/drift --revert/state refresh-observednow FAIL FAST when another process holds the stack lock, instead of running under it and then deleting that process's lock (issue #2161) --src/cli/commands/destroy-runner.ts,src/cli/commands/import.ts,src/cli/commands/orphan.ts,src/cli/commands/state.ts,src/cli/commands/drift.ts,src/cli/commands/export.ts,docs/troubleshooting.md.LockManager.acquireLockreturnsPromise<boolean>and returnsfalse-- it does NOT throw -- when a live, non-expired lock is already held by another owner. Seven call sitesawaited it and DISCARDED the boolean, so the surroundingtry/catchnever fired and each command proceeded as though it had acquired. BecausereleaseLockis an owner-blind unconditionalDeleteObject, the command then deleted the OTHER process's lock on the way out. The worst case wasdestroy: with a concurrentcdkd deployholding the lock, it deleted the live AWS resources, deletedstate.json, and deleted the deploy's lock -- reproduced against real AWS in the issue.cdkd deploywas never affected (it usesacquireLockWithRetry, which checks the boolean and throws), and top-levelcdkd exportwas already correct. Each site now checks the boolean and throws before anylockHeldflag, lock-releasingtry, state write, or provider call, so the foreign lock is left untouched; the message namescdkd force-unlock <stack> --stack-region <region>, the region-qualified form, because a first-time import and a<parent>~<Child>nested child have no state record forforce-unlockto infer the region from. Two consequences worth stating:cdkd import --dry-runnow aborts on contention (import deliberately takes the lock even in dry-run so its plan cannot lie about live AWS, unlikeorphan --dry-runwhich skips acquisition), anddestroy --allstops at the first contended stack rather than continuing. Separately, the fix maderunDestroyForStackthrow EARLIER than thetry/finallythat restored the process-globalAWS_REGION/ AWS clients for a cross-region stack, which exposed a family of pre-existing leaks at every exit before thatfinally; all of them now route through one idempotentrestoreBaseRegionAndClients()helper (cross-region setup failure, lock-acquisition failure, strong-ref refusal, a rejectingreleaseLock, a throwingdestroy(), and a throwingrenderer.start()), each fenced by a mutation-probed test in the newtests/unit/cli/destroy-runner-lock-contention.test.ts. Contributed by @nix-tkobayashi.
Recently Implemented (2026-08-21):
cdkd scrubno longer refuses two classes of secret reference it can now handle: an ASSEMBLED one, and a producer that declares an expression in anFn::Ifbranch the deployment did not take (issues #2157 and #2150) --src/cli/commands/scrub.ts. Both refusals were correct when they shipped and both had become over-refusals; neither was ever silent, but both were UNBYPASSABLE (exit 2, no flag), so a single affected leaf made a whole stack unscrubbable while itsstate.jsonstill held plaintext. #2157 -- the assembled-reference guard now DEFERS instead of refusing.resolveForeignRegionTokens's pre-pass detects a leaf that OPENS more secret references than the shared scan found complete tokens of, or a whole token still carrying anFn::Subplaceholder; that detection is unchanged (it is now the named predicateisAssembledSecretReference, carrying the same four measured rows), but a hit returns the leaf BY IDENTITY so the PRIMARY resolver classifies the reference onceresolveSub/resolveJoinhave assembled it. Issue #2134 is what made that safe: the region question is now asked insideresolveDynamicReferences, over the COMPLETE expression, so the property the refusal bought -- a reference whose region cannot be established is never resolved in the stack's own region -- is bought downstream over strictly more information. Three of the four previously-refusing shapes still refuse, now with the resolver's ownDYNAMIC_REFERENCE_REGION_AMBIGUOUSand naming the ASSEMBLED secret (prod-db, which the pre-pass could never see); the fourth, an ARN form, is routed to the region the ARN NAMES. The unlocked shape is the one that cost something:{"Fn::Sub": ["{{resolve:secretsmanager:${SecretArn}:SecretString:password}}", {"SecretArn": "<foreign ARN>"}]}refused onmainand now scrubs, fetching from the producer's endpoint and never the consumer's. Deferring costs one property the refusal had, and it is FILED rather than fixed here (issue #2166). The refusal fired BEFORE any lookup; deferring moves the lookup insidescrubStack's best-effortcatch { logger.debug }, so a producer region answering AccessDenied -- or anFn::Subplaceholder scrub cannot evaluate, whichresolveSubwarn-and-KEEPS without throwing at all -- becomes a verbose-only line under aNo plaintext secrets foundsummary. The wrong-REGION property is genuinely preserved, and over more information; LOUDNESS on a failed lookup is not, and the complete-token spelling keeps its loudSCRUB_CROSS_REGION_SECRET_UNRESOLVED. Three rounds of review were spent trying to close it inside this change and each attempt was withdrawn, which is why the issue carries the measurements rather than the code: every attempt detected "this reference went unresolved" from OUTSIDE the resolver with a proxy, and every proxy was wrong in BOTH directions. Keying on "the resolution threw" missed the warn-and-keep shape and over-reported an unrelatedReffailure sharing the bag. Keying on "the raw leaf text survived" missed a leaf a downstream intrinsic rewrote without resolving (measured: anssm-secure:reference assembled from a resolvableFn::Subplaceholder reports zero findings while loggingUnsupported dynamic reference service), broke on JSON escaping, and fired PERMANENTLY on prose that merely mentions{{resolve:secretsmanager:-- the unactionable-refusal classSECRET_REFERENCE_OPENINGS' own doc records as unacceptable, re-opened one level out. Only the resolver knows which references it declined, so the fix belongs there and gets its own PR. Shipping without it opens no new silent class:classifyReplaySecretRegionverdicts an ARN-form tokennamed-regionregardless of evidence, so a stack with no cross-stack read on record already reached the same outcome for the same leaf. The deferral is unconditional on evidence where the refusal was gated on a foreign producer region being on record, which also removes an inconsistency rather than adding one:classifyReplaySecretRegionverdicts an ARN-form tokennamed-regionwhatever evidence it holds, so the identical leaf already resolved silently in a stack with no cross-stack read recorded -- one shape now has one behaviour. TheSCRUB_SECRET_REFERENCE_UNCLASSIFIABLEcode is retired with its only throw site,foreignProducerRegionsleavesCrossRegionSecretContext(its last reader was that gate), and theleafPathargument leavesresolveForeignRegionTokens(its only consumer was that message). #2150 --producerPublishesSecretExpression's literal scan now makes the sameFn::Ifselection its hop walk does. The scan wasJSON.stringify(subject).includes('{{resolve:'), which sees BOTH arms, so a producer whose output is{"Fn::If": ["IsProd", "{{resolve:...}}", "plain"]}deployed with the condition false verdicteddeclared; the discriminator then read the stored valueplain, found no expression, and raisedSCRUB_CROSS_STACK_PRODUCER_PLAINTEXT-- unclearable, because nocdkd scrubof that producer can turnplaininto an expression. Issue #2146 had fixed the sibling instance incollectReExportHopsand deliberately left this one, so the two halves of one question disagreed. They now share ONEselectTakenConditionalBranches, which rebuilds ontoObject.create(null)for the__proto__hazard the three sibling walks in this file already answer that way (a JSON-parsed template carries__proto__as an OWN key, and assigning it onto an object literal walks the prototype setter, dropping the key and any{{resolve:beneath it -- fail-OPEN). Applied ONCE per subject and passed to both: FALSE branch of a well-formed 3-tuple (mirroringresolveIf's unknown-condition arm, since every condition on this path belongs to another stack scrub cannot evaluate), NO branch of a malformed one. One site rather than two per consumer is load-bearing and was measured: a per-site copy insidecollectReExportHopscould be deleted with the entire suite still green, because its caller had already pruned. The residual both halves now accept is stated in code: a secret reachable only through the TRUE branch is not detected, which is that reference's pre-#2133 outcome. Tests:scrub-cross-region-secret.test.tsre-points the four cases that pinned the pre-pass refusal at the post-assembly behaviour rather than deleting them (three now assert the resolver's code and the assembled name, the ARN one asserts WHICH region was asked), adds the unlockedFn::Sub-assembled-foreign-ARN case, and re-points one negative assertion that #2157 made vacuous (it pinned the absence of a code that no longer exists, so it now asserts the pre-pass performed no lookup instead);scrub-import-value-secret.test.tsadds the #2150 trio -- untaken branch does not refuse, TAKEN branch still refuses, malformed claims no branch. Every half was mutation-probed separately against the real suite: reverting #2157 alone reds 5 cases, reverting the #2150 selection alone reds 5 (2 new plus the 3 pre-existing hop-walk ones, which is what proves thecollectReExportHopsrefactor is behaviour-identical). Four clauses that a first round of tests left INERT were then measured and fenced, each by a case that reds only under its own mutation: the predicate's COUNT clause (short-circuiting it tofalsehad left 15 496 of 15 496 green -- every other assembled fixture yields ZERO tokens, which reaches the same identity return through the empty-verdictspath, so only a leaf splicing a WHOLE token beside a split opening can tell the two apart), theObject.create(null)rebuild, the selection's ARRAY arm, and its recursion into the selected branch. A fifth case was DELETED rather than reworded: "the PRE-PASS did not fire" had pinned the absence of a code #2157 retires, and its replacement could not fail independently either, since the leaf it uses carries no{{resolve:opening and the pre-pass can never issue a lookup for it -- the case above it already discriminates, the pre-pass's own ambiguity refusal carrying a different code. Live arms, one per half, each on an EXISTING fixture. #2150 ridestests/integration/cross-stack-secret-import/: the producer gains a conditional export whoseFn::Ifcondition is a literal-falseFn::Equals(so the deployed value is always the plain branch, on every account, with no parameter to set) and the consumer reads it through its existing parameter'sDescription-- deliberately not a second resource, since every assertion there counts RESOURCE records. Step 8, which has nothing to do with conditionals, is the discriminator: onmainit exits 2 withSCRUB_CROSS_STACK_PRODUCER_PLAINTEXTand the consumer's real seeded secret is stranded. Two premise assertions guard it against going inert in either direction -- the producer's STORED value must be the untaken branch (an expression there makes the discriminator return early) and the template'sifTruearm must actually carry a{{resolve:secretsmanager:...}}expression (nothing for a both-arms scan to have found otherwise). #2157 ridestests/integration/dynamic-ref-cross-region/, which gains a THIRD stack carrying ONE assembled foreign-ARN SecureString reference and no region-less one: scrub's pre-pass refuses over the whole leaf set, so onceoutputReadsis seeded with a foreign region -- required, since the relaxed guard was gated on exactly that evidence -- the existing stacks' region-less leaves would classifyambiguousand refuse first, and the arm would measure the wrong refusal in both polarities. The new phase seeds the legacy plaintext plus that evidence, then requires rc=0 ANDScrubbed 1 resource record(s)AND the record back on its expression: rc=0 alone is what an early return also produces, and a resolution answered by the WRONG region would not match the seeded plaintext, so the count line is simultaneously the region assertion.cdkdnow decides WHICH REGION answers for a{{resolve:...}}reference AFTER the reference is assembled, so an ASSEMBLED cross-region reference is no longer fetched against the wrong regional endpoint (issue #2134) --src/deployment/intrinsic-function-resolver.ts,src/deployment/secret-region-classification.ts(new),src/cli/commands/scrub.ts. The decision used to be a PRE-PASS incdkd scrubover the RAW template leaf. A reference whose opening or whose tail is contributed by aRef/Fn::Sub/Fn::Join/Fn::FindInMappart does not EXIST in that text -- the shared scan is\{\{resolve:[^}]+\}\}, a class that cannot cross a}-- so the pre-pass found nothing to classify and returned the leaf by identity;resolveSub/resolveJointhen re-enteredresolveDynamicReferenceswith the ASSEMBLED expression on the PRIMARY resolver. A foreign ARN was therefore fetched against the stack's own regional endpoint. What that costs depends on the SPELLING, and the measurement corrected the issue's framing: for an ARN-form reference it is a HARD FAILURE, not a silent miss -- a real-AWS probe with the fix reverted gotIncorrect region in: arn:aws:ssm:us-west-2:...and the resource failed to create, because SSM validates the ARN's region against the endpoint. So the ARN half of this change turns a cross-region reference from unusable into working, rather than from wrong into right. The SILENT-miss shape the issue describes is the region-LESS spelling, where nothing in the expression says which region owns it and a same-named secret in the wrong region answers successfully -- that is the half theambiguousrefusal covers. (Measured forssm; thesecretsmanagerendpoint's behaviour on a foreign ARN was not measured here, and the unit matrix covers its ROUTING with fakes rather than its rejection semantics.)resolveDynamicReferencesnow classifies each matched token itself, which is the first point at which the complete expression exists. Anamed-regionverdict (the id is anarn:naming a different region) is delegated -- the single TOKEN, not the whole string -- toresolverForProducerRegion, the pre-existing per-region sibling cache that marks its siblingsproducerRegionGuestso a foreign region cannot pin anssmtype verdict in the process-global store (the #1933 hazard). Exactly ONE level of recursion, provably: the sibling is pinned to that region, so the same classifier compares the ARN's region against its own and returnslocal. TheambiguousREFUSAL is opt-in via a newResolverContext.producerRegions, andcdkd deploydeliberately does not supply it -- the evidence is per-STACK rather than per-reference, so arming it there would refuse the ordinary CDKsecretValueFromJsonshape in any stack that also holds one cross-region import, and templates that deploy today would stop deploying.cdkd scrubsupplies it, because for scrub a wrong-region answer is a silent miss and failing closed is the point;cdkd driftand the rollback replay keep their own pre-existing per-token classification. The ARN half needs no evidence and is therefore always armed, deploy included. The classifier moved to a LEAF module because the dependency had to flip:rollback-executor.ts(its old home) imports the resolver, so importing it back would close a cycle; every name is re-exported there, so its four importers are untouched.cdkd scrub's own pre-pass guard is KEPT but is now conservative rather than necessary -- it only over-refuses, and relaxing it is issue #2157. Two REVIEW-ROUND blockers are part of this change rather than follow-ups, because both were regressions it introduced. (a) The refusal was raised INSIDEresolveDynamicReferences, andscrubwraps each resolution pass in a best-effortcatch { logger.debug }-- so the refusal was downgraded to a verbose line, no needle was recorded, and the command reportedNo plaintext secrets foundand exited 0 over state that still held the plaintext. That is strictly WORSE than not refusing: pre-change the same shape resolved locally, produced a needle, and WAS scrubbed. It is now aDynamicReferenceRegionAmbiguousErrorre-raised by cause-chain walk at all three catches, mirroring how the pre-pass refusals are placed outside them. (b)reresolveCrossStackValuehanded the CONSUMER's context to a producer-pinned sibling, so with two or more producer regions on record the sibling re-judged a reference whose origin cdkd had just PROVEN and verdictedambiguous; asiblingContexthelper now strips the evidence at both delegation sites. Both were found by review, not by the suite -- every test drove the RESOLVER directly, so nothing exercisedscrubStack, and a reviewer's probe showed that deleting scrub's wiring entirely passed every suite. Review round 2 then found that (b)'s fix closed only half the leak, and the miss is the instructive part: it stripped the evidence by RESOLVER IDENTITY, but a producer in the CONSUMER's OWN region yields the same resolver -- so the strip did not happen and the LOCAL producer refused while the cross-region one worked, backwards, and after (a) that aborted the whole stack's scrub instead of logging a debug line. The through-line behind all three is one shape:producerRegionsis a per-STACK signal, and every defect came from consulting it where the reference's origin was ALREADY known. The gate is therefore now the real question -- is the origin known? -- rather than a proxy for it, soreresolveCrossStackValuestrips whenever it was handed a producer region and keeps the evidence only on the arm where the region genuinely is a guess. Round 3 confirmed the shape and found the last instance of it: the gate spelled that question!== undefinedwhileresolverForProducerRegionspells it!producerRegion, so the two disagreed on''-- on the fail-OPEN side, since the resolver treats an empty region as unknown and answers from the CONSUMER's region while the gate called the origin known and stripped the refusal's evidence. Reachable rather than theoretical, through either of two distinct paths in the exports index:loadIndexparses the stored file with an uncheckedJSON.parse, so aproducerRegionthe file holds arrives verbatim, andrebuildwritesref.region ?? this.region, where??passes an empty string through rather than replacing it. Both sites now use the same predicate verbatim, and a test pins it. Command-level tests now fence both, and the classifier's PLACEMENT below theskipDynamicReferencesarm is fenced too (moving it above previously survived every case). Fenced by a JOINT-distribution matrix (shape x region-form x evidence) asserting WHICH REGION WAS ASKED via the constructor-region discriminator, with two independent mutation probes -- disabling the routing reds 5 cases, disabling the refusal reds 2, disjoint sets -- plus a LOCAL control proving that the two-fetch count on a doubledFn::Joinpart is pre-existing behaviour rather than anything the delegation introduces.cdkd drift's "N resources checked" no longer counts a resource nothing was read for (issue #2141) --src/cli/commands/drift.ts. A resource whose provider does not implement the optionalreadCurrentStateis reportedunsupported(? <logicalId> (<type>), "drift unknown"), and no comparison is attempted for it at all -- yet it incrementedinspectedCount, so a stack whose single resource is unsupported printed✓ TestStack (us-east-1): no drift detected (1 resource checked, 1 unsupported). The count stated a read that never happened. This was main's pre-existing arithmetic (inspected = outcomes.length - skippedCountincluded it too), preserved deliberately by #2135 rather than changed under cover of a refactor whose PR claimed byte-equivalence with the old formula. Of the issue's two candidate remedies -- exclude the resource from the count, or keep it and rename the label to "inspected" -- the FIRST is what shipped: the wordcheckedis the one CI users grep for and it is not the half that is wrong, and--jsonalready reported the resource under its ownnotSupportedarray rather than as anything checked, so excluding it is what makes the two renderings agree. Theunsupportedarm of the exhaustivematchOutcomepass now increments nothing, joiningskipped(#323, where drift is not actionable) for the same reason from the opposite direction -- no read is POSSIBLE there, rather than no read being useful -- soinspectedCountcounts exactly the outcomes a comparison was attempted for. User-visible number change, both renderings: the stack above now printsno drift detected (0 resources checked, 1 unsupported), and the partially-compared spelling's denominator drops the same resource (1 of 3 resources fully checked, not1 of 4). Nothing is lost -- the total is still printed on the same line fromunsupported.length, and--jsonis unchanged. The⚠spelling is also REGROUPED, because excludingunsupportedbroke its arithmetic: that line states a denominator, so its parenthetical reads as a partition of it, and1 of 3 resources fully checked (2 only partially compared, 1 unsupported)sums to 4 against a stated total of 3.unsupportednow sits outside the parens --1 of 3 resources fully checked (2 only partially compared), 1 unsupported-- so the parenthetical accounts for exactly theinspected - checkedgap. The✓spelling keeps both counts inside its parens, since it states no total for them to contradict. Both render sites are fenced by a test asserting the EXACT rendered fragment, and a mutation probe restoring the increment reds both (the✓branch renders1 resource checked, the⚠branch2 of 4). The⚠test pins the WHOLE line rather than fragments, which is what fences the GROUPING: measured, reverting the regroup alone left all 32 tests in that file green, because everytoContainfragment survives it.cdkd scrubnow follows a RE-EXPORT CHAIN when deciding whether an imported export is secret-bearing, so a single-stack scrub at the end of one can no longer report success over surviving plaintext (issue #2146) --src/cli/commands/scrub.ts. This SUPERSEDES the one-template gate described in the #2133 entry below, whose account of that gate as a prior condition on the producer's own template -- and of the verdict as three-valued -- records what THAT PR shipped rather than current behaviour: the #2133 refusal gated onproducerPublishesSecretExpression, which asked ONE template -- the direct producer's -- for a literal{{resolve:. A MIDDLE stack that re-exports someone else's export has none: its output IS theFn::ImportValue. So for stack C importing B's re-export of A's secret-bearing export, the verdict wasno, the pre-pass returned early, and with B's ownstate.outputsstill holding the pre-#1899 plaintext,cdkd scrub CprintedNo plaintext secrets foundand exited 0 while C's record still held that plaintext -- the #2133 silent success reached through a re-export instead of a direct import.cdkd scrub --allmasked it, becauseorderScrubTargetsscrubs producers first and heals the chain before C is reached; the reachable population is the documented single-stackcdkd scrub <name>, which is exactly what a user runs when told to scrub one stack. The gate is now a BREADTH-FIRST WALK over(stack, key)pairs: a matched output that carries no expression is followed through itsFn::ImportValue(via an export-name -> owning-stackS index built once per scrub, keeping EVERY owner rather than the first:inferCrossStackStackDepskeeps one because it is choosing a deploy edge and either choice orders the graph, while here following the wrong twin of a duplicated export name returnsnoand reports a stack clean over surviving plaintext) and itsFn::GetStackOutput(literalStackName/OutputName) into the next producer's template, walking the WHOLE value node so a re-export wrapped in anFn::Joinis still found. TERMINATION IS A VISITED SET, not a hop cap: each pair expands deterministically so a second visit can yield nothing new, and the pair space is finite (stacks x outputs), which halts a CYCLE (two stacks re-exporting each other -- no deploy can produce one, a template can express one) without losing reach. A depth cap was rejected because it is lossy in the one direction that matters -- a chain longer than the cap returnsno, which is the silent success this walk exists to kill. WIDENING STAYS ROOT-ONLY: the all-outputs fallback fires for the direct producer alone (where the key may match nothing because that producer'sExport.Nameis an intrinsic scrub cannot reproduce); one hop up the key is a literal name read out of a template, so a miss means that template genuinely does not declare it, and widening there would refuse a consumer over an unrelated secret two hops away in a refusal no scrub of anything could clear. The verdict gained a fourth value,chained, carrying the stacks crossed, so the refusal message says the producerpublishes '<key>' by RE-EXPORTING a value that '<head>' declares from a {{resolve:...}} expressioninstead of asserting a declaration that template does not make -- and the REMEDY is now the whole chain in scrub order ('cdkd scrub <head>', then 'cdkd scrub <middle>'), because the middle stack cannot store the expression until its own producer has been scrubbed and a one-command remedy would send the operator into a second refusal. Review round added three things the first cut got wrong. (1) The hop walk descended into BOTH arms of anFn::If, breaking the mirror ofresolveIfthe pre-pass keeps deliberately -- a producer whose output is{"Fn::If": ["IsProd", {"Fn::ImportValue": "ProdSecret"}, {"Fn::ImportValue": "DevPlain"}]}, deployed in dev with a benign stored value, then refused with achainedverdict that NOcdkd scrubcan clear (nothing turns that producer's non-secret value into an expression, and there is no bypass flag), where main answeredno. The walk now takes the FALSE branch only, which isresolveIf's unknown-condition arm and the only defensible one here since every condition belongs to ANOTHER stack whose parameters scrub does not have, and a malformedFn::Ifyields no hop at all. (2) Thewidenedverdict DISCARDED its chain, so the message claimed the producer "publishes at least one output from a{{resolve:...}}expression" in exactly the case the walk had proven none of them does, and the remedy fell back to the single command the chain remedy exists to replace;viais now carried onwidenedtoo and its wording says the output RE-EXPORTS one, naming the stack that declares it. (3) The "WIDENING IS ROOT-ONLY" rationale contradicted the code, which does expand a widened root's subjects into hops: the expansion is KEPT (dropping it would make an intrinsic-Export.Nameproducer that re-exports a secret undetectable, reintroducing the silent success for the one population the widening exists for) and the comment now states the trade -- it extends #2133's accepted over-approximation by one hop, in the direction that rule calls safe. Also from the round: the verdict is memoized per(producer, key)for the whole stack scrub, sincereadOneruns once per reference OCCURRENCE and a widened root re-walked the reachable space each time. A delta review then took three more: the message wording moved intoscrubRefusalWording, where the CLAIM keys onkind(only awidenedverdict may say "could not say which", and achainedone with an emptyviano longer borrows that hedge) while WHAT it names keys on the chain; andviais de-duplicated order-preservingly — keeping the FIRST occurrence for the claim so the declaring stack stays last, and the LAST for the remedy so the direct producer stays at the end — because a chain that returns to the producer under another output key (A -> B -> A, or a self-import) is a real walk result, the visited set being keyed by(stack, key), and it rendered a remedy runningcdkd scrub Atwo or three times. Residuals, unchanged in kind and each a possible false CLEAN rather than a refusal: a producer that publishes a secret without a literal{{resolve:AND without a followable re-export (aRefto a parameter, or a re-export of an export produced outside this app); a non-literalFn::ImportValueargument or a non-literal / foreignFn::GetStackOutputslot, which cannot be resolved statically; anFn::GetStackOutputnaming this app's stack in ANOTHER region, since the index andproducerTemplatesare keyed by stack name alone; and a secret reachable only through the TRUE branch of anFn::If, per the mirror above. Each keeps the pre-#2133 outcome for that one reference. Tests:tests/unit/cli/commands/scrub-import-value-secret.test.ts-- 30 cases asserting WHICH values became needles and WHICH refusal wording was emitted, never that the command exited 0. Fourteen run the whole command over the real resolver (chain refusal naming the head and the scrub order; the same chain RESOLVING when the middle stack is scrubbed, at one hop and at two, so a blanket-refuse implementation fails half the pairs; a three-hop chain pinning thethrough 'A', 'B'list and the four-command remedy; bothFn::Ifpolarities, since the branch scrub takes must still refuse; anFn::GetStackOutputre-export; a cycle that terminates AND still finds the secret behind it; a cycle with no secret that terminates and does NOT refuse; a direct import still classifieddeclared; a widened root naming the stack up the chain that does declare the expression; and an upstream export outside the app as the documented residual). Nine call the walk DIRECTLY and seven call the refusal WORDING directly, because several of their properties are unreachable from the command surface at a sane cost: TERMINATION could otherwise only be pinned by HANGING (the walk is synchronous, so removing the visited set wedges the worker instead of failing a test, and no vitest timeout can fire -- a budgeted templates map turns that into a red assertion in 4 ms that names the defect), and theFn::GetStackOutputhop plus the non-root NO-WIDENING rule need a producer template naming a stack and output directly, the second being reachable only through the first. Mutation-probed fifteen ways across three rounds; each named defect reds a named case in milliseconds: hops disabled (the pre-#2146 gate) reds 12, the visited set removed reds both budgeted cycles, walking bothFn::Ifarms reds the untaken-branch case while leaving the taken-branch one green, droppingviafromwidenedreds the widened case, first-writer-wins reds the duplicate-export case, widening at a hop reds the no-widening case, deleting theFn::GetStackOutputbranch reds two, refusing on everychainedverdict reds the resolve case, treating anyFn::ImportValueas evidence reds the two over-approximation controls, labelling a root matchchainedreds the direct-import case, dropping either de-duplication reds a repeating-chain case, letting achainedverdict fall through to the widened hedge reds the empty-viacase, walking a malformedFn::If's children reds the malformed-shapes case, and keying the memo by producer alone reds the two-exports-one-producer case. Live arm:tests/integration/cross-stack-secret-import/grew a THIRD stack -- the existing consumer now re-exports what it imports, making it the middle link, andCdkdCrossStackSecretChainConsumerimports that re-export two hops from the secret -- plus a phase that scrubs the chain consumer ALONE in both polarities (healthy chain resolves and rewrites a seeded plaintext; middle stack'sstate.outputsseeded back to the plaintext must exit 2 with the re-export wording and the chain remedy, and must NOT be thedeclaredverdict, which would mean the middle template grew an expression and the phase had silently become a duplicate of the #2133 one).- An
Fn::ImportValue/Fn::GetStackOutputsource leaf can now be POSITION-certified, so two cross-stack reads whose plaintexts coincide no longer collapse onto one expression (issue #2059) --src/deployment/secret-redaction.ts+src/deployment/intrinsic-function-resolver.ts.intrinsicSkeletonPatternrecognises onlyFn::JoinandFn::Sub, so a cross-stack source leaf could not be positioned at all and fell to the plaintext-keyed value scan. Two leaves importing the:AWSCURRENTand:AWSPREVIOUSexports of one secret are momentarily equal during a rotation, so both were persisted holding whichever expression was recorded last, andresolveReplayPropsthen re-resolved the WRONG reference against the live resource on a rollback or acdkd drift --revert. Extending the skeleton pass could not close it, which the issue thread measured twice before this change: the skeleton is a TEXT matcher and neither intrinsic carries any text about its expression (an export NAME bears no relation to the producer's{{resolve:...}}string), whileSKELETON_WILDCARDis[^}]*and cannot cross a token's own}}, so a pure-wildcard skeleton matches zero candidates and always refuses. What was missing was a RECORDING SEAM:reresolveCrossStackValueis the only point holding both the consumer's source leaf and the whole{{resolve:...}}token the producer stored, so it now recordscanonicalKey -> expressionintorecordedCrossStackExpressions, a process-wide store owned bysecret-redaction.tsfor the same reasonrecordedSecretExpressionsis (that module is the LEAF, the resolver already imports it, and the reverse edge would close a cycle). The key comes fromcrossStackSourceKey, called by BOTH sides over the RAW intrinsic --Fn::ImportValue <exportName>andFn::GetStackOutput <stackName> <outputName> <region> <roleArn>-- so the writer's key and the persist path's key are byte-identical by construction rather than by a proof that two spellings agree; deriving the writer's from the RESOLVED names would look equivalent and is not, since the persist path holds only the unresolved template. A slot that is itself an intrinsic ({"Fn::ImportValue": {"Fn::Sub": ...}}) has no key, and the redaction path then falls back to the skeleton pass and the value scan exactly as before.redactByPath's intrinsic-object arm consults the store BEFOREpositionByIntrinsicSkeleton, under three conditions: the same condition 1 (the bag leaf's WHOLE value must be a plaintext this pass recorded); a PAIRING check that the plaintext the WRITER recorded beside the expression equals this bag; and the same condition 3 (an association THIS PASS can SEE resolved to a different plaintext is refused, which fences a bag/source misalignment on a readback walk); the extractedplaintextIndexOfis now shared by both arms so the conflicting-plaintext poisoning has one definition. The associations live in aWeakMapkeyed by the resolution pass's OWNrecordedSecretValuesbag, so a foreign entry is not merely refused — it cannot be REACHED. That scope is the safety argument, and it took two rounds to reach: a PROCESS-WIDE store is unsound here because the key is not region-qualified (anFn::ImportValuekey carries no region and anFn::GetStackOutputthat omitsRegionkeys it empty), so onecdkd deploy --allputs two stacks in two regions on ONE key —deploy.tsbuilds a resolver per stack region. A second stack whose producer still holds the PLAINTEXT (the #2133 / #2146 population, wherecarriesDynamicReferenceshort-circuits so nothing is recorded AND no conflict can be seen) was then certified with the first stack's region-pinned expression — a case the value scan gets RIGHT, so it was a NEW wrong answer rather than a missed improvement. Pairing each entry with its plaintext narrowed that and could not close it: two regions holding the SAME value (a Secrets Manager multi-region replica, a shared API key) pair happily, and "correct only while replication holds" is a property nobody declared and nothing enforces. The bag was already per-pass and already travelled from the resolver context to the redaction path (DeployEngine.perResourceSecrets,cdkd scrub'sperResourceSecrets,rollback-executor.ts'ssecrets, each storing the very object the resolver mutated), so this is a scope change rather than new plumbing and no call site grew a parameter; a caller handing the redaction path a DIFFERENT bag from the one it resolved with —cdkd state refresh-observed, whose map is empty by construction — finds no associations and falls back to the value scan, which is the direction a mismatch must fail in. TheWeakMapalso means the PLAINTEXTS these entries hold die with the pass, which retired an explicit clear that production never called. The plaintext pairing is KEPT rather than deleted now that scope covers the foreign case: against another pass it is belt-and-braces, but inside ONE pass it is still the only guard against a bag/source MISALIGNMENT, where a readback bag holds a different resource's secret while the source leaf still spells this import.recordCrossStackExpressionadditionally REFUSES anexpressionthat is not a whole{{resolve:...}}token — the two payload parameters are bothstring, so the type system cannot see a SWAPPED call, and the reader returnsexpressionto be persisted, so a swap would write a SECRET intostate.json; the invariant narrows that from "any secret" to "a token-shaped secret", which is as far as a shape test can go while issue #1917 means a plaintext can look like a token. The colliding pair this fix exists for is unaffected throughout, because both leaves are recorded by the SAME pass against the SAME plaintext, which is what "their values coincide" means. This is a POSITION certification for exactly two intrinsic spellings, never a widening of what the value scan may assume -- it never lets the scan take a source subtree, so the issue #1915 fences still hold ({Name: '', Value: 'an-unrelated-literal'}is untouched, in both its scalar and array shapes). The seam is gated on TWO tests, and neither is sufficient alone. Presence inrecordedSecretValuesproves the pass resolved this token to a usable needle — and is what stops askipDynamicReferencescomparison resolve, which leaves a known secret UNRESOLVED, from recording the token as its own "plaintext" and POISONING the key for a deploy resolve that REUSES THE SAME BAG.isSecretExpressionByVerdictOrSpellingis the test about THIS token (secretsmanagerby spelling, or anssmreference PROVEN to be aSecureString), and it is required becauserecordedSecretValuesis shared across the whole pass: a PUBLIC{{resolve:ssm:/x}}whose value coincides with any secret already recorded passes a presence test, and persisting its expression is issue #1901's perpetual-UPDATE class — coinciding plaintexts being this issue's own premise rather than a contrived path. A key recorded against a different (expression, plaintext) PAIR is POISONED at write time rather than overwritten; either half differing is enough, since two expressions under one key is the shared-key case above and one expression under two plaintexts is the same reference answering differently in two regions (the #1933 shape). 25 unit cases intests/unit/deployment/secret-redaction-cross-stack-source.test.ts. Every case asserting the arm FIRES drives the RESOLVER rather than calling the writer from the test -- a store no writer populates is a guard that cannot change an answer -- with one exception whose subject IS the write-side dedup; the seven hand-filled cases are all REFUSALS, where granting the store a more favourable entry than a resolver pass would produce only strengthens the assertion. Each firing case pairs two leaves whose expressions differ while their plaintexts do not, since a single-leaf case passes with the collapse fully intact. cdkd scrubcan now redact a secret that arrived throughFn::ImportValue/Fn::GetStackOutput, and REFUSES rather than reporting success when such a read cannot be resolved (issue #2133) --src/cli/commands/scrub.ts. Every resolve contextscrubStackbuilt omittedstateBackend, which bothresolveImportValueandgetSameAccountStackStaterequire, so a cross-stack read threw for want of a dependency; the throw landed in the per-item best-effortcatch { logger.debug }that exists so a partially-resolvable template still gets scrubbed for everything else. The leaf was therefore silently treated as unresolvable: its plaintext was never fetched, so it never enteredrecordedSecretValues, so the value-based scan had NO NEEDLE for it, so nothing removed it -- and scrub exited reporting no plaintext found over astate.jsonthat may still have held it. This was pre-existing, not a regression from #2109, and it is the same class of silent success reached through a different input rather than a different region. The wiring is now ONE factory insidescrubStackrather than three (in fact four -- theevaluateConditionscontext had the same omission and is not named in the issue) inline literals, so a further context cannot be written without it, which is how the original four came to lack it.exportIndexis deliberately NOT supplied, because supplying it would let the scan arm callexportIndex.patchEntry-- an S3 WRITE from a command documented to perform no AWS mutation,--dry-runincluded. The scan fallback is NOT quite "equally correct", as an earlier revision of this entry claimed: it takes the first matching record across ALL regions while the index is keyed by name alone, so two regions publishing one export name can resolve a different producer than the deploy did. Recorded in the code rather than fixed -- the tie-break lives in the resolver's scan, and the only alternative costs the no-AWS-mutation property. The best-effortcatchKEEPS swallowing; the cross-stack class is lifted out of it instead by a pre-pass (resolveCrossStackReads, run over the resourceProperties, intrinsicExport.Nameand outputValuebags) that refuses withSCRUB_CROSS_STACK_READ_UNRESOLVED(exit code 2, cause masked throughmaskSecretsInText) -- the same placement #2109 uses, and for the same reason: a refusal a debug line swallowed IS the reports-success-over-surviving-plaintext outcome. Making the catch itself refuse was rejected because it cannot tell "aRefto a resource not in state" (what it exists for) from "the producer's state could not be read", so it would break partial scrubbing; lifting only the cross-stack class out preserves it and makes everything the catch still swallows provably not a cross-stack read. Three deliberate properties: each node is resolved as{ [key]: node[key] }rather than the enclosing object, so a sibling key cannot make the pass resolve something else, and WHICH key of a multi-key node is eligible is decided by a mirror of the resolver's own dispatch order (isolation stops the pass resolving something ELSE; it does not stop it resolving something EXTRA, and a node carrying a higher-precedenceRefreally would have made the pre-pass perform -- and possibly refuse over -- a read the main resolution never makes); the bag is NOT rewritten (the main resolution re-resolves the reference; the SECRET is fetched once, and the state READS behind it are shared with the pre-pass by a memoizing view of the state backend -- before that view every reference cost a fulllistStacksplus up to NgetStateTWICE), which buys back the hazard of a resolved cross-stack value that is itself reference-shaped being re-interpreted by the stack's own resolver; andFn::Ifis walked the wayresolveIfwalks it -- selected branch only, unknown condition selecting the FALSE branch -- so a conditional import of a not-yet-deployed producer cannot refuse the whole stack, unbypassably, over a reference the deploy never read. Review round added four things the first cut got wrong. (1) The resolver'sResolved Fn::ImportValue/Resolved Fn::GetStackOutputlines interpolated the resolved VALUE at DEFAULT verbosity, defended by "what a producer stores for a secret-bearing export is the{{resolve:...}}EXPRESSION" -- true of post-#1934 state and false by construction for the population scrub exists for, which holds the PLAINTEXT, and which reaches those lines only because of this change. They now name the reference, the producer and a non-disclosing SHAPE note, and the resolved export / stack / output NAMES (which come back throughresolveValue, so anFn::Sub-assembled name can itself be a resolved secret) go throughmaskSecretsForLoglike every other resolved value in that file. (2) A read that SUCCEEDS but returns no dynamic reference produced no needle, so a consumer importing from a producer whose own state is still unscrubbed was reported CLEAN over surviving plaintext -- the same silent success one step later. It now refuses withSCRUB_CROSS_STACK_PRODUCER_PLAINTEXT, naming the producer and the working remedy (scrub the producer first). The gate is a DIRECT READ of the producer's stored value --state.outputs[<the key the read matched>], on the producer the pre-pass's ownrecordedImports/recordedOutputReadsentry names -- with the producer's TEMPLATE declaring that export from a{{resolve:...}}expression as a prior condition so an ordinary import of a bucket name is untouched. The template alone is NOT sufficient and neither is anything else visible from the consumer's side:reresolveCrossStackValueresolves a stored expression to plaintext before returning it (#1934), so a healthy producer and an unscrubbed one both arrive holding a plaintext and both satisfy the template test. Three cuts each inferred the difference from the consumer and each was wrong differently --carriesDynamicReference(resolved)inverted; the template test true of BOTH (which refused every consumer of a secret-bearing export, caught by the real-AWS arm rather than by any of the 39 unit tests then in the file, because the two signals were perfectly correlated across that matrix); and a NEEDLE-COUNT test wrong in both directions, since the needle map is keyed by the resolved secret SUBSTRING -- a COMPOSITE export (postgres://u:{{resolve:...}}@h, whatsecretValueFromJsonproduces) makessecrets.has(resolved)false for the whole leaf and refused a healthy producer on its second reference, a NON-STRING resolved value could not be tested at all, and a needle recorded while resolving the reference's own ARGUMENT satisfied the count and SUPPRESSED a real refusal. Reading the producer's stored value settles it directly, is exact for all four shapes, and costs no extra AWS call (the same memoizedgetStatepromise). The recorded entry is picked by the intrinsic's own FAMILY and with.at(-1), because a cross-stack read nested in the argument is recorded first and may land in the other bag. The template verdict is three-valued (declared/widened/no), so a refusal whose verdict came from the widened all-outputs scan says so instead of asserting what was never checked for that key. Resolving the producer's expression recordsplaintext -> {{resolve:...}}, which is the mapping scrub needs to rewrite the consumer's leaf; an already-plaintext producer records nothing.scrub --allnow orders producers before consumers (orderScrubTargets, usingdependencyNamesplusinferCrossStackStackDeps) so one real run normally resolves the expression instead of hitting the refusal at all. (3)CrossAccountSecretRefusalError-- the cross-accountFn::GetStackOutputof a redacted value, which cdkd declines BY DESIGN, and a new SUBCLASS ofIntrinsicResolutionRefusalErrorprecisely so this treatment cannot capture that class's five USER-FIXABLE siblings (a stale placeholder ARN, a fabricated account, an unenrichedFn::GetAtt,--strict-getatt, a malformedFn::Split), each reachable here whenever a cross-stack node's argument is anFn::Sub/Fn::GetAtt/Fn::SplitsinceresolveSubre-raises the class -- was being converted into a whole-stack refusal with a remedy ("deploy the producer stack") that cannot fix it and no bypass, stranding every other secret in that stack permanently; it is now an unremediable FINDING like a secret-bearing state KEY (reported, counted,--failexits non-zero, the rest of the stack still scrubbed). The rule applied: a read that FAILED for a reason a user can fix (deleted producer, unassumable role, missingcloudformation:ListExports) refuses, because a re-run then works; a read cdkd will never perform degrades. (4) A CONDITION-SUPPRESSED output could refuse the whole stack in dev over a prod-only import; suppressed outputs are now resolved for their needles but cannot refuse -- for EITHER refusal, and thecanRefusetest runs ahead of the by-design branch so such a position records no permanent FINDING either -- and a MALFORMEDFn::Ifis walked with refusals disarmed instead of falling through to the plain object walk. Suppression is decided by the condition AND by state, sinceconditionscomes from template parameter DEFAULTS (scrub takes no--parameters) and degrades to{}on failure: a key present instate.outputsproves the deploy wrote that output. Three more identifier / key sites on this path stopped printing unmasked values: the re-resolution's ownRe-resolving dynamic reference(s) in <origin>debug line (whoseoriginembeds the RESOLVED export / output / stack name), theDescribeStacksfallback warn (which prints at default verbosity and had never been given acontextto mask against), and theAvailable outputs:enumeration in anFn::GetStackOutputnot-found ERROR, which is now masked and capped at ten keys because those are the PRODUCER's output keys and a key can itself hold plaintext. The per-stackNo plaintext secrets found in <stack>line is now gated onunverifiableReads === 0as well, so it cannot contradict the warning above it. Tests:tests/unit/cli/commands/scrub-import-value-secret.test.ts-- 39 cases over a real resolver, asserting WHICH values became needles, WHICH stack's state was read and WHICH region's client answered rather than that the command exited 0; each pre-pass call site fenced individually so the refusal is not an unfenced disjunction; both polarities of every new refusal; and negative controls (an unresolvableRefalone, a resolvable import beside one, an unreadable import in a not-takenFn::Ifbranch, an ordinary import resolving to a plain string, a producer outside the app, an ordinary error where a by-design refusal is handled) so a refusal firing on everything cannot satisfy the positive assertions. Every case was mutation-probed red, including the two that are only red when BOTH of their redundant guards are removed. Live arm ontests/integration/cross-stack-secret-import/.cdkd driftmodels "this resource was not compared" as a FIRST-CLASS outcome instead ofcleanplus a boolean, so a consumer that forgets to ask cannot report an unchecked resource as a clean one (issue #2135) --src/cli/commands/drift.ts. The per-resource outcome union wasdrifted/clean/unsupported/skipped, and a resource whose dynamic references cdkd could not -- or refused to -- resolve landed incleancarryingreferencesUnresolved: true(plus a secondcomparisonRefusedflag for the narrower exit-code population). Every consumer therefore had to REMEMBER to consult the flag, and the default behaviour of forgetting was to report a resource cdkd never checked as one with no drift; the same root cause surfaced twice in successive review rounds on the #2108 lane (round 1:--jsonreported it as plainclean; round 2: the exit code read the refusal as a pass). The union now carries anotComparedmember holding a singlenotComparedCauseof'refused'(cdkd declined -- exit2) or'unresolvedToken'(a surviving{{resolve:ssm-secure:...}}token, a permanent pre-existing population deliberately kept out of the exit code),cleancarries no completeness marker at all, and adriftedoutcome carries the same cause orundefined. All eight consumers -- thenotComparedroll-up, the exit-code decision,runAccept,runRevert, both--dry-runplans, the--jsonwriter and the human report with its "N of M fully checked" counter -- route through onematchOutcomehelper whose handler record is a mapped type overDriftOutcome['kind'], so a new variant is a COMPILE error at every one of them rather than a silent wrong answer (proven by adding a temporary sixth variant: all eight failed to compile). User-visible payload delta,--jsononly: a not-compared resource now appears undernotComparedand NOT underclean,notComparedentries carryreferencesUnresolved: true, andcleanentries arereferencesUnresolved: falseby construction. No key changed name or meaning, and the exit codes, the human report and thePARTIALLY comparedblock are unchanged. Fenced bytests/unit/cli/drift-outcome.test-d.ts(the mapped type must stay total;cleanmust reject a completeness rider) plus three runtime tests asserting the counter, the human block, the--jsonroll-up and the exit code all answer from the same variant.- A Ctrl-C during
cdkd rollback's lock release no longer strands the lock for its full 30-minute TTL (issue #2118) --src/cli/commands/rollback.ts,src/deployment/deploy-engine.tsandsrc/provisioning/interrupt-watch.ts(comments only),.claude/rules/code-layout.md,docs/provider-development.md.rollbackCommand'sfinallytore down every signal path BEFORE releasing the lock:unforwardSigterm(), thenprocess.removeListener('SIGINT', sigintHandler), thenreleaseLock. Between the removals and the release the process held the stack lock with ZERO SIGINT listeners, so a Ctrl-C landing in that S3 round-trip took Node's DEFAULT terminate -- the release never completed and the nextcdkd rollback/deploy/destroyon that stack blocked until the 30-minute TTL expired (recoverable withcdkd force-unlock, which is why this is a blocked command rather than lost data). This is the MIRROR of issue #1348, which fixed the other end of the lock's life by registering the handler before ACQUIRING; the rule both need is the onedestroy-runner.tsalready states on its strong-ref refusal path -- "Release FIRST, remove the listener LAST: while the release round-trip is in flight the handler stays armed." Both unregistrations now live in a nestedfinally, because the.catch()on the release covers a REJECTION and not a synchronous throw -- defence in depth rather than a live leak, sincereleaseLockisasyncand nothing inLockManagerreaches that shape today, but the leak it would cause is per-command and permanent. The first cut of this change also claimed the ORDER within the teardown pair was load-bearing, in four places, and it is not -- the review round that measured it is the reason the claim is not shipping. The stated mechanism was thatunforwardSigterm()(which since issues #2053 / #1952 also closes the interrupt-watch scope and removes that module's shared SIGINT listener) could empty the listener set beforesigintHandlerwas reached. It cannot: the command's own handler is still registered at that point, and the two calls are adjacent and synchronous, so no signal can be delivered between them. What IS load-bearing, and is what the SIGTERM half of the test pins, is thatunforwardSigterm()must not run BEFORE THE RELEASE -- CI cancellation delivers SIGTERM, so with the forwarder gone that signal strands the lock on its own, and a fix that reordered only the SIGINT half would look correct. The acquire-failure catch above is REORDERED to match, so the file no longer reads as contradicting its own comment 300 lines down. It stays correct either way -- no lock is held there (acquireLockWithRetryreturns on the first successful PUT, so every throw path leaves the lock not ours) and both statements are synchronous, so the end state is identical. Marginally safer in the new order: ifunforwardSigterm()ever threw, the command's own handler is already gone. An earlier draft of this entry called that catch "unchanged", which its own diff contradicted. A repo-wide sweep of the other lock-holding commands found no further instance --drift/scrub/export/import/orphanandstate refresh(which releases a lock instate.tsbut registers no handler at all) have nothing to unregister,destroy-runner.tswas corrected in the #2053 / #1952 lane, anddeploy-engine.tsis now the ONLY site that still unregisters first, which is safe for a reason the others did not have and which its call site documents:deploy.tsregisters a top-level handler that outlives the whole method. That call-site comment's forward reference to this issue is de-staled in the same PR, as are THREE further copies of the same claim --.claude/rules/code-layout.md,docs/provider-development.md, andsrc/provisioning/interrupt-watch.ts's force-quit arm, which said outright that "no window remains" was true of that handler but false of the codebase becauserollback.tsstill unregistered first. That fourth copy is the highest-signal one and the original sweep missed it; a review round found it, which is also how the enumeration in an earlier draft of this entry came to be wrong. Its replacement then had to be scoped twice more, because the obvious rewrite is also false: what holds is that no lock-holding command THAT REGISTERS A SIGINT HANDLER unregisters it before releasing, withdeploy-engine.tsas the one deliberate exception. It is NOT true that no stranding window remains anywhere --import/export/scrub/orphan/driftandstate refresh-observedregister no handler at all, so each holds its lock with zero listeners for its ENTIRE duration, a wider window than the one this issue closes and a separate defect. Tests:tests/unit/cli/rollback-lock-release-ordering.test.ts, the behavioural twin ofdestroy-runner-lock-release-ordering.test.ts-- it observes the SIGINT and SIGTERM listener sets from INSIDEreleaseLock, which is the only vantage point that can see the window the defect lived in. Both signals are measured separately on purpose: a fix that reordered only the SIGINT half would leaveunforwardSigterm()running before the release, and CI cancellation delivers SIGTERM, so that half strands the lock on its own. A merge-gate review round then found one more gap and one more overclaim: the release's REJECTION arm was unfenced (deleting the.catchpassed all seven cases, while the twin fixture has carried that arm all along), so a regression masking the rollback's real error with the S3 failure would have gone unnoticed -- now pinned; and the SIGTERM case's own comment called itself the fence for the order WITHIN the teardown pair, contradicting the file header three lines up. Two cases came out of the review rounds: the ordering is also driven on the SUCCESS path (a replayed journal), which is the only path where the provider-side interrupt watch is ever armed, and a case recording as DELIBERATE that a Ctrl-C landing during the release is swallowed. That last one is a consequence this fix CREATES, and the twin command answered it the other way: arming the handler for longer moves the window a signal can arrive in, which is exactly the round-4 regressiondestroy-runner.tshit when a late flip leftresult.interruptedfalse anddestroy --alldeleted the next stack.rollbackhas no such consumer --interruptedis read only inside thetry,rollbackCommandreturns void, and its one call site iswithErrorHandling-- and by that point every operation has replayed, the journal is popped and state is saved, so raising aPartialFailureErrorwould report exit 2 for a rollback that fully succeeded. The case exists to stop a later change adopting the destroy-runner precedent as if it were binding. A second review round found that case was VACUOUS as first written: it asserted only that the command resolved cleanly, which is equally true when the handler has already been unregistered and there is nothing left to fire -- so it went green under the very ordering regression its siblings catch. It now captures stderr and asserts the handler's own interrupt notice was written during the release, which is the only observable proof it ran; with that assertion it reds under the ordering mutation alongside the other three. Two comment claims from the same round are corrected rather than left: the success-path case's rationale said the replay is "the only path on which the provider-side interrupt watch is ever armed", which is not what that case exercises (its provider is a stub, and exactly one SIGINT listener is live at the release), and a note claiming the fixture leaves "the harness's own SIGINT handling untouched" described a loop that in fact invokes every registered listener. Mutation-probed in both directions: restoring the old ordering reds the two armed-during-release cases AND the success-path one, and dropping the nestedfinallyreds the synchronous-throw case -- plus an inverted control on the acquire-failure path, which releases nothing and must still leak nothing. cdkd gcandcdkd bootstrap --destroynow name the STACK for a European Sovereign Cloud state key, instead of reporting the region as the stack name (issue #2001) --src/cli/commands/state-file-keys.ts,src/cli/commands/gc.ts,src/cli/commands/bootstrap-destroy.ts,src/cli/commands/bootstrap.ts,src/deployment/intrinsic-function-resolver.ts(comment only).REGION_SEGMENT, the pattern that decides whether the last segment of{prefix}/{stack}/{region}/state.jsonis a region, required a prefix of EXACTLY two letters (^[a-z]{2}(-[a-z]+)+-\d+$). Every commercial / GovCloud / China / ISO region has one, but the European Sovereign Cloud partition's is FOUR -- soeusc-de-east-1was rejected and the whole partition mis-parsed. The audit the issue asked for is what makes this more than a one-line swap, and the answer is worse than a cosmetic label:describeStateKeyfalls back to the LAST segment, which for a region-prefixed key IS the region, so a rejection does not degrade to "region omitted" -- it reports the region string AS the stack name. Three of the four call sites (bootstrap-destroy.ts's marker-reference scan and its state-bucket teardown refusal,gc.ts's lock guard) then name a stack that does not exist while listing what blocks a teardown. The fourth is not display at all:gc.ts's corrupt-state abort re-parses the described string with/^(\S+) \((\S+)\)$/to BUILD a recovery command, which stopped matching -- so the user was handedcdkd state show eusc-de-east-1(no--stack-region, and a stack name that is really a region) at exactly the moment their state file was unreadable and they needed to inspect the real one. No state-key MIGRATION is implied: the pattern only classifies keys that already exist, and never writes one. The fix went through three revisions, and what it converged on is narrower than any of them. The pattern's first token is now[a-z]{2}OR the literaleusc-- enumerated fromaws-cdk-lib/region-info'sAWS_REGIONSand@aws-sdk/util-endpoints'partitions.json: 46 REAL regions, first tokens all two letters oreusc, none three. (An earlier draft said 53, which counts theaws-global/aws-iso-*-globalpseudo-regions whose first token isaws-- they carry no trailing-<digits>so both the old and the new pattern reject them, but at 53 the claim is false as stated.) The obvious fix, relaxing the bound to{2,4}, was written first and shipped a worse defect than the one it fixed. Widening a length class widens what the pattern mistakes for a region in the OTHER direction, and that direction bites far more often: the pre-fix hazard needed a stack nameddb-/qa--something, while{2,4}captures the idiomatic first tokens --api-prod-1,demo-app-1,prod-api-2,data-eu-west-2,core-api-1all flipped. In the legacy region-less layout a stack name sits exactly where a region would, so each of those was described with the key's PREFIX as the stack. A length class cannot separateeuscfromdemo; only naming the partition can. The cost of enumerating is that a future non-two-letter partition needs this line updated -- a one-token change that fails in the recoverable direction, unlike corrupting ordinary stack names. For a legacy region-less key those are described ascdkd (demo-app-1), reporting the state PREFIX as the stack name, andgcthen emitscdkd state show cdkd --stack-region demo-app-1-- the #2001 defect reintroduced from the other side, wrong in both arguments. SodescribeStateKeyno longer guesses on the ONE layout where depth can settle it: it takes an optionalknownPrefix, and a key with exactly one segment under that prefix is the legacy{stack}layout by construction, whatever the stack is named. It deliberately does NOT claim the two-segment case, and a first cut did -- on the reasoning that at that depth the last segment must be the region. Review found--state-prefix cdkd/team-a, which nests INSIDE the default prefix, socdkd/team-a/MyStack/state.jsonis a legacy key two segments deep and would have been split asteam-a (MyStack)wheremaincorrectly saidMyStack: the fix would have shipped a fresh instance of the very class it removes. Depth cannot tell that from a real{stack}/{region}and only the tail's shape can, so the two rules end up with disjoint jobs rather than overlapping ones.gcandbootstrap --destroyboth configure their own state backend with the default prefix, so both pass it, and a new sharedDEFAULT_STATE_PREFIXinstate-file-keys.tsreplaces the copy each command held -- a drifted copy would not error, it would silently demote every key back to the guess.bootstrap.ts,options.ts(the--state-prefixDEFAULT, which is what actually decides where a user's state lands) andmigrate-command.tsare pointed at it too, since changing any of those alone is exactly the silent demotion. The shape rule remains for keys under a FOREIGN prefix, which is not a fallback that can be removed: the listings deliberately span the whole bucket so a stack deployed with a custom--state-prefixcannot slip past a teardown guard, and for those keys the depth is genuinely unknown. The pattern stays SHAPE-based and deliberately does not reuseisClientSafeRegion, whose job is the opposite one (keeping a value inside a hostname label, so it accepts nearly any stack name); that function's own comment claimed the sibling "rejectseusc-de-east-1" and is corrected here to give the structural reason instead. The same audit swept the repo for sibling region-shaped patterns:gc.ts:356andecr-uri.ts:225are permissive and clean,scripts/andtests/have none, andsrc/local/lambda-resolver.ts:878carries this defect plus a wider one (its partition alternation lists three of eight, so layer ARNs in all four ISO partitions fail too) -- filed as issue #2143 rather than folded in, sincesrc/local/**sits behind theinteg-localmerge gate and would pull a real-Docker cycle onto a PR in another command family. Tests:tests/unit/cli/state-file-keys.test.ts-- 12 real regions across every partition; five negatives, of which review established that only ONE (the over-long prefix) actually fences the widening while the others guard the case rule and the-\d+$anchor, so their comments now say which; and a depth-branch block covering the region-shaped legacy stack names, a region spelling the shape rule refuses (proving depth does not consult it), lock keys in both layouts, and the foreign-prefix fallback. Plus consumer cases that drive the real commands end to end: two intests/unit/cli/gc.test.tsasserting the recovery hint readscdkd state show PaymentsApi --stack-region eusc-de-east-1, and two intests/unit/cli/bootstrap-destroy.test.tson the reference scan -- the one call site that prints no raw key beside the descriptor, so a wrong descriptor is all the user gets. Mutation-probed in three directions: restoring{2}reds seven cases including bothgcconsumers, widening to{2,}reds the upper-bound negative alone, and disabling the depth branch reds seven state-file-keys cases plus thebootstrap-destroylegacy one. The eusc reference-scan case deliberately survives that third probe -- it fences the widening, not the depth rule. Three successive review rounds each found the same structural defect -- a classification rule asserting it covered more cases than it does -- and the third found it in the fix for the second. Round 2 found that three of the four CALL-SITE tests could not fail, which is the more interesting result: each used a region-shaped REGION segment, and that is precisely the input where the depth rule and the shape heuristic AGREE, so deleting theDEFAULT_STATE_PREFIXargument fromgc.ts's two sites orbootstrap-destroy.ts's teardown listing left the suite green -- the threading could have gone inert with nothing to say so. Each site now also drives a LEGACY region-shaped stack name, where the two rules disagree, and stripping the argument from all four sites reds exactly one case per site. Two nits from the same round are fixed rather than recorded: aknownPrefixspelled with a trailing slash silently demoted every key back to the guess (the exact failure the parameter exists to prevent, arriving through a spelling instead of a drift -- now tolerated, and fenced by its own probe), andsrc/cli/commands/bootstrap.tsstill spelled the prefix as a literal in two places, which is the "create side" the new doc comments cite BY NAME, so it is unified onto the shared constant too.cdkd scrubnow resolves a foreign-region secret reference in the region the reference NAMES, and REFUSES rather than reporting success when that region cannot be established (issue #2109) --src/cli/commands/scrub.ts. scrub learns which plaintexts to look for by re-resolving today's template through oneIntrinsicFunctionResolverbuilt from the stack's own region, andresolveSecretsManagerReferencebuilds its client from that region while passing the SECRET_ID through as an opaque string -- the SDK's endpoint ruleset has no ARN-derived endpoint rule, so a reference naming another region's ARN went to this stack's regional endpoint. Both halves failed at once: the plaintext scrub exists to remove was never found (the needle was the wrong region's value), so the command reported the stack CLEAN over state that still held the secret; and the foreign value is a real string, so scanning for it could rewrite an unrelated stored literal onto a{{resolve:...}}expression the stack never had. The region split is issue #2057's, imported fromrollback-executor.tsrather than re-spelled:producerRegionsFromStatereads the per-stack evidence (state.imports[].sourceRegion+state.outputReads[].sourceRegion) andclassifyReplaySecretRegionreturnslocal(the stack's own region answers),named-region(an ARN names a FOREIGN region, so a resolver pinned to it answers) orambiguous(a region-LESS name form with a foreign producer region on record -- refused). The refusal is aCdkdErrorthat leavesscrubStackuncaught, so it is a NON-ZERO EXIT with the message printed, never a debug line under a "nothing to scrub" summary; it names the reference, both regions and the ARN remedy, and leaks neither region's value. Anamed-regionreference whose own region cannot answer is refused too rather than retried locally. The known over-refusal is inherited with the classifier and stated in code: a name-form reference in a stack with ANY foreign producer region on record is refused even when it is the stack's own purely-local secret, because the evidence is per-STACK, not per-reference. Unit coverage:tests/unit/cli/commands/scrub-cross-region-secret.test.tsfakes only the leaf SDK client classes (real resolver, realAwsClients) with the CONSTRUCTOR region as the discriminator, and primes the SAME reference with DIFFERENT values in the two regions so "which region answered" is observable at all. Four review-round hardenings ship with it. (1) A reference the intrinsics ASSEMBLE is refused rather than silently unclassified. The token scan runs on the RAW template leaf and its[^}]+class cannot cross a}, so an assembled reference is not the one whole token a literal one is, and the THREE shapes fail in TWO different ways -- all four rows measured. A MID-stringFn::Subplaceholder ({{resolve:secretsmanager:${Env}-db:SecretString:password}}) and anFn::Joinsplit ('{{resolve:secretsmanager:'as one part) yield ZERO tokens while plainly OPENING a reference; a TRAILING placeholder ({{resolve:secretsmanager:x:SecretString:${Field}}}) instead closes the match ONE BRACE SHORT and yields exactly one token per opening, so a count can never see it. In every case the leaf came back by identity andresolveSub/resolveJointhen handed the assembled expression to the PRIMARY resolver, which is this issue verbatim with the refusal never firing. Two tests now raiseSCRUB_SECRET_REFERENCE_UNCLASSIFIABLE, naming the leaf path and the remedy: openings-vs-tokens for the first two shapes, and "a whole token still contains${" for the third (no Secrets Manager secret name or SSM parameter name may contain$or{, so the only false positive it can produce is a JSON key literally spelled${...}outside anFn::Sub). The ARN form of that third shape was previously safe only by luck -- it classified asnamed-region, so the producer region WAS asked with a secret id still carrying a literal${Field}, and safety came from that lookup happening to fail; it is now refused before any lookup, which the suite asserts against a PRIMED producer region so the luck cannot come back. The count is over openings followed by a SECRET SERVICE (secretsmanager:/ssm:/ssm-secure:, mirroringrollback-executor.ts'sREPLAY_SECRET_SERVICES), not over the bare{{resolve:the first draft counted: measured,Use the {{resolve: prefix for dynamic references,prefix {{resolve:and{{resolve:}}each count one opening and zero tokens, so any description, IAM policy document, UserData script or environment variable that merely MENTIONS the syntax made its stack permanently unscrubbable (exit 2, no bypass flag) with a remedy -- "spell the reference as one complete literal" -- that is unactionable for prose. The tokens are filtered to the same spellings, so a complete reference to a non-secret service cannot make the two sides disagree either. Both tests are gated on a foreign producer region being on record: with none there is no cross-region question to get wrong, and refusing there would reject ordinaryFn::Subtemplates. Classifying AFTER assembly inside the resolver is the structurally complete fix and is out of this change's scope, so the residuals are stated in code rather than implied away, and the scope text of issue #2134 names them: an assembled FOREIGN-ARN reference in a stack with no cross-stack read on record; anFn::Jointhat splits BEFORE the service name; and -- the one that bounds the whole pre-pass -- a leaf whose{{resolve:opening is CONTRIBUTED by aRef/ parameterDefault/Fn::FindInMaprather than present in the template text, which the walk returns by identity so neither the region split nor this guard ever sees it, foreign producer region on record or not. (2) Under--alla refusal is caught PER STACK -- the stacks after it are still scrubbed, each failure is logged WITH ITS CAUSE CHAIN (a provider or AWS failure is routinely a generic sentence over the link that names the denied action, and thislogger.erroris the only place a per-stack reason is rendered), the final error NAMES every failed stack without restating the reasons that were already printed, and the run still ends non-zero. Under--dry-run --failthe REFUSAL (exit 2) outranks the FINDING (exit 1) -- they call for opposite responses, so the precedence is asserted rather than left to statement order. Previously one refusal abandoned every later stack while the earlier ones had already been rewritten, which the rollback replay's per-op twin never did; no summary line claims success over a stack the run could not examine. (3) Every scrub refusal carriesexitCode = 2, since1is already spent on--fail'sScrubNeededError("plaintext found") and a CI gate reading the code alone must not confuse "refused to look" with "looked and found a leak". (4) Every error text this command emits is masked against what it recorded -- the unresolvable-region refusal masks the cause message it echoes, and so do the three sites that interpolate a resolver error into a log line (theExport.Namewarn at DEFAULT verbosity plus the two verbose-only partial-resolution debug lines), each of which is handed a bagpinCrossRegionSecretsmay already have SUBSTITUTED a foreign plaintext into. Per-site masking is not sufficient on its own, so every error that ESCAPESscrubStackalso passes throughmaskSecretsInErrorat that boundary:formatErrorrenders aCdkdError's cause asCaused by: <message>and the CLI's top-levelconsole.errorwalks the whole chain and every link'sstackthroughutil.inspect, which read the error OBJECT and no log-site mask can reach. No resolver error carries a plaintext today, but this is the command whose subject is a leaked plaintext.cdkd scrub's outputs redaction converged ontoTEMPLATE_SOURCED_RULES, so the twostate.outputswriters agree again (issue #2099) --src/cli/commands/scrub.ts,src/deployment/secret-redaction.ts.DeployEngine.redactOutputsmoved off the defaultTEMPLATE_DERIVED_RULESfor issue #1943; scrub'spositionedOutputscall kept the default on the premise that the two constants' only difference (descendArrays) could not fire here, "because CloudFormation requires an OutputValueto be a string or an intrinsic OBJECT". CloudFormation does require that; cdkd does not enforce it --TemplateOutput.Valueis typedunknown, the resolver walks an array elementwise with no string coercion, andStackState.outputsis deliberately not string-coerced -- so a list-valued output (an escape hatch, a hand-written or imported template) puts an array on BOTH sides and the arm IS reachable. The pair is the one positional descent is least sound for: the bag is a PREVIOUS generation's persisted array and the source is today's template array, so a stored literal at indexiwas rewritten to today's expression at indexi(redactByPathreturns a known-secret source leaf verbatim), andstate.outputsis re-applied VERBATIM to consumer stacks by the exports index and byFn::ImportValue. The trade is stated in place rather than re-decided: turningdescendArraysoff gives up a legitimate positional descent, and although this call site's bag is always cross-generation, the swap is not free -- when state happens to be current the refused descent would have rewritten an element whose plaintext this run did not record (a value rotated away since the deploy), which the value scan cannot identify. ThePathSourceRulesgeneration table'scdkd scruboutputsrow and its "the two OUTPUTS rows disagree" paragraph were updated to match. Unit coverage:tests/unit/cli/commands/scrub-outputs-array-generation.test.ts, including the capability the swap KEEPS (a whole-value plaintext match in a list element is still repaired by the value scan).cdkd driftno longer re-resolves a cross-region secret reference in the CONSUMER's region ----revertcould write a foreign region's same-named secret to a LIVE resource (issue #2108, thedrift.tshalf of #2057) --src/cli/commands/drift.ts. Since issue #1934 a cross-region cross-stack consumer re-resolves a redacted producer value in the PRODUCER's region and records the PRODUCER's region-less spelling of the{{resolve:...}}expression into its ownstate.json.resolveStateSecretExpressionsre-resolved that spelling through resolvers built from the CONSUMER's region with no region check at all, at BOTH of its call sites, and a Secrets Manager secret (or an SSMSecureString) of the same NAME in two regions is two independent values. Three consequences, in severity order: (1)--reverthandeddesiredPropertiesstraight toprovider.update, so the foreign region's secret was WRITTEN to a live resource -- reachable from an ordinary drift run rather than only after a failed deploy, and measured under a reverted fix as a Lambda env var receivingtokyo-password-2108where the deploy had appliedireland-password-2108; (2) detection baselined against a plaintext AWS can never hold, so the property reported drift forever and no--accept/--revertcould converge it; (3) thesecretsneedle map held the WRONG plaintext, so value-based redaction both missed the real secret and rewrote an unrelated public literal coinciding with the foreign one. Every reference is now classified byclassifyReplaySecretRegion-- IMPORTED fromrollback-executor.tsrather than re-derived, so the two commands cannot answer differently -- and an ARN naming a foreign region resolves through a resolver PINNED there (DriftSecretResolvers, one instance per stack because the resolved-value cache lives on the resolver instance since #1933), while a region-less name form in a stack with a foreign producer region on record is REFUSED withDRIFT_SECRET_REGION_AMBIGUOUSbefore any lookup. The foreign-region evidence isproducerRegionsFromState(state)read straight off the record the command already loaded (state.imports[].sourceRegion/outputReads[].sourceRegion) -- the rollback lane had to plumb that through its context; drift needs no plumbing, which is why this fix could be exact where #2057 had to settle. Both refusal paths degrade fail-closed through catches that already existed: detection falls back to the unresolved baseline, whose{{resolve:...}}leaves the comparator SKIPS (not compared, rather than compared against a foreign plaintext), and revert counts the resource unresolvable and returns beforeprovider.update. Same-region behaviour is byte-for-byte unchanged. Three costs are ACCEPTED rather than incidental, and are recorded here because each is a behaviour a user can observe. (a) The refusal fires on ANY name-formsecretsmanager/ssmreference in a stack that has ANY foreign producer region on record -- including a purely LOCAL secret, which is the shape CDK's ordinarySecretValue.secretsManager('name')/secretValueFromJsonemits -- because the evidence is per-STACK, not per-reference; the same known over-refusal #2057 accepted, and the remedy is the same (spell the reference as a full ARN, which names its region). (b) On that refusal path the per-resource secrets map is CLEARED, so only the offline positionalseededSecretPathsmasks that resource. Pre-#2108 an over-refused local reference resolved fine and the map held the CORRECT plaintext, which value-based redaction used to mask that value wherever it appeared -- including at paths whose state side carries no{{resolve:for the positional seed to find. Kept anyway: the alternative is resolving the reference to build the needle, which is the wrong-region fetch this change exists to refuse. (c) A refused resource is reported CLEAN (the comparator skips its{{resolve:...}}leaves), where pre-#2108 it reported -- wrongly, but visibly -- as drifted.cdkd drift --jsontherefore gainedreferencesUnresolvedon everydriftedANDcleanentry plus anotComparedroll-up, and the human report gained aPARTIALLY comparedblock, so a CI job gating ondrifted.length === 0cannot read a SKIPPED comparison as a passing one. All three keys are additive; no existing key changed meaning. And so does the EXIT CODE, which is the signal most CI gates actually read and the layer round 1 of this change left alone: a detection-only run that drifts nowhere but REFUSED at least one comparison now exits2(this repo's "work completed but something was SKIPPED" code) instead of0. That direction PRESERVES what CI consumers had -- pre-#2108 that population reported phantom drift and exited1, so0would have been the silent downgrade -- and drift still WINS, so a run that both detects drift and refuses a comparison exits1exactly as before and a gate keyed on=== 1loses nothing.--accept/--revertkeep their documented codes. The human summary follows the ROLL-UP rather than the exit code (⚠instead of✓, and<n> of <m> resources fully checked, since counting a not-compared resource ascheckedcontradicted thePARTIALLY comparedblock printed two lines below it -- and keying the glyph on the narrower exit subset would reintroduce exactly that contradiction for thessm-securepopulation). The exit trigger is a STRICT SUBSET of thenotComparedroll-up, via a second outcome fieldcomparisonRefusedset only from the THROWN half ofreferencesUnresolved. The other half -- a surviving{{resolve:ssm-secure:...}}token, which cdkd resolves for nobody because CloudFormation resolves it server-side -- is a large PRE-EXISTING population unrelated to this issue and permanent by construction, so driving the exit off the combined flag would have failed those users'cdkd driftin CI forever over a defect this change did not introduce. Such a stack is still listed undernotComparedand in thePARTIALLY comparedblock, and still exits0. As information both populations were genuinely not compared; only the exit code, the thing CI keys on, is confined to the one this change created. The two flags are the shape issue #2135 exists to fix structurally. (d) A deliberate refusal is now told from a failed READ by CLASS rather than by an enumerated error code: both refusals are raised asDriftSecretRefusalErrorand both message sites askisDriftSecretRefusal(err). The two sites originally compared againstDRIFT_SECRET_REGION_AMBIGUOUSalone, soDRIFT_SECRET_TOKEN_SCAN_MISMATCH-- whose own message says "Refusing rather than resolving" -- printed the read-failure wording and sent the reader hunting for an IAM grant that is not missing. Pre-v8 residual, the same limitation #2057 carries: a cross-regionFn::GetStackOutputon a stack last deployed under schema v7 has nooutputReadsat all, soproducerRegionsFromStatereturns empty, every name-form reference classifieslocal, and the wrong-region resolution persists until the stack's next deploy writes a v8 record. An ARN-form reference is unaffected (the expression settles the question itself). Tests:tests/unit/cli/drift-cross-region-secret.test.ts(24 cases at both polarities, faking the leafSecretsManagerClient/SSMClientso each assertion names WHICH region's client answered rather than merely that a value came back, with the two regions primed to DIFFERENT values for one secret name; the MIXED-leaf cases assert the FULL rebuilt string, since a whole-token leaf leaves the segment-rebuild loop's two splices dead) including the full truth table of the facts the exit code decides on -- refused-only, drifted-only, clean, both at once, and the case the narrowing exists for: a survivingssm-securetoken with NO refusal, which must be reported and must still exit0),tests/unit/cli/drift-secret-refusal-is-positive.test.ts(a source-level fence that no branch enumerates a refusal CODE and that every refusal is raised as the class -- source-level because the token-scan refusal is an internal-invariant guard no input reaches while the scanner is correct), andtests/unit/cli/drift-leaf-region-walk-mirrors-replay.test.ts(the anti-drift fence for the deliberate second implementation: it READSrollback-executor.tsand asserts the two leaf walks are the same program once the declared identifier aliases and the two command-specific throw texts are normalized). Integ:tests/integration/rollback-cross-region-secret/phases 2b / 2b2 / 2c, which assert both exit codes against real AWS and tamper a SECOND, non-secret property (Description) so the revert actually runs -- tampering only the secret-bearingValueleft the resourceclean, sorunRevertreturned early and the phase exercised no revert code at all.- A FAILED deploy now persists the cross-stack reads it actually made, closing a destroy-time strong-reference hole (found while fixing issue #2057) --
src/deployment/deploy-engine.ts,docs/cross-stack-references.md. Only the SUCCESS path persistedrecordedImports/recordedOutputReads; every other save wrote the PRE-deployimports[]/outputReads[]snapshot beside POST-deploy resource records, with a comment saying so deliberately -- the diff-clean no-change save, the per-resource partial save, the pre-rollback save, the TWO post-rollback saves (primary and ETag retry) andpersistStateAfterOutputFailure, which looks like a success save because provisioning WAS clean, yet writes a rollback journal segment and rethrows. Two consequences, and the second is independent of #2057 and was onmain: (1) the region evidence #2057's refusal needs is absent on exactly the deploy that introduces a cross-region read, so the refusal could not fire where it mattered -- a green fixture coexisted with an inert fix until a review round caught it; (2) a consumer deploy that ADDED anFn::ImportValueand then failed recorded the consumer's new resources with NO import, sofindActiveImportConsumers(destroy-runner.ts, the pre-flight that refuses to destroy a producer while a consumer imports from it) found nothing andcdkd destroy <producer>proceeded against a live importing consumer. Unlike the documented v3-to-v4 gradual-activation gap this never healed on its own, because nothing re-recorded the import until the consumer's next SUCCESSFUL deploy.crossStackReadsForPartialSave/unionCrossStackReadsnow union this session's reads at every save except the terminal success one. The count in this entry was wrong twice while the lane was open (an "ALL FIVE" that missedpersistStateAfterOutputFailure, and a "three post-rollback saves" that is two), so the enumeration is no longer maintained in prose:tests/unit/deployment/deploy-engine-cross-stack-read-writers.test.tsscansdeploy-engine.tsfor directimports:/outputReads:writes and fails on any that is not the allow-listed success-path one, with a positive control proving the scan sees a violation. A union never drops, so a stack that stops reading cross-stack keeps a stale entry until its next successful deploy -- an over-refusal that names the consumer and is overridable, chosen over the direction that deletes a producer out from under a live consumer. Covered bytests/unit/deployment/deploy-engine-partial-save-cross-stack-reads.test.ts, and by the new arm B oftests/integration/rollback-cross-region-secret-- one deploy that BOTH introduces the read and fails, which is the shape arm A (a successful deploy establishing the read first) structurally cannot reach. cdkd bootstrap/gc/bootstrap --destroynow resolve their region from your AWS profile, and hold existing storage rather than stranding it (issues #2029 / #1820) — new resolver + reconciliation insrc/cli/region-options.ts, wired throughsrc/cli/commands/{bootstrap,gc,bootstrap-destroy}.ts. This removes an incoherence rather than adding a preference, and the incoherence was MEASURED rather than read. cdkd already consulted the profile: every command builds its pre-flight bag asnew AwsClients({ ...(options.region && { region }) }), so with no flag the bag is region-less and the SDK resolves the profile. Only the region VALUE fell back to theus-east-1literal — and that value keys the bootstrap marker, keys the state file and answersAWS::Regionin every user template. Withregion = ap-northeast-1in the profile, no--regionand noAWS_REGION, on an env-agnostic stack:State bucket '...' is in 'us-east-1' (client was 'ap-northeast-1'); rebuilding S3 client./Getting state for stack: CdkdBasicExample (us-east-1)/Resolved Ref to pseudo parameter: AWS::Region -> us-east-1. One command, two regions — the pre-flight talked to the profile's region while the resources, their ARNs and their state landed in us-east-1. Not "cdkd ignores your profile", which would at least be consistent; it half-honoured it. Resolution is now--region->AWS_REGION->AWS_DEFAULT_REGION-> the SDK's own chain (the profile) ->us-east-1.AWS_DEFAULT_REGIONis read even though the JS SDK itself does NOT (measured: with only that variable set,STSClient({}).config.region()returns the PROFILE's region) — the AWS CLI honours it, so reading it is what stops cdkd disagreeing with a CLI command the user just ran.resolveEffectiveRegionreturns the SOURCE alongside the region, and that distinction is the entire safety mechanism: only an INFERRED region is subject to the reconciliation. The reconciliation is why this is shippable at all. Changing what a bare command targets moves the key its storage lives under, so an inferred region yields to an existing opt-in under the old default and SAYS so (cdkd asset storage exists in us-east-1, but your AWS profile resolves eu-west-1. Continuing to use us-east-1 so the existing storage is not orphaned. Pass '--region eu-west-1' ...), a NAMED region is obeyed as given, and a first bootstrap goes to the profile's region because nothing exists to strand. A probe that THROWS counts as "no marker" — on a first bootstrap the state bucket does not exist yet — which is also the safe direction, since it can only send the command toward the profile's region and never away from storage that exists. All three commands go through ONE function, which is the point: a previous attempt at #2029 (in the PR that shipped the #2065 fold) moved the READ side alone and had to be reverted, becausecdkd bootstrapwrites the keygcandbootstrap --destroyread — for a user with a non-us-east-1 profile that made gc report "not opted in" and the teardown "nothing to delete" while the asset bucket and ECR repo stayed alive and billing, the failure directionbootstrap-destroy.ts's own header calls the worse one. The pairing is now structural rather than three call sites agreeing. The sequencing each site records: the reconciliation must ask "does a marker exist under the old default?", which needs the state backend, which needs the bucket, which needs the account id — so the account lookup runs on a bag built from the UNRECONCILED region, which is safe becauseGetCallerIdentityis region-agnostic and the state backend re-resolves the account-scoped state bucket's own region itself. Only genuinely region-specific clients (ECR, the asset-S3 baglistS3Candidates/deleteS3Candidatesuse) are built afterwards. Live-verified against real AWS, both polarities, non-destructively: with profileap-northeast-1and a marker there,cdkd gc --dry-runcollected in ap-northeast-1 and found 20.6 MiB of garbage that the pre-fix binary would have left untouched while looking at us-east-1; with profileeu-west-1and no marker there, it printed the hold message and operated on us-east-1. Tests:tests/unit/cli/region-effective.test.ts(13 cases on the resolver + both reconciliations, every one mutation-probed — removing the profile step or the hold reds them), plus new per-command cases. One defect in this very change was caught by probing rather than by the green suite:gc-region-resolution.test.tsandbootstrap-destroy.test.tsdid not mock@aws-sdk/client-sts, which the resolver builds to ask the SDK chain what the profile resolves — so they read the DEVELOPER'S real~/.aws/config, green on a us-east-1 machine and red on any other. Found by re-running them underAWS_CONFIG_FILE=<a config with region = ap-northeast-1>, which failed 4 cases; both now mock it and all 131 cases across the four suites pass under either config. The state-key family (deployand friends) deliberately still uses the literal — same migration question, its own integ, tracked as issue #2100.--region US-EAST-1no longer breaks every non-localcommand, andcdkd gcstops pinning aus-east-1literal over the profile region (issues #2065 / #2029) — newsrc/cli/region-options.ts, 18 files undersrc/cli/commands/,src/utils/aws-clients.ts. Issue #1795 folded--regionat the boundary of the fourcdkd local *commands for a reason that was never specific to them: SDK endpoint resolution, SigV4's credential scope, this repo's ownPARTITION_TABLEprefix walk and every ARN segment built from the value are all case-SENSITIVE. The commands it skipped kept the raw spelling, and #2065 measured what that cost —cdkd deploy --region US-EAST-1died at the state-bucket preflight before doing anything (AuthorizationHeaderMalformed: the region 'US-EAST-1' is wrong; expecting 'us-east-1'), which is also why the #2021 lane recorded its own deploy-path harm narrative as UNREACHABLE. The defect was one SHAPE, not one site:options.region || process.env['AWS_REGION'] || 'us-east-1'appeared 23 times undersrc/cli/, 19 of them unfolded. All 19 now route throughfoldRegionOption(options)at the handler entry (aboveapplyRoleArnIfSet, because STS rejects a non-canonical region too —SignatureDoesNotMatch: Credential should be scoped to a valid region) plusnamedCliRegion(...)at the read. The fold covers the two ENV vars as well as the flag, and that half is the one that matters most: a handler buildingnew AwsClients({})with no region hands resolution to the SDK's own chain, which readsAWS_REGION/AWS_DEFAULT_REGIONDIRECTLY — no fold at a cdkd read site can reach that read, soAWS_REGION=US-EAST-1 cdkd deploywas broken independently of the flag. It also canonicalizes the ~50providerRegion = process.env['AWS_REGION']captures acrosssrc/provisioning/providers/**at one point, which is the layer issue #1881 argued for.AwsClients' constructor folds its configured region as a last line of defence, soclient.config.region()is canonical for every CONFIGURED bag including a LIBRARY caller that never runs a CLI handler. The raw spelling is deliberately preserved where one consumer still needs it: the bootstrap marker's second probe exists to find a key an unfoldedcdkd bootstrapwrote (issue #1820), sodeploy.ts/publish-assets.tscapturerawCliRegion(...)on the line ABOVE the fold and hand only that toAssetModeResolver.resolve;gc.tsandbootstrap-destroy.tskeep therawRegion/regionpair they already had. #2029 is the second half, and what it turned out to BE is worth recording, because a first cut got it wrong. gc resolved the literal into a value and passed it tonew AwsClients({ region })unconditionally, so a user who named no region at all — no--region, noAWS_REGION, but a configured~/.aws/configregion — gotus-east-1pinned OVER their profile, silently in both directions because us-east-1 is a valid region: gc evaluated (and could DELETE in) a region they never mentioned while reporting success about the one they work in. The reported symptom is aus-east-1literal winning over the profile region; the DEFECT underneath it is that the literal was materialised into a value while the CLIENTS were built from a different resolution, so the two could disagree.cdkd gcandcdkd bootstrap --destroynow derive both from ONE value —aws-clients.tsis explicit that a region-less bag's lazy members need not agree with each other, so reusing an unconfigured bag would have reintroduced the divergence somewhere subtler. Its region is ALSO a value (it keys the marker and names the asset bucket / ECR repo), so client region and marker key are one value by construction; fixing only the client half would have read one region's marker and deleted against another region's endpoints, which is worse than the bug. The literal itself is deliberately NOT swapped for the profile region, and a first cut of this change did exactly that before round 2 caught it.cdkd bootstrapWRITES the marker under the same?? 'us-east-1'default (issue #1820), so a read side resolving the profile instead stops finding the marker its own create side wrote: for a user with a non-us-east-1 profile and no flags,cdkd gcwould report "not opted in" andcdkd bootstrap --destroywould report "nothing to delete" — while the asset bucket and the ECR repo stayed alive and billing, which is the worse failure direction on both commands and the exact onebootstrap-destroy.ts's own header warns about. The read side cannot move until the write side moves with it. So #2029's reported SYMPTOM stays open, tracked with its mechanism in #2100, while the divergence that caused it is fixed here. gc's delete plan (and its--dry-runplan) now also NAME the region, which was inferrable only by accident before — the default storage name embeds the region, but a bootstrap run with--asset-bucketprints a custom name with no region anywhere, so-ydeleted in a region the user was never shown. The same literal-vs-profile change is deliberately NOT made anywhere else: fordeployand friends theregionvalue keys the state file, so moving it would strand existing state at the old key — that is issue #1820's migration question, not a bug fix, and issue #2100 carries the remainder with its site list re-derived by grep in both directions (the 18 sites that carry the defect, AND the sites where the literal is CORRECT and must not be "fixed":aws-region-resolver.ts's decoding of S3's emptyLocationConstraint, and the global IAM client's signing region).
Three findings from this lane's own review rounds are worth recording, because none was visible to any test that existed. cdkd bootstrap --destroy is dispatched from a DIFFERENT handler than cdkd bootstrap, so the first cut's fold — placed in bootstrapCommand — never ran for it at all; it carried BOTH defects, and it deletes. And cdkd bootstrap's own region is now deliberately the one VALUE this change leaves raw: it feeds ensureAssetStorage, whose existing-marker READ is paired with its marker WRITE, and folding it silently moved the write key so a user holding a raw-key marker would stop having their recorded custom asset names reused. And the FIX for that second one over-applied: it assigned the raw spelling to bootstrap.ts's region wholesale, which reaches the CreateBucket guard at region !== 'us-east-1' — re-creating issue #1888's defect inside cdkd bootstrap, where an upper-cased region would send a LocationConstraint for the one region S3 forbids it in — plus three SDK clients that would then sign for a region SigV4 rejects. The raw spelling is now scoped to the single ensureAssetStorage({ region }) argument that needs it. All three are pinned by tests; the second is why every claim that cdkd bootstrap still writes a verbatim key remains TRUE after this PR, and the third is why round 2 exists at all — it is a defect that did not exist when round 1 ran. One residual is PINNED rather than fixed: cdkd local start-api folds the flag but not the env half, so an upper-cased AWS_REGION still reaches every Lambda container it starts (issue #2103, found BY the scanner below). src/cli/commands/local-*.ts sits behind the integ-local merge gate, so folding it here would pull a real-Docker run onto a PR in a different command family. The scanner carries it in a KNOWN-violations list with an exact count, so the count can only SHRINK, a new violation in that file fails, and fixing #2103 fails the test until the entry is deleted — the opposite of a path allow-list, which goes inert silently.
Tests: tests/unit/cli/cli-region-fold.test.ts (130 cases — helper polarities, plus a SCANNER over every file in src/cli/commands/ that fails on any unfolded occurrence of the shape, exempting canonicalizeRegion(...)-wrapped and raw*-bound spellings BY SHAPE rather than by a path allow-list, with a floor so "found nothing" cannot pass as "all clean"; calibrated against the pre-fix tree, where it reports exactly 19 violations and no false positives; plus an ORDER fence that the raw capture precedes the fold precedes the first AWS call, since swapping those two lines fails nothing and silently collapses the marker's second probe onto its first), tests/unit/cli/gc-region-resolution.test.ts (8 cases), tests/unit/utils/aws-clients-region-fold.test.ts (7 cases, including one asserting the fold reaches the CONSTRUCTED client rather than only the reported config). Every case mutation-probed. The last of those files also fences a constraint that had never been written down and that this lane tripped over: src/utils/aws-clients.ts is imported as '../src/utils/aws-clients.ts' by scripts/audit-provider-coverage.ts, which runs under node with native type stripping and resolves relative specifiers LITERALLY — so the obvious import { canonicalizeRegion } from './aws-partition.js' turned 32 gen-nested-key-coverage cases red, several files from its cause. That file therefore inlines the one-line fold and carries a test pinning it byte-equivalent to canonicalizeRegion plus a test that the file gains no relative import at all.
- The substring redaction becomes ONE rule over the whole leaf, so a recorded secret occurring inside a
{{resolve:...}}token stops being spliced into it, and the outputs walk stops claiming its bag is this generation's (issues #1935 / #1943) -src/deployment/secret-redaction.ts,src/deployment/deploy-engine.ts. #1935:redactSecretsForState's sourceless walk guarded a leaf that IS a complete{{resolve:...}}token, but a MIXED leaf holding a token inside a larger string fell through to a blanketvalue.replace(regex, ...). So a plaintext that happens to occur inside the TOKEN'S OWN TEXT was spliced into the reference: deploy 1 persistsjdbc://appdb:{{resolve:secretsmanager:appdb/creds:SecretString:password}}@host, deploy 2 records an ssm SecureString whose plaintext isappdb, and the walk writes{{resolve:secretsmanager:{{resolve:ssm:/app/dbname}}/creds:...}}. The rollback executor'sresolveReplayPropsscans with([^}]+), which stops at the first}, so the replay asks Secrets Manager for the secret id{{resolve:ssm:/app/dbname- rollback blocked, or garbage applied to a live resource;cdkd scrubwrites the same wreckage intopropertiesandobservedProperties. Both obvious fixes are wrong, and the second one shipped in this lane's first round before the security review caught it - the correction is the interesting part. "Mask only OUTSIDE the spans" stops redacting a secret whose resolved PLAINTEXT is itself a token (the #1917 shape embedded in a larger leaf), trading a mangling bug for a disclosure. Adding "...unless the span IS a recorded plaintext" fixes that and still loses a plaintext that STRADDLES a span boundary or CONTAINS a whole span - it belongs to neither half, so it was persisted IN THE CLEAR where the pre-fix code had redacted it. Measured on both sides with one probe: for a recorded plaintext ofpw{{resolve:ssm:/a/b}}tail, the pre-fix code produced the expression and the two-rule form producedjdbc://user:pw{{resolve:ssm:/a/b}}tail@host. If a secret's value can BE a reference (#1917), it can CONTAIN one. The shipped form is therefore ONE predicate over the whole leaf - replace every recorded-plaintext match EXCEPT one lying STRICTLY INSIDE a complete{{resolve:...}}span (contained by it and shorter than it) - so a match that is coextensive with a span, contains one, straddles one, or is disjoint from all of them is replaced. The single pass also preserves needle PRECEDENCE:buildNeedleRegexsorts alternatives longest-first, which only holds WITHIN one scan, so the split form let a short needle in the tail beat a long straddling one and write the WRONG expression (the #1910 class, re-applied by the replay). Spans come from the existingDYNAMIC_REFERENCE_TOKEN_SCANrather than a new token grammar (issue #1936 keeps one spelling), read withmatchAllafter an explicitlastIndexreset -matchAlldoes not MUTATE the shared constant but it SEEDS its clone fromlastIndex, so a dirty value would silently skip leading spans and restore the splice. It lands in the SHARED arm, so all fivePathSourceRulesconstants and every sourceless walk - the journal'spreviousState,attributes, andredactByPath's four fallbacks (a key the source lacks / a diverged shape / an unpaired element / a public-reference leaf, one case each) - inherit it from one place. Two residuals are pinned by tests rather than left as prose: an UNTERMINATED{{resolve:opener forms no span, so a needle after it is still replaced and the result reads as a reference to a bogus secret id (identical to the pre-fix behavior - treating a bare opener as protected would leave PLAINTEXT behind two characters any string can contain); and a value ALREADY mangled by an older cdkd parses as a valid span now, so it is neither made worse nor repaired, andcdkd scrubcannot repair it either - affected records need the resource redeployed or the key edited out of state. The whole-token early-out is kept as a cheap early-out for the re-scrub-of-clean-state path and is now an EQUIVALENT mutant, which the tests say out loud. Two rationale comments that the fix made false are corrected in the same pass (PathSourceRules' whole-value note andredactByPath's, plus the twin sentence in.claude/rules/code-layout.md): the scan no longer splices, so the whole-value fallback and the full scan now agree for that shape by two independent mechanisms rather than one. #1943 item 1:DeployEngine.redactOutputspassed the defaultTEMPLATE_DERIVED_RULES, whosedescendArrays: trueasserts the bag was PRODUCED by resolving the source. Two of its three call sites cannot say that -redactStateForPersistredacts whateverstate.outputsholds, and the no-change path persistspersistedOutputs, the PREVIOUS deploy's bag, whenever a resolution failure keeps today's from landing - and the consequence is not merely a missed redaction:redactByPathreturns a known-secret SOURCE leaf VERBATIM, so a carried literal at indexiis rewritten to today's expression at indexi, a value the stack never held, persisted intostate.outputsand re-applied to consumers by the exports index. It now passesTEMPLATE_SOURCED_RULES. Reachable rather than theoretical, narrowly:TemplateOutput.Valueisunknownand cdkd does not enforce CloudFormation's string-valued-output rule, so a list-valued output (an escape hatch, an imported template) puts an array on BOTH sides, andStackState.outputsis explicitly not string-coerced. The issue's premise was stale and is corrected here: it saidcdkd scrubhad already moved its outputs call onto a TEMPLATE_SOURCED constant. It had not - PR #1944 moved scrub'spropertiescall and deliberately kept the default for outputs, on a measurement ("the array arm is never reached") that rests on CloudFormation's rule rather than on what cdkd enforces. Converging the scrub twin is issue #2099; until it lands the generation table carries the two outputs writers as SEPARATE rows, because one row could only be wrong about one of them. #1943 item 2 needed no code: PR #2025 already moved all three walk accumulators toObject.create(null), verified by reading them. What was missing is a per-walk fence at this module's own level - the value scan had one, the path pass and the readback refusal were covered only jointly and only through the CLI - sosecret-redaction-proto-key-walks.test.tsadds one case per walk, each probed by mutating that accumulator alone. Tests: 27 new cases across three files (secret-redaction-mixed-leaf-spans.test.ts,secret-redaction-proto-key-walks.test.ts,deploy-engine-outputs-generation-rules.test.ts), one case per POSITION a match can take plus the precedence case and both residuals, every one mutation-probed: restoring the blanket replace reds 13, collapsing to the two-rule form reds the 5 cases that form got wrong (both #1917 counter-cases plus straddle / contains / precedence), dropping the coextensive exception reds the 2 #1917 cases, seeding the shared scan'slastIndexreds the leading-span case, and reverting the constant swap reds the engine case with the fabricated:AWSCURRENTexpression it would persist. The per-constant matrix is reported at the strength it has and no more - test review measured therulesargument to be INERT on the source-lacks-key path, so those five rows pin "no constant BYPASSES the shared walk", not per-constant behavior. - A rollback replay now resolves a secret reference in the region it NAMES, refuses a region-ambiguous one, and a failed deploy stops erasing the cross-stack reads it just made (issue #2057) —
src/deployment/rollback-executor.ts,src/deployment/deploy-engine.ts,src/cli/commands/rollback.ts. Behaviour change: a rollback that used to succeed while writing the WRONG secret to a live resource now either resolves the right one or fails loudly. Since issue #1934 a cross-stack consumer re-resolves a redacted producer value in the PRODUCER's region and records the PRODUCER's spelling of the expression into its ownstate.json— the right thing to persist, and region-less.replayRollbackrebuilt its resolver fromctx.regionalone, soresolveReplayPropsre-resolved that reference against the CONSUMER's region and handed a provider whatever a same-named secret holds there; a Secrets Manager secret (or an SSMSecureString) of the same NAME in two regions is two independent values, so the replay applied a different credential than the deploy did — silently, on the recovery path, to a resource that is live.classifyReplaySecretRegion(new, exported) now returns one of three verdicts per reference, which is issue #1957's "a named region binds; never substitute a guess" spelled out:named-region— the SECRET_ID / parameter name is an ARN naming another region, so a resolver pinned to THAT region answers (ReplayResolvers.forRegion, cached per region); cdkd would otherwise send it to the stack's own endpoint, sinceresolveSecretsManagerReference/resolveSSMReferencebuild their client fromthis.explicitRegionand pass the id through opaquely.ambiguous— the reference names no region AND the stack has a foreign producer region on record, so the replay throwsROLLBACK_SECRET_REGION_AMBIGUOUSBEFORE any lookup, naming the logical id, the property path, the secret, the consumer region, the producer region(s) and the remedy.local— everything else, resolved exactly as before: every service cdkd cannot resolve at all, every same-region ARN, and every region-less reference in a stack with no foreign producer region. A leaf mixing a local reference with a foreign-ARN one is rebuilt segment by segment so each is resolved by its own region; a leaf with no foreign reference still goes toresolveDynamicReferencesWHOLE, so that method's substitution semantics (including the #1917 token-shaped-plaintext guard) are untouched. The evidence half is where the protection was nearly inert, and fixing it also fixes a pre-existing bug of its own. The foreign-region evidence is the newRollbackExecutorContext.importedProducerRegions, derived by the new exportedproducerRegionsFromState(state)fromStackState.imports[].sourceRegion+outputReads[].sourceRegion— but a rollback journal exists ONLY after a failed deploy, and all five non-success saves indeploy-engine.tspersistedcurrentState.imports/currentState.outputReads, the PRE-deploy snapshot, beside the POST-deploynewResources. So the deploy that INTRODUCES a cross-region read never recorded it, the list came back empty, and the verdict waslocal. Those saves now persistcrossStackReadsForPartialSave— the union of the snapshot with this session'srecordedImports/recordedOutputReads, deduplicated on a canonicalized region — andDeployEngine.rollbackExecutorContext(previousState)passes the same union, so the in-process automatic rollback has strictly more evidence thancdkd rollbackcan derive. Independently of #2057, that same gap silently downgraded a fresh strong reference to no reference:findActiveImportConsumers(src/cli/commands/destroy-runner.ts) scans consumers' persistedimports[]to refuse destroying a producer while a consumer imports from it, so after a failed consumer deploy the consumer's resource was recorded live while its import was not, andcdkd destroyon the producer sailed through the pre-flight;findDownstreamConsumersunder-reportedoutputReads[]the same way. The union never drops a record, so a stack that STOPS reading across a region keeps the stale entry until its next SUCCESSFUL deploy replaces the list wholesale — a deliberate fail-closed residual, recorded in the helper's own doc. Two review-round corrections worth naming because both rested on a false premise: anssmdynamic reference CAN name a full ARN (resolveSSMReferencerebuilds the name asparts.slice(1).join(':')), so the classifier mirrors that split instead of takingsplit(':')[1], which used to yield the literal'arn'and refuse a reference that names its own region; and the segment-rebuild's "token not found" guard now THROWS (ROLLBACK_SECRET_TOKEN_SCAN_MISMATCH) rather than falling back to the stack's own resolver, which would have reintroduced the defect inside the guard against it. Tests:tests/unit/deployment/rollback-executor-cross-region-secret.test.ts(fakes the leaf SDK client so the CONSTRUCTOR region of the client that answered is the assertion target, and primes DIFFERENT values in two regions so "which region answered" is observable at all),tests/unit/deployment/deploy-engine-partial-save-cross-stack-reads.test.ts, and the real-AWS fixturetests/integration/rollback-cross-region-secret— whose second rollback phase introduces the cross-region read in the FAILING deploy itself, the arm that would have caught the inert-evidence gap. - BREAKING: a custom resource whose delete handler REPORTS
FAILEDis no longer recorded as deleted (issue #2054) —src/provisioning/providers/custom-resource-provider.ts. The terminalStatus === 'FAILED'arm ofCustomResourceProvider.deletewarned and fell through toreturn undefined, whichdeleteSkipReasonreads as DELETED: the state record was dropped, the row printed asdeleted, andcdkd destroyexited 0 over a resource whose handler had EXPLICITLY said it did not delete it — with the physical id needed to reach it discarded in the same breath. It is the silent-orphan class issue #2033 removed from the sibling THROW arm and issue #1752 removed from the two guard arms above it, reached through the handler's RESPONSE instead. The arm now returns{ outcome: 'skipped', reason: CR_DELETE_HANDLER_FAILED_SKIP_REASON }, so the record is KEPT, the row prints asskipped (...)and the run exits 2. UNCONDITIONAL, with no already-gone classifier: the handler'sReasonis free text a user writes, so any classifier is a guess and a wrong guess re-introduces the orphan — the maintainer's explicit call between the issue's three options. The reason is a FIXED constant and the handler's own text goes out on thelogger.warnbeside it, because areasonis rendered into theErrorthe deploy-side replacement sites throw and their catch classifies "already deleted" by SUBSTRING. Compatibility break: a destroy whose delete handler reports FAILED now exits 2 where it used to exit 0. The two callers have DIFFERENT escape hatches and only one is a flag —cdkd deployaccepts--allow-unaddressed(issue #1960), whilecdkd destroyhas none and raisesPartialFailureErrorunconditionally, so the remedy there is the one its own summary names: confirm the resource is gone, then drop the record withcdkd state orphan <stack>. Tests:tests/unit/provisioning/custom-resource-delete-failed-response.test.ts(the skip is asserted THROUGHdeleteSkipReason, i.e. as a statement about the record, with the SUCCESS polarity and the already-gone-reason case both pinned). - The custom-resource synthetic
StackIdnow carries the real account, region and partition (issue #1866) —src/provisioning/providers/custom-resource-provider.ts. Every handler receivedarn:aws:cloudformation:us-east-1:000000000000:stack/cdkd-<logicalId>/cdkdregardless of where the deploy ran, so a CloudFormation-authored handler readingevent.StackIdto re-derive its account / region, to build an ARN, or to name a log stream got a coherent-looking ARN addressing nothing.syntheticStackIdnow takes the wholeAwsAccountInfo— deriving ONE segment in isolation is worse than deriving none, which is why issue #1815 deliberately left the hardcodedarn:aws:prefix alone — andCustomResourceProvider.resolveSyntheticStackIdresolves it throughgetAccountInfo(), keyed on the region the provider's own client bag was pinned to (captured in the constructor beside the clients, so--stack-concurrency's process-global swap cannot hand a sibling stack's region). When STS cannot answer, the account falls back to the ALL-ZERO placeholder rather thangetAccountInfo's own123456789012:StackIdis a REQUIRED member so omission — the Cloud Control enrichment answer — is unavailable, and between two wrong strings the honest one is the one a handler cannot mistake for a live account; a warning names it. The resolution deliberately runs AFTER the invocation's SIGINT watch is installed, so the addedawaitnever leaves Ctrl-C dead during the 47.75s pre-delivery backoff. ConsumesgetAccountInfo, so it inherits that function's own known defect (issue #1730) for the fabricated case. Tests:tests/unit/provisioning/custom-resource-synthetic-stackid.test.ts(per-segment, plus the create / update / delete parity and the watch-ordering fence). - A THROWN delete during an SNS subscription replacement now ABORTS instead of creating anyway (issue #1967) —
src/provisioning/providers/sns-subscription-provider.ts.SNSSubscriptionProvider.updatereplaces DELETE-first and its SKIP arm already aborted; thecatcharound that delete only warned and fell through tocreate(), so a two-arm failure had a one-arm guard. One rule now covers both: cdkd creates the replacement only when the old subscription is PROVEN gone. The refusal ismarkNonRetryable-marked and keeps the AWS text out of its message, matching the skip arm. What this does NOT fix, measured against real SNS and re-verified in review — the issue's premise was wrong. SNS enforces uniqueness on (topic, protocol, endpoint): a repeatedSubscribewith identical attributes returns the SAMESubscriptionArnand leaves ONE subscription, and with DIFFERENT attributes it is refused (InvalidParameter ... Subscription already exists with different attributes). Those three fields are exactly thecreateOnlyPropertiesofAWS::SNS::Subscription, and a createOnly change routes to the deploy engine's OWN replacement branch — the engine's onlyprovider.update()call site is in theelseof thatif— so anything reaching this method leaves all three identical and the "two live subscriptions delivering every message twice" outcome cannot occur here. So this is not a compatibility break: the pre-fix path already failed, just with SNS's confusingalready exists with different attributesraised by the create. The delta is error QUALITY — an accurate, actionable, non-retryable refusal that names the failed DELETE (the thing the user must act on) instead of a downstreamSubscriberejection — plus the case where the duplicate IS reachable:cloudformation:DescribeTypebeing unavailable degrades an endpoint change into an in-place update (create-only-properties.tswarns about exactly this), and there the endpoint differs and two subscriptions are possible. Tests:tests/unit/provisioning/sns-subscription-thrown-delete.test.ts(the discriminator is thatSubscribewas NOT called, with the successful-delete polarity pinned beside it);tests/unit/provisioning/replace-path-delete-skip-outcome.test.ts's throw-arm case is INVERTED from #1778, which had asserted the defect. Integ:tests/integration/sns-subscription-update/— the first real-AWS coverage ofSNSSubscriptionProvider.update()at all. - BREAKING: a retried 500 no longer duplicates a CloudFront distribution — it fails loudly and names the orphan (issue #2079) —
src/provisioning/providers/cloudfront-distribution-provider.ts.CallerReferenceIS CloudFront's idempotency key and was minted afresh insidecreate()fromDate.now()+Math.random()— the worst shape of all, because the call LOOKS idempotent while behaving exactly as if it had none. The deploy engine wrapscreate()in its outer transient-error retry and issue #2026 made HTTP 500 / 502 / 504 retryable, so a 500 whoseCreateDistributionhad actually SUCCEEDED server-side re-invokedcreate(), minted a fresh reference, and CloudFront created a SECOND distribution: no state record, invisible tocdkd destroy, billing indefinitely. The reference now comes fromacquireIdempotencyToken(issue #2039), stable across every attempt of one logical create and released — generation-bumped — only once the distribution is settled, so a--replacere-create is not handed the reference of the resource it just tore down. Unlike the Route 53 site it has NO adopt path, and that is measured rather than assumed:DistributionSummarycarriesCommentbut NOTCallerReference, andGetDistributionneeds theIdthe lost response was carrying. So the replay is refused withDistributionAlreadyExistsand cdkd turns that into a message naming the orphan and how to find it —aws cloudfront list-distributions, matched on the origin domain and comment, the two fields that ARE listed. Writing a cdkd marker intoCommentto make it searchable was considered and REJECTED (Commentis capped at 128 characters, so a marker can break a create that works today, andmergeUpdateConfigdocumentsCommentas fully template-owned). Compatibility break: loud-and-orphaned replaces silent-and-orphaned — the orphan existed either way, and only now does the user learn about it. Tests:tests/unit/provisioning/cloudfront-caller-reference.test.ts(identity across two attempts of one create, difference after a release, and the orphan message's contents). - Every
withRetryundersrc/provisioning/**now honours Ctrl-C, and a mechanical critic keeps it that way (issues #2053 / #1952) —src/provisioning/providers/{dynamodb-globaltable,elbv2,servicediscovery}-provider.ts,src/provisioning/dynamodb-index-busy-delete.ts,src/provisioning/interrupt-watch.ts(new),src/deployment/deploy-engine.ts,src/cli/commands/destroy-runner.ts,src/utils/interrupt-signals.ts,scripts/check-withretry-interrupt.ts.docs/provider-development.mdstates as MANDATORY that a newwithRetrythreadisInterrupted/onInterrupted, and it is structural rather than stylistic:withRetryis the only wait in cdkd that consults an interrupt DURING a backoff (it probes once a second while sleeping), while the deploy engine,destroy-runner.tsandrollback-executor.tsall poll only BETWEEN operations. Measured when #2053 was filed: 11 call sites underproviders/**andisInterruptedin exactly ONE of them — the site issue #2033 had just added. A convention one site in eleven honours is not a convention; it teaches the next author the wrong pattern. All ten are threaded now (4 inapplyAutoScalingDiff, 2 ELBv2ModifyListenerAttributes, 4 ServiceDiscovery —DeleteNamespaceplus three attribute calls), plus the twelfth site indynamodb-index-busy-delete.ts, which is #1952: the index-busyDeleteTableloop kept re-issuing deletes for the better part of twenty minutes after a Ctrl-C, on the DESTROY path wheredestroy-runner.ts'swithResourceTimeouthad ALREADY abandoned the promise — so the work continued detached behind a run the user was told had ended. #1952's stated blocker did not survive contact with #2033's fix and no interface changed. The issue said providers cannot see interruption because there is noisInterruptedon theResourceProviderdelete contract; #2033 answered that by watching the PROCESS SIGINT signal — the same source the engine's own handler reads — through a watch created per WAIT, never onthis. That distinction is load-bearing and is why a flag on the provider would have been wrong: providers are registered as SINGLETONS serving concurrent resources, so provider-level state is some other resource's. The watch now lives in ONE module,src/provisioning/interrupt-watch.ts(issue #2104, filed and closed inside this change). It started as four module-local copies, and review found that shape was not merely duplicative but WRONG in two ways that only consolidation could fix, because a singledelete()traverses more than one module. (1) One error type.deploy-engine.tsdecides whether to ROLL BACK by asking what the failure was, and itsInterruptedErroris module-private — no provider can produce one. The first cut'sonInterruptedreturned a bareError, providers wrapped it in aProvisioningError, and the engine took the ROLLBACK branch: Ctrl-C during a deploy triggered an automatic rollback of the whole stack, where the same Ctrl-C previously exited with "partial state saved". On the create paths it was worse still — the interrupt landed in the partial-create cleanup arms, which issueDeleteListener/DeleteService, so Ctrl-C meant "delete what you just made, then roll back".onInterruptednow returns an exportedInterruptedWaitError; the engine matches it throughisInterruptedWaitError, acausewalk bounded at depth 5 (every provider catch re-wraps, so a plaininstanceofwould have been a placebo); and both cleanup arms re-throw ahead of their delete. The same bare-Errorshape shipped in PR #2033's own site and is fixed here too, so this closes that residual rather than leaving the original unfixed while fixing its ten copies. The rollback gate is the load-bearing half and its classifier walks thecausechain with avisitedset and NO depth ceiling: a ceiling has to be sized against the deepest real chain, and that chain GROWS — flat is 2,DagExecutoradds none (it collects rather than wraps), butdeploy-engine.tsadds oneProvisioningErrorPER NESTED-STACK LEVEL, so a depth cap of 5 missed at four levels of nesting and reinstated the automatic rollback there. (1b) The partial-create cleanup arms still clean up. The first cut made them re-throw ahead of theirDeleteListener/DeleteService, on the reading that a Ctrl-C must not delete what the user just made. That reading assumes the resource is TRACKED and it is not:create()is throwing, sonewResources[logicalId]is never set, the journal recordsphysicalId: undefined, and the rollback executor classifies itskip-failed-unknown. Nothing holds the physical id, so the real choice is delete vs orphan forever — a preserved listener fails every later deploy withDuplicateListenerwhilecdkd rollbackskips it andcdkd destroyhas no record of it, and an orphaned Cloud Map service additionally blocks its namespace's deletion withResourceInUse, all while the engine prints "run deploy again to resume,cdkd rollbackto revert, or destroy to clean up". Both arms clean up, and each now prints the physical id and a manual delete command BEFORE attempting it, so a process killed mid-cleanup still leaves a handle. (1c) An interrupt is no longer read as "already deleted".destroy-runner.tsanddeploy-engine.tsboth decide a failed delete means the resource is gone by SUBSTRING-matching the message, and an interrupt's message embeds a name the USER chose — so a logical id likeHandleNotFoundExceptionmade an interrupted delete report success and DROP a live resource's state row. Both now checkisInterruptedWaitErrorahead of the match, mirroring theisFinalSnapshotErrorguard already there for the same class of mistake. (2) One STICKY latch. A watch started after the signal must already be interrupted. The first cut cleared the latch when the last watch was disposed, which SEQUENTIAL waits always do: aGlobalTabledelete runs the #1521 gate, then the index-busy loop (~18 min), then the gone-wait (~12 min), each disposing before the next begins — so a Ctrl-C during the gate left both later waits deaf, which is issue #1952's own scenario surviving its own fix. Only a COMMAND clears it now (forwardSigtermToSigint()), and the process listener is installed once and never removed for the same reason: one torn down between two waits cannot record a signal landing in the gap. (3) It arms only inside a command that OWNS interrupt handling. Any listener disables Node's default terminate, andcdkd drift --revertreachesprovider.updatewhile installing none of its own — so an armed watch would have stopped that command exiting on Ctrl-C and left it writing to AWS. Gating onprocess.listenerCount('SIGINT') > 0was the first cut and is defeated by the very case it targets:driftrunsprovider.updateat concurrency 4, and a concurrent CloudFront / ACM / Route53 wait installs a TRANSIENT SIGINT listener, so a wait starting inside that window saw a non-zero count and armed permanently. The gate is now an explicit scope opened byforwardSigtermToSigint(), whichdeploy/destroy/rollback/statecall anddriftdoes not. (4) The handler force-quits when it is the LAST SIGINT listener. A command scope is not the same as a live graceful path:destroy.tsregisters no SIGINT handler anddestroy-runner.tsremoves its own in afinally, so between two stacks of a multi-stack destroy the shared watch is the ONLY listener — and merely latching there SWALLOWED the Ctrl-C, leavingdrainingunset,result.interruptedfalse, and the loop free to delete the NEXT stack after the user asked to stop. Alone, the handler now does exactly what Node would have done with no listener at all — plus acdkd force-unlockhint it cannot verify but cannot afford to omit; a second graceful path was rejected because it would duplicatedestroy-runner.ts's drain and have to be kept in sync with it. (4b) ...andrunDestroyForStacknow releases the lock BEFORE unregistering its handler. Property 4 made a SECOND window reachable, in the runner's ownfinally, which removed its SIGINT handler first and released the lock last:destroy.ts/state.tsregister no handler of their own, so from that removal until the release resolved the command held the lock with no handler at all, the force-quit fired, and the release never ran — the lock stranded for its full 30-minute TTL, on every stack of a--allrun once any earlier stack had armed the watch. That is the issue #1348 class the file already claimed to have closed, and the same file states the correct rule verbatim for its strong-ref refusal path ("Release FIRST, remove the listener LAST"). Both paths now agree, the removal sits in its ownfinallyso a throwing release cannot leak the handler, andtests/unit/cli/destroy-runner-lock-release-ordering.test.tspins it by capturing which listeners are registered at the momentreleaseLockis entered — mutation-probed toexpected 0 to be 1under the old ordering. (4b-ii) ...and the reorder MOVED the windowresult.interruptedis read in.destroy-runner.tsassigns it once, inside thetry, after the level loop; keeping the handler armed across the renderer teardown, the state flush and the release put a whole class of first-Ctrl-C signals AFTER that read, sodrainingflipped too late and the flag stayed false.destroy.tsregisters no SIGINT handler of its own (deploy.tsdoes — which is why the deploy path has no equivalent hole) and reads exactly that flag, socdkd destroy --allwent on to delete the NEXT stack after the user asked it to stop. Pre-reorder the same signal hit the force-quit and exited 130: lock stranded, stack B untouched — so the reorder had traded a 30-minute TTL for a destroyed stack, the worse side. Fixed withresult.interrupted ||= drainingat the end of thefinally(thereturnis after it, so the caller sees the corrected value), and marked TACTICAL in the code: that flag being the ONLY channel to the--allloop is the real defect, filed as #2117 rather than fixed here, because this was round 5 and each of the previous four rounds' fixes had created the next round's blocker. The nestedfinallyalso now opens atrenderer.stop()rather than at the release, andstop()itself is caught: it writes to stdout, so an EPIPE on a closed pipe (cdkd destroy | head) previously skipped the release AND leaked the handler — measuredthrew=EPIPE releaseLock=0 leakedListeners=1. The enclosingfinallyprotects the listener; only the catch protects the lock. (4c) Command scopes NEST. The gate is a depth counter rather than a boolean: under a boolean an inner scope's close removed the shared listener and cleared the OUTER command's sticky latch mid-run, so a signal in the re-arm gap went unrecorded — property 2's own bug, through a door property 2 does not watch. Unreachable on today's one-command-per-process CLI, but the module offers itself to a host that runs more than one. One shared listener also keeps a--concurrency 10run under Node's ten-listener warning ceiling, which onlydestroy-runner.tsraises. #1952 also asked whether the other bounded waits on the delete path are owed the same treatment, "since interrupting only one of them buys little" — they are, and they got it:waitForIndexesSettled(which serves BOTH the #1521 pre-delete gate and the retry re-arm, on both DynamoDB types, so threading it needed no call-site change),waitForReplicaGoneandwaitForTableGone. The two gone-waits answer the signal DIFFERENTLY, mirroring the throw/warn split they already carried for budget exhaustion:waitForReplicaGoneTHROWS (it runs beforeDeleteTable, which AWS refuses while a replica lives, so nothing has been accepted),waitForTableGonewarns and RETURNS (AWS accepted the delete, so throwing would turn an accepted delete into a reported failure with state preserved), andwaitForIndexesSettledstops waiting without throwing, which is the contract every other give-up arm in it already keeps. The regression guard isvp run audit:withretry-interrupt:check, wired into CI besideaudit:provider-error-cause:checkandcache: falsefor the same reason — a green that replays from cache is a checker reporting "all threaded" without having looked. It parsessrc/provisioning/**with the TypeScript AST and checks PROVENANCE, not presence: both options must be direct properties of an options OBJECT LITERAL, must read off the SAME identifier, and that identifier must be bound fromstartInterruptWatchimported frominterrupt-watch.jsin that file. Presence alone was the first cut and a reviewer broke it in one line —isInterrupted: () => falsedeclares both names and disables the mechanism, so the checker was verifying its own spelling rather than its own effect. Half a pair is still a defect (withoutonInterrupted,withRetrythrows a bareError('Interrupted')naming no resource; withoutisInterruptedthe other half is never called), a conditional spread does not count, and an options bag it cannot READ isopaquerather than skipped. A SECOND rule runs per file: every watch must bedispose()d from inside afinally— the highest-value shape now that the latch is sticky, since a leaked watch stays live for the process and makes every later wait abort instantly after one Ctrl-C. That rule took three rounds to get right, each round a constructed false-clear rather than a hypothetical: a name-only key let one method's correctfinallycredit a sibling method's leak (found by the break-test, which reported nothing at all); keying on name-plus-enclosing-FUNCTION then merged two same-named watches in sibling BLOCKS of one function; and afinally { if (flag) watch.dispose(); }or adispose()inside an unrelated nested callback'sfinallyboth passed while releasing nothing reliably. The dispose is now credited to a DECLARATION resolved by lexical scope, must be an unconditional statement of thefinally, and must sit in the same function as the declaration. All three cases are permanent probes. Import ALIASES are resolved on both sides —import { withRetry as retryOp }used to yield ZERO sites silently, the exact "found nothing passes as everything matches" shape the floors exist to prevent and which they could not see, because they count per file. FLOORS on files scanned / sites found / sites threaded / files carrying a site / watch bindings, each with its OWN probe (a one-file tree trips them all at once, so none had been exercised alone). Eleven self-probes with known verdicts INCLUDING failing ones run before the tree is read, and the one exemption (describe-type.ts, an analysis-path schema fetch unreachable from any provider operation) is re-audited so an entry whose subject no longer has awithRetryfails instead of going quietly inert. Tests:tests/unit/provisioning/interrupt-watch.test.ts(17 cases on the shared module itself — both polarities of the arming rule against the REALprocess.listenerCountpredicate, the sticky latch across three sequential waits, TEN overlapping watches sharing one listener, and every arm of the bounded cause walk including a cyclic chain and a look-alike whose message andnamematch but whose type does not),tests/unit/scripts/withretry-interrupt.test.ts(31 cases, every failure probe taken against a scratch COPY so none touchessrc/) plus four behaviour suites —elbv2-listener-attributes-interrupt,servicediscovery-interrupt,dynamodb-index-busy-delete-interrupt,dynamodb-globaltable-delete-interrupt. Every case fires the signal during a NAMED attempt and pins the ATTEMPT COUNT, because an un-threaded site also throws eventually — that is the whole complaint — so a test asserting only "it rejected" passes with the fix deleted. Each suite is mutation-probed: dropping the threading turnsexpected 9 to be 2on the auto-scaling teardown, a 47s schedule and an AWS-worded rejection where the interrupt error belongs on ELBv2, four failures on ServiceDiscovery, and 120s test timeouts on both 600-poll gone-waits. Each suite also carries the opposite polarity — an ordinary retry with nothing interrupting — so a watch stuck reporting "interrupted" cannot pass; an independent probe confirmed the counts really are the discriminators (isInterrupted: () => falsefails all 11 interrupt cases with all 5 controls green,() => truefails all 4 controls). Three further cases came out of review: the two partial-create suites now assert the cleanup delete does NOT fire, the gone-wait's "AWS ACCEPTED the DeleteTable" warn is pinned on its TEXT (deleting that warn left the suite 3/3 green, because only the early return was pinned, and that line is the one thing telling a user their delete will still complete), and a provider-level latch case fires the signal inside the #1521 gate and asserts the gone-wait after it issues ZERODescribeTablecalls rather than its 600. - All four spellings of the dynamic-reference token pattern now derive from one constant, and the strictest one stops persisting plaintext (issue #1936) —
src/deployment/secret-redaction.ts,src/cli/commands/drift.ts.IntrinsicFunctionResolver.resolveDynamicReferencesscans with/\{\{resolve:([^}]+)\}\}/g, which is the AUTHORITY on what cdkd will actually try to resolve. Three predicates downstream answered a different question:secret-redaction.ts'sisSingleDynamicReferenceTokenanddrift.ts'sisWholeDynamicReferenceboth spelled the inner class[^{}]*, whiledrift.ts'ssurvivingDynamicReferencesspelled it[^}]+— with a comment arguing the split was principled. It was not. For a reference whose inner text carries a{(a Secrets Manager JSON key or secret name, e.g.{{resolve:secretsmanager:app/db:SecretString:my{key}}) the resolver resolved it fine while the strict spelling said it was not a single token, soredactByPath's whole-token source arm refused it — and on an EMPTY-secrets-map path (cdkd scrub's cross-generation observed walk, whose value scan has no needles by construction) the RESOLVED PLAINTEXT was persisted verbatim intostate.json. Narrow, pre-existing, and a disclosure. The{-exclusion bought nothing: the concatenated / spliced shapes it seemed to guard ({{resolve:a}}{{resolve:b}}) were already refused, because an ANCHORED[^}]+cannot cross the first}either — a claim to the contrary circulated in review and is false.DYNAMIC_REFERENCE_INNER/WHOLE_DYNAMIC_REFERENCE_PATTERN/ the now-exportedisSingleDynamicReferenceTokenare the single source of truth;SKELETON_WILDCARDand this module's own fourth spelling (dynamicReferenceTokens, previously[^{}]*global) derive from the same character class, anddrift.tsimports the predicate under its own name instead of carrying a hand-copied twin. That the twin stayed byte-identical to its sibling is exactly why the copy was not the problem: being identical to the wrong answer is not agreement. Two user-visible deltas beyond the disclosure:--revertno longer pushes a literal braced token over a livessm-securevalue (preserveLiveValuesAtUnresolvedTokensnow recognizes it as a whole token, preserves the live value AND registers it with the maskers), and a MIXED leaf embedding a braced PUBLIC{{resolve:ssm:token is now classified by the same #1901 rule as every other token rather than being invisible and unconditionally over-redacted.{{resolve:}}(empty inner text) is no longer called a token by anything, matching the resolver. Tests:tests/unit/deployment/secret-redaction-dynamic-reference-pattern.test.ts— including the fence the issue asked for, which READS the resolver's own source and asserts its scan capture is the same STRING as the shared constant (a hand-copied literal in the test would have been a fourth spelling), a fence that neither consumer carries an inline character class any more, and end-to-endcdkd scrub/--revertcases asserting WHICH string each bag ends up holding. Every case is mutation-probed. The issue #2088 review then folded in the rest of that review's findings: the assembled PATTERN was still byte-duplicated across the two files (only the character CLASS had been shared), sodynamicReferenceTokensis now EXPORTED anddrift.tscalls it; the per-callnew RegExpat both sites was justified by alastIndexclaim that is FALSE for this use (measured:String.prototype.matchwith a/gpattern setslastIndexto 0 on entry and leaves it 0), so the pattern is a module constant and the comment now says which methods DO advance it; the no-inline-spelling fence enumerated ONE bad spelling, and widening it to three did not survive review either -- a fourth (re-duplicating the ASSEMBLED pattern from the shared class) and a fifth (a.+?class, which never opens with[^) both passed, so the fence now states the GOOD condition positively: a regex matching a{{resolve:token must escape both braces, and only the resolver (once, as the authority) andsecret-redaction.ts(twice) may write one, ZERO anywhere else undersrc/**. Writing the resolver's own site into that table was not planned -- the fence FLAGGED it, which is the rule working, and an exempt-by-name list would have hidden a second spelling appearing in the one file where it matters most. The hoisted scan'sgflag is pinned too (.global === true, the missing half of its sibling's.global === false) plus a two-token case at the unit that owns it, since droppinggsilently makes.matchfirst-match-only and would let anssm-securetoken that is not FIRST go unreported bycdkd drift; and the leak SCOPING was incomplete --cdkd scrubleaks on BOTH its walks, thepropertiesone underTEMPLATE_SOURCED_RULESas well as the cross-generation observed one, because it resolves best-effort and records the position source unconditionally while recording the secrets map only when non-empty. A second security review then caught the constant's own JSDoc claimingmixedLeafMayCarryPublicReferencewas its ONLY reader, one paragraph below the text addingdrift.tsas a second one -- the sentence a later editor uses to bound the blast radius of touching the class, so it now names both. - An upper-cased region no longer misses the bootstrap marker, and the canonical-then-raw marker read becomes ONE shared helper (issue #2021) —
src/assets/asset-storage.ts,src/cli/commands/{gc,bootstrap-destroy,local-state-loader}.ts. What is and is not reachable today was MEASURED against real AWS while building the live-test arm, and the issue's own deploy-path harm narrative did not survive that measurement — so this entry deliberately does not repeat it. SigV4 rejects a non-canonical region at every service cdkd calls (AuthorizationHeaderMalformedfrom S3,InvalidSignatureExceptionfrom Lambda / ECR,SignatureDoesNotMatchfrom STS), anddeploy.tsdoes NOT canonicalize wheregc.ts:943andbootstrap-destroy.ts:338do — socdkd deploy --region US-EAST-1dies at the state-bucket preflight and never reaches the marker read at all. Thebody === nullarm the issue describes needs a CANONICAL client region paired with a RAW key spelling, and on the deploy path those are the same string; the "silent downgrade then collected bycdk gc" could not publish anything either, since the legacy destinationcdk-hnb659fds-assets-<acct>-US-EAST-1is not a legal S3 bucket name. That deploy-boundary fold is issue #2065, filed with the measurement; until it lands, the reachable value of this change is the two DESTRUCTIVE callers (cdkd gc,cdkd bootstrap --destroy), which do fold and do reach the helper under an upper-cased region, plus the shared-helper consolidation and the cache-slot fix below. Third instance of the region-case defect fixed forcdkd gcandcdkd bootstrap --destroyin #1995, and the one whose failure surfaces LATEST. Both deploy-time callers derive the asset region asoptions.region || AWS_REGION || 'us-east-1'and thenstack.region || baseRegion, so an env-agnostic stack — the CDK default — undercdkd deploy --region US-EAST-1(orAWS_REGION=US-EAST-1) handedAssetModeResolver.doResolvethe upper-cased spelling, which looked forcdkd-bootstrap/US-EAST-1.json, missed the markercdkd bootstrapwrote atcdkd-bootstrap/us-east-1.json, and took thebody === nullarm. A stack whoseenv.regionis pinned in CDK was unaffected (stack.regioncomes from the Cloud Assembly and is canonical). Nothing failed at deploy time and the legacy notice even fired — but the whole point ofcdkd bootstrap(#1002) is thatcdk gccannot reach cdkd's asset storage, and the legacy destination is precisely whatcdk gcDOES collect, so the failure surfaced later as assets vanishing from under deployed stacks.AssetModeResolver.resolvenow canonicalizes at its own boundary rather than at each caller, which fixes a second, quieter bug in the same line: the resolver caches by region, sous-east-1andUS-EAST-1occupied two slots and each re-probed S3. The RAW spelling still travels through to the marker read alone, because the WRITE side does not fold (cdkd bootstrapderives its region verbatim — issue #1820) and a fold-and-stop read would MISS a marker the pre-fold read HIT; everything else THE RESOLVER builds — its verification clients, its auto-create call and its notice text — now uses the canonical region. The scope stops at the resolver, deliberately:deploy.tskeeps handing the RAWassetRegiontobuildAssetRedirectMapandaddAssetsToGraph, so the publish-time S3 / ECR clients still see the raw spelling. That is self-consistent (the redirect map's region is raw both when built and when applied, so there is no split brain) and out of this lane's scope, but it does mean the#1795/#1820case-sensitive endpoint hazard is now reachable on acdkd-assets-mode publish that previously could only have run in legacy mode. The canonical-then-raw probe is now ONE shared helper,readBootstrapMarkerBody(stateBackend, rawRegion)insrc/assets/asset-storage.ts, returning{ body, resolvedKey }— the key ACTUALLY read, which every caller's error message, plan line and delete target follows. There were three hand-written copies (local-state-loader.ts,gc.ts,bootstrap-destroy.ts) before this lane needed a fourth, and they already differed in ways that are easy to get wrong. The helper deliberately does NOT catch, so each caller keeps its own policy unchanged on top of it:gc/bootstrap --destroystill translateNoSuchBucketinto their never-bootstrapped message and hard-error on anything else, whileloadBootstrapContainerRepostays best-effort and falls back to the conventional asset-repo names.bootstrap --destroyadditionally still DELETESresolvedKeyrather than the canonical key — deleting the canonical one when the body came from the raw one orphans the marker, a real review catch on #1995's PR that a naive fold would have regressed.ensureAssetStorageis deliberately NOT folded, with the reason recorded at the call site: its marker READ is paired with its marker WRITE and both use the same rawregion, so folding the read alone would break that pairing — a bootstrap underUS-EAST-1would read the canonical key and write the raw one, and a custom name recorded by an earlier run would stop being reused while a conflicting one would stop being refused. Aligning the WRITE side (and with it the asset bucket / ECR repo NAMES the same variable builds) is #1820's lane. Covered by 12 new cases intests/unit/assets/asset-storage.test.ts— 5 on the helper (one probe when canonical, canonical-first-and-stop, the raw fallback reporting the raw key, the canonical key reported on a double miss, and that it does not catch) and 7 on the resolver (an upper-cased region resolvingcdkd-assetsinstead of legacy, canonical-region verification clients, ONE cache slot across both spellings asserted on the PROBE COUNT rather than the returned mode, the raw-key marker still reachable,parseBootstrapMarkernaming the key actually read, the notice naming the canonical region so its remediation command works, and auto-create using the canonical name and key). Every one was mutation-probed; the three legacy callers' existing #1836 / #1995 suites go red against a de-canonicalized helper, which is what proves they really route through it now. - The ELBv2 LoadBalancer arm and all six ServiceDiscovery namespace arms adopt the
maskSecretscontract, andpollOperation— the site no issue named — stops leaking on arms that already had it (issues #2058 / #2063) —src/provisioning/providers/elbv2-provider.ts,src/provisioning/providers/servicediscovery-provider.ts. #2058's own cited site needed no change, and this PR closes that issue by VERIFYING rather than by fixing: theconvertTargetsdrop-warn atelbv2-provider.ts:1771-1773was already threaded by the merged #2067 (#2050) lane, which masks the value walk BEFOREJSON.stringifyand passes the masker from all three call sites — confirmed againstorigin/mainrather than assumed. What this PR fixes is #2063 plus the sites its own audit found. Issue #2050 threadedCreateContext.maskSecrets/UpdateContext.maskSecretsinto these two providers' Listener / TargetGroup / Service arms; the arms it left out kept interpolating a RESOLVED property value — plaintext by the time a provider sees it — into aProvisioningErrorthe deploy engine prints at ERROR, i.e. at DEFAULT verbosity. ELBv2:createLoadBalancernow takes the masker (its outer catch is the ONLY disclosure surface for a non-retryableCreateLoadBalancerrejection, since nothing on that path runs throughwithRetryand there is no give-up summary behind it), and its partial-create cleanupwarnis masked for uniformity with the sibling Listener / TargetGroup paths.updateLoadBalancerneeds no parameter for its FAILURE surface — it carries no try/catch, so itsModifyLoadBalancerAttributes/SetSubnets/SetSecurityGroups/AddTagsrejections propagate RAW toupdate()'s outer catch, which is where the mask belongs (the sibling arms wrap their own errors first, so theCdkdErrorpassthrough short-circuits them and that frame masks only what actually escapes unwrapped) — but it takes one anyway for a surface that frame cannot reach, and finding that is what the review round bought. Three SUCCESS-pathlogger.debuglines interpolate a resolved property value with no mask, two on create (EnforceSecurityGroupInboundRulesOnPrivateLinkTraffic,MinimumLoadBalancerCapacity.CapacityUnits) and one on update (the capacity twin). They are reached when the AWS call SUCCEEDS, so no catch anywhere downstream ever sees them. Debug level is not an exemption — #1997 shipped this exact shape as a live leak (a resolved ASG name in a debug line) — and the capacity one is the more reachable of the three: a{{resolve:secretsmanager:...}}resolving to a numeric string is accepted byNumber()and by AWS, so the plaintext prints under--verbose. TheNumber()coercion is not a sanitizer either, since a number stringifies back to the same digits. All three now mask the RAW value before interpolation. ServiceDiscovery: all six namespace arms (three kinds x create/update — the issue body says "four"; its own review comment corrects the count) take the masker, covering both theCreate*Namespace/Update*Namespacepayload and theTagResourcecallsyncNamespaceTagsmakes with resolved tag VALUES.pollOperationis the site neither issue named, and it is the one that also affects arms #2050 had already fixed. Cloud Map's create and update are OPERATION-BASED, so the submit call usually succeeds and AWS reports its rejection asynchronously inOperation.ErrorMessage, quoting the offending value back — which makes a FAILED operation the NORMAL rejection path for these arms rather than an edge case. Every namespace arm ANDupdateServiceopens its catch withif (error instanceof ProvisioningError) throw error;, so the error built there is re-thrown VERBATIM and masking the arm does nothing for it; the mask therefore had to be applied at the point of construction, on the rawErrorMessage(before interpolation, so it reachesmaskSecretsInText's whole-value arm at any length rather than only the substring arm's 4-character floor). Threaded from every CREATE / UPDATE caller — seven of the eight,updateService's included (that arm looked done after #2050 and was not). The eighth,deleteNamespace, stays unthreaded BY CONTRACT rather than by oversight:DeleteContextcarries no masker (issue #2007) and its payload is a physical id, not a resolved property bag. The docs added here sayevery create/update callerfor the same reason — an absolute would send a future PR trying to thread a masker delete-side. Two sites are audited and left UNMASKED with the structural reason recorded in-code rather than a mask added for tidiness:resolveNamespaceArn/ the post-createGetNamespacedebug andwaitForCapacityReservationProvisionedeach send only an AWS-issued identifier, so no resolved template value is in the payload for AWS to echo back; the delete arms are out of scope by contract (DeleteContextcarries nomaskSecrets). Tests: two new suites (tests/unit/provisioning/elbv2-loadbalancer-masking.test.ts,tests/unit/provisioning/servicediscovery-namespace-masking.test.ts, 40 cases) asserting the STRING that escaped — the thrown error'smessageor the logger's argument — never that a masker was passed, with an identity-fallback case per arm pinning the "absent means unmasked" half of the contract. The fixture secret is a Secrets Manager JSON DOCUMENT per #2063's acceptance item 4, which is also what caught the first draft's own non-vacuity check walking into the gap that item is about:JSON.stringifyescapes the document's quotes, soJSON.stringify(input).includes(secret)reported the plaintext ABSENT from a payload that carried it, and the check now walks the request's string LEAVES instead. Sixteen mutation probes, one per threaded site plus two dispatch-threading probes, each verified to red exactly the tests written for it. A round-2 review scoped to the FIX DELTA then found that the fix for the debug lines had introduced a leak of its own, which is the reason that round exists: the UPDATE-path capacity line maskedString(newCapacityUnits)— theNumber()-COERCED value — where its create twin masks the raw property. A masker matches by literal occurrence, so any valueNumber()does not round-trip printed unmasked ('0471'->471,'1e5'->100000,' 4071'->4071, a 20-digit value losing its tail to float precision), and the in-code comment beside it asserted the OPPOSITE — that a number stringifies to the same digits, so the coercion is not a sanitizer. True of4071, false of every value above. It now masks the raw value like the create twin, and the comment says which. The fixture that catches it is'07', chosen to fence BOTH properties at once: sub-MIN_NEEDLE_LENGTH(so only the whole-value arm can reach it, which reds a mask relocated onto the assembled sentence) and non-round-tripping throughNumber()(which reds the coerced-value mask). Both probes verified red; the earlier'4071'/'on-4071'fixtures were ≥ 4 characters and could fence neither. The review round's own probe is the one worth recording: relocatingpollOperation's mask onto the ASSEMBLED sentence left all 14 of its cases GREEN, because every fixture quoted a long secret inside an AWS sentence, which the substring arm handles either way. That is what the JSDoc had over-claimed —maskSecretsInText's whole-value arm fires only whensecrets.has(text), i.e. when the text IS the secret entire, so masking raw buys coverage BELOW the 4-characterMIN_NEEDLE_LENGTHfloor for a whole-valueErrorMessageand nothing at all for an embedded one. The comment now says exactly that, and a case whoseErrorMessageIS a 3-character secret fences it (verified to red under the relocation, which is the probe the 14 could not fail). - The reverse-replacement replay-CREATE retries an IAM-propagation error instead of rethrowing it on attempt 0, and an opt-out provider is now single-shot on both of that path's retry loops (issue #2032) —
src/deployment/rollback-executor.ts. Both arms of thereverse-replacementreplay (the create-first attempt and the post-delete-new-first retry) calledcreateProvider.create(...)DIRECTLY inside awithRetrycarryingRECREATE_RETRY_SCHEDULEplus a customisRetryable(isNameCooldownError/isRecreateRetryableError). Two independent mechanisms inretry.tsthen made the IAM-propagation class unreachable: a caller-suppliedisRetryableREPLACESisRetryableTransientErroroutright, and ANY explicit schedule knob setsdefaultSchedule = false, which is the gate on the dense 26-retry / 47.75s propagation path. So a rollback that re-created a Lambda whose execution role had been re-created moments earlier in the SAME rollback tookInvalidParameterValueException: The role defined for the function cannot be assumed by Lambda., retried ZERO times, and left the resource absent from BOTH AWS and state. A single local helper,createWithRollbackRetry(the create-side twin of the existingupdateWithRollbackRetry), now owns BOTH loops for both arms: an inner call passing no schedule knobs and no classifier — sodefaultScheduleis true and the dense path is live — nested inside the outer name-release loop, with the masking retry logger (#2038) and theisInterrupted/onInterruptedpair threaded into each. Nesting rather than hoisting the propagation check intoretry.tsis the load-bearing decision:withRetrynever receives the provider, so it structurally cannot honourResourceProvider.disableOuterRetry— re-runningCustomResourceProvider.create()mints a fresh pre-signed S3 URL and RequestId and strands the previous attempt at a key nobody polls, and re-runningNestedStackProvider.create()re-creates child stacks and child state files — and a hoist would additionally have overridden the documented "any explicit knob means the caller owns the cadence" invariant for all 8 custom-isRetryablecall sites, including four throttling-only ones inintrinsic-function-resolver.ts/export.ts/describe-type.ts/dynamodb-index-busy-delete.ts. ThedisableOuterRetryguard is hoisted to cover the OUTER loop as well, closing a pre-existing gap the nest made worth fixing in the same pass: the outer schedule had no guard at all, so an opt-out provider on this path measured 9create()calls for a cooldown and 10 for a collision — the exact hazard the flag exists for, arriving through the outer loop instead. A single-shot call still lets a name collision reach the caller's catch (which sits outside the helper), so the delete-new-first fallback fires on attempt 0 for an opt-out provider exactly as it does for a normal one. The precedent claim is stated precisely rather than generalized: the two deploy-engine delete-then-re-create sites are the twin of arm 2 only; arm 1's analogue is the property-driven create-first atdeploy-engine.ts:3745, which has one default-schedule loop and NO outer custom-classifier loop, so arm 1 is the SUM of both precedents. The neighbouring "if it fails the new resource survives untouched" comment was corrected in the same pass — with an inner retry that holds only when the create-first attempt fails with something other than a name collision, since a provider leaving a NAMED orphan after a transient failure now collides on an inner retry and routes into the destructive fallback (the same class the deploy engine's--replacefallback already accepts). The name-collision fallback is unchanged, and that was MEASURED rather than assumed:isNameCollisionError's signature (already exist(s)/AlreadyExists) is deliberately absent fromRETRYABLE_ERROR_MESSAGE_PATTERNS, so the inner default classifier rejects a collision on attempt 0. The SQS same-name cooldown IS matched by the inner classifier (the generic table carrieswait 60 seconds), which is the same division of labour the deploy engine's named-replacement site already documents — the inner ~47s budget absorbs most of the 60s window and the outer ~64s one covers the tail — at the cost of a longer worst case when the cooldown never clears. Covered bytests/unit/deployment/rollback-executor-replay-propagation-retry.test.ts(12 cases running the REALwithRetrywith itssleeprecorded rather than merely zeroed, so the classification AND both schedules are the subject: the measured classifier answers for all three message classes, both arms retrying propagation, a 9-failure case the outer 8-retry schedule alone cannot clear, a case asserting the inner CADENCE is the dense 250/500/1000/2000 one — a count cannot see this, since{ maxRetries: 26 }yields the identical attempt count on the generic schedule — the collision arm pinned by exact call ORDER before and after, a 12-failure cooldown proving the outer loop still re-enters, and three opt-out cases covering propagation, cooldown and collision), plus a new case intests/unit/deployment/rollback-executor-log-masking.test.tspinning the mask on the inner loop's give-up summary. That summary is a NEW sink at these sites — before this change neither replay-CREATE could emitretry.ts's give-up line at all (both counters were inert under a custom classifier), so the nested loop is the first thing here that prints at DEFAULT verbosity with the AWS message interpolated verbatim, which is the shape GHSA-p5qg-v9gv-hc7w was. Six mutation probes were run against the real file, each emitting a receipt proving the mutation landed: unwrapping arm 1 reds 7 cases and arm 2 reds only its own; removing the shared opt-out guard reds all three opt-out cases; narrowing that guard to the inner loop alone reds them too (the cooldown case showing the pre-fix 9 calls); routing the inner give-upwarnto the raw logger while leavingdebugmasked reds the new masking case naming the plaintext (all 146 sibling cases stay green, which is what makes it worth having); and setting the inner opts to{ maxRetries: 26 }reds ONLY the cadence case while all 11 count assertions stay green — the proof that the counts alone were not fencing the schedule. Two pre-existing suites were updated for the new call shape rather than the new behaviour:rollback-executor.test.ts's #1206 case now selects the last OUTERwithRetrycall by "carries a custom classifier" instead of.at(-1)(which is now the inner call), androllback-executor-log-masking.test.ts's create-first non-vacuity count moves from 9 to 81 (9 outer x 9 inner attempts) with the arithmetic spelled out in place. - Provider
catchsites that drop the AWS error are now mechanically refused, and the audit that decided the size is recorded (issue #2040) —scripts/check-provider-error-cause.ts(new),vite.config.ts,.github/workflows/ci.yml. cdkd's three transient-error classifiers —isTransientServerError(issue #2026),isThrottlingError,isMarkedNonRetryable— all find their signal by walking the error's.causechain, so a provider that wraps an AWS failure in aProvisioningErrorWITHOUT threading the caught value makes all three INERT for that call: the identical AWS failure is retryable in one provider and terminal in another, for no reason a user could predict. The issue was filed on two review estimates that disagreed by two orders of magnitude (6-of-363 and ~325-of-719, depending on how sites were matched), so the audit was the first deliverable, not a premise. Measured across all 81 files ofsrc/provisioning/providers/**, over the 21CdkdErrorsubclasses actually constructed there: 766 constructions, of which 392 have a caught value in scope and all 392 thread it (387 inside a lexicalcatch, 5 inside awrapErrorhelper), 374 are validation / precondition throws with no cause to thread, and 0 drop a cause. The ~325 figure is reproduced by the token-level heuristic it came from — 338 of the 720new ProvisioningError(sites carry nocauseargument — and every one of those 338 is a validation throw; the 6 figure is reproduced by widening the scan pastproviders/(see the follow-up below). Two independent methods (a TypeScript-compiler-API catch-scope walk and a brace-matching textual pass) were run against each other and their 13-site disagreement was read line by line: all 13 thread inline aserror instanceof Error ? error : undefined, which the token heuristic cannot see. So there was nothing to sweep, and the shipped change is the mechanism that keeps it that way — which makes the critic itself the deliverable, and its blind spots the thing worth spending review on. Three were found by independent review and closed, each MEASURED against the real tree rather than argued: (1) helper indirection — the fivewrapError/wrapUpdateErrorhelpers (appsync, lambda-microvm-image, and the three rds-dbproxy files) build their error OUTSIDE any lexical catch, so a purely lexical rule cleared all of them; deleting thecauseargument fromrds-dbproxy-provider.ts's helper un-threads 7 throw sites at once and used to exit 0. Call sites are now resolved to a FIXPOINT, so a catch handing its binding to a helper — or a helper forwarding it to another helper — propagates the caught-value binding, and those 5 constructions (serving 33 throw sites) are checked. (2) an unguarded class allowlist — a hardcoded 5-class table silently omittedHostedZoneNameNotFoundError, declared insideroute53-provider.tsand extendingProvisioningError; a catch-sited construction of it that dropped its cause passed with counts unchanged. The table is now DERIVED fromsrc/utils/error-handler.ts(every class transitively extendingCdkdError, with its cause position read off its own constructor or inherited from its base) and extended per-file with provider-LOCAL subclasses, which is what covers the route53 case. Note the positional index needed no fencing: adding a parameter beforecausemakes every site of that class readdropped, which is loud — it is the class SET that failed quietly. (3) a name-based threading test — the old check asked "does this expression mention the binding?", soconst cause = new Error(error.message)passed while being exactly as inert as dropping it (the derivedErrorcarries no$metadataand no marker), andresult.errorwas credited from a property name. The check is now STRUCTURAL: a bare identifier resolving to the caught binding, optionally through parentheses / anascast / a!/ anundefined-guarded conditional /??/||, and through local bindings resolved to the declaration NEAREST the use (a sibling block'sconst causeno longer credits an unrelated site). A property access, a call, anew, an object or a string is refused. The critic's own two failure modes are now defended separately, because they need different mechanisms and an earlier revision of its header wrongly credited the floors with both: collapse toward zero (a parse that yields nothing) is caught by per-shape FLOORS — files, constructions, catch-sited, helper-sited, threaded, derived-class count — plus a hard failure on any file with parse diagnostics, which is the real fix since the floors carry enough slack to hide up to three whole unparseable provider files; collapse toward green (the classifier degrades so everything reads as threaded) is caught by a SELF-PROBE of 13 fixed sources with known verdicts, five of them expectingdropped, run before the tree is read — makingisThreadedreturn true unconditionally leaves every count and the printed line byte-identical but now fails 7 self-probes. Two entrypoint mechanics that both ended in a silent green were fixed: the main-module guard usedimport.meta.url === \file://${process.argv[1]}`, which exits 0 having done nothing when the script is invoked through a symlink (Node resolves the main module to its realpath whileargv[1]keeps the link) or through any path needing percent-encoding, andprocess.exit()truncated a piped--jsonreport at 131072 of 167543 bytes — invalid JSON for any consumer. Registered as the Vite+ taskaudit:provider-error-cause:checkwithcache: false, for the reasontypecheckcarries it, and **invoked by an explicitci.ymlstep** like all six siblingaudit:*:checkguards — registered-but-uninvoked is how a task's own command string goes unexercised everywhere.tests/unit/scripts/provider-error-cause.test.ts(44 cases) takes every failure probe through the--providers-dir=seam against a scratch copy so no probe writes tosrc/, and pins both thecache: falseentry and the CI step. Break-tested in BOTH directions against the REAL tree: the unmutated copy exits 0, while removing acauseargument, passing an explicitundefinedthere, passing an unrelatednew Error(), deriving a newErrorfrom the caught one, un-threading awrapErrorhelper, dropping the cause on a provider-local subclass, and making one file unparseable each exit 1 naming the site.tests/unit/provisioning/provider-error-cause-classification.test.tscovers the behaviour rather than the shape: it drives one AWS-shaped error — a realSQSServiceException/IAMServiceExceptioncarrying$metadata.httpStatusCode, which is the ONLY signal an empty-bodied 500 leaves, not a hand-rolled stub — through two independent real providers (SQSQueuePolicyProvider, the issue's named reference site, andIAMRoleProvider) and asserts all four classifier verdicts agree, for a 500, a throttle, and a cdkdmarkNonRetryablemarker. Its DISCRIMINATOR is the classifier's verdict, not that an error was thrown — the pre-fix code throws too — so a negative control pins that the same wrapper with the cause dropped flipsretryablefromtruetofalse, and mutation-probing a real provider back to the dropping shape fails 4 of its 8 cases. **Known scope bound, filed as issue [#2075](https://github.com/go-to-k/cdkd/issues/2075):** the same defect DOES exist outsidesrc/provisioning/providers/**— 4 sites insrc/assets/docker-asset-publisher.tsand 2 insrc/state/s3-state-backend.ts`, which is where the 6-of-363 estimate came from — and those files were out of this change's edit scope. The asymmetry is worth naming rather than leaving implicit: the set with ZERO defects is regression-guarded and the set with SIX is not. The critic scans whatever root it is given, so widening its default once those six are fixed is a one-constant change plus a floor re-calibration, and that is what #2075 asks for. - A retried 500 no longer duplicates a non-idempotent EC2 / Route 53 create, and a replayed
CreateAccessKeyno longer strands an ACTIVE key (issue #2039) —src/provisioning/providers/idempotency-token.ts(new),ec2-provider.ts,route53-provider.ts,iam-access-key-provider.ts. The deploy engine wraps everyprovider.create()in an outer transient-error retry, and issue #2026 made HTTP 500 / 502 / 504 retryable for the default classifier, so a 500 whose request had actually SUCCEEDED server-side re-invokedcreate()and provisioned a SECOND resource — with no state entry, invisible tocdkd destroy, billing indefinitely.RunInstances,CreateNatGateway,CreateRouteTableandCreateNetworkAclnow carry aClientTokenfrom the new shared helper, which is memoised per(scope, region, stackName, logicalId)so every attempt of one create sends the SAME value, mixes a per-process nonce so no latercdkdrun can collide, and is RELEASED on success — and on the wiring-failure path, but only once the compensatingTerminateInstanceshas actually SUCCEEDED, because a terminate that fails leaves the instance running and the retry should be handed that same instance rather than launching a second one beside it — so a--replacere-create is not handed back the resource the deploy just destroyed — EC2 keeps aRunInstancestoken for ~24h and would otherwise answer with the terminated instance.AWS::Route53::HostedZonewas the worst shape found by the audit: it already passed aCallerReference, but derived from${logicalId}-${Date.now()}, i.e. regenerated per attempt — a call site that LOOKS idempotent while Route 53 happily creates a second hosted zone (and a second NS delegation set) for the same domain. It now takes the stable token, and because a repeated caller reference is REFUSED rather than deduped, the replay is recovered by looking the zone up by its caller reference (ListHostedZonesByName, name-scoped) and adopting it,GetHostedZoneincluded so the adopted zone returns the sameNameServersa first-attempt create would.CreateAccessKeyhas no token member at all anddisableOuterRetrywas rejected (it would make the provider single-shot for the IAM-propagation window between a siblingAWS::IAM::Userbeing created and being visible), so it serializes creates per IAM user in-process and reconciles from the FAILED attempt's own catch, deleting a key only when it is absent from a baseline taken moments earlier under that lock, AND was created at or after the attempt started, AND is not one cdkd itself created — anything unattributable is reported and left alone, because deleting a live credential that state still advertises is worse than the untracked one this fixes — the orphan's secret went down with the lost response, so adopting it is impossible and deleting it is the only remedy; a failed baseline read warns and disarms rather than failing the deploy. Unit tests model AWS-side dedup and assert what SURVIVES (exactly one instance / zone / access key), driven through the realwithRetry; theec2-instanceinteg fixture gained a real-AWS arm that reads the live instance'sClientTokenback and replaysRunInstanceswith it, failing (and terminating the leak) if EC2 launches a second instance. Two findings are worth keeping because neither is reconstructable from the diff. First, the audit's most dangerous category was not "no token" but a token REGENERATED PER ATTEMPT —route53-provider.ts(fixed here) andcloudfront-distribution-provider.ts(issue #2079, unfixable the same way becauseListDistributionsdoes not returnCallerReference) both passed one, so both call sites read as idempotent while duplicating exactly as if they had none; of the six providers that carry a token at all, only three (EFS, FSx, CloudFront OAI) were stable. Second, the unit probes initially reported a FALSE NEGATIVE: with the retry's sleep stubbed to a no-op, both attempts landed in the same millisecond, aDate.now()-derived token coincidentally matched, and the pre-fix implementation went GREEN through the probe written to catch it. The fixtures now fakeDateand advance the clock inside the sleep, which is what makes the probe discriminate (measured: the pre-fix Route 53 derivation fails 2 of 4 cases after the change, 0 of 4 before it). Third, the integ arm failed its first real-AWS run for a reason worth recording next to the second:aws ec2 run-instanceswas invoked with--min-count 1 --max-count 1, which are the API's parameter names (MinCount/MaxCount) and not the CLI v2 spelling (a single--count, which also acceptsmin:max). The CLI rejected the command outright withUnknown optionsand rc=255, so the replay never reached EC2. This is the same lesson as the JMESPath expressions that WERE validated against real output, applied to the other half of a command: an invocation written from the API shape and never executed. Everyawsflag the fixture uses is now checked against the real CLI with--dry-run. What kept this from shipping green is the vacuity guard: the primary assertion is a count of live instances carrying the token, and it would have passed here — there WAS exactly one, because the replay never ran — so only the exit-code branch, added to prove the replay reached the idempotency machinery at all, turned it into a FAIL. The same run also lost the tail of its own diagnostic, because a CLI auto-prompt redraw put a CR in the captured output and the terminal overwrote everything after it; the fixture now strips control bytes before interpolating command output into a message. The audit behind the change is recorded on issue #2080: 3 provider files fixed here, the remaining ~25 vulnerable creates across 16 providers enumerated in issue #2080 for a follow-up sweep, and the unit-test hazard that produced the false AWS call in issue #2081. - A cross-stack import of a REDACTED secret no longer ships the literal
{{resolve:...}}token to AWS, and two lookups stop building their own clients (issues #1934 / #1994) —src/deployment/intrinsic-function-resolver.ts. Since PR #1899 a secret-bearing output is PERSISTED as its unresolved expression, which is the whole point of the GHSA-p5qg-v9gv-hc7w fix — but a CONSUMER resolvingFn::ImportValuegot that string back verbatim from the exports index (or from thestate.jsonscan) and handed the literal token to the provider as a property value. The same shape had already been fixed twice for readers of a redacted bag (resolveReplayPropsfor the rollback replay, #1914 forcdkd drift --revert); the cross-stack edge was missed because the redaction and the consumption live in different stacks and usually different runs. It is not merely a broken value: a{{resolve:...}}string landing in a password field is a PREDICTABLE credential. The three cross-stack reads the intrinsic resolver owns now re-resolve before returning —Fn::ImportValue's index arm and scan arm, and theFn::GetStackOutputsibling read path the issue asked to be audited in the same pass, which reads the samestate.outputsbag and carried the same defect. Two OTHER readers of that same redacted bag live outside this file and are NOT fixed here, so the scope is deliberately not "every cross-stack read": a nested-stack child's outputs reach the parent verbatim throughnested-stack-provider.ts(issue #2055, the same AWS-facing defect via a third route), andlocal-state-loader.ts's own--from-statecross-stack resolver hands a container the token (issue #2056, local-emulation fidelity rather than a disclosure). Both were found by this lane's spec review and filed rather than folded in, because each needs a verification cycle this lane does not pay for. Which region resolves it is the decision this settles, consistently with #1957's family: the PRODUCER's (entry.producerRegion, the state ref's region, or the reference's ownRegion), because a Secrets Manager secret or an SSM parameter of the same NAME in two regions is two independent values (#1933) and only the producer's region reproduces what the producer exported. The index hit andFn::GetStackOutputcarry that region as recorded data; the index-miss scan is the one arm that can still fall back to the consumer's region, and only for a PRE-V2 state record carrying noregionfield — the same guess the state read itself just used, so the re-resolution cannot disagree with the record it was handed. That is done with a resolver PINNED to the producer region rather than a client bag, becausecachedDynamicReferencesis keyed by expression alone and is sound only while one resolver stands for one stack in one region — resolving a foreign region's expression inside the consumer's resolver would put a foreign answer under a key the consumer's own lookups read, re-opening exactly the leak #1933 closed. Credentials are the consumer's, which are the producer's too on every path that reaches there (the exports index and the state bucket are account-scoped). Cross-accountFn::GetStackOutputREFUSES such a value instead — anIntrinsicResolutionRefusalError, marked non-retryable — because the only credentials in hand are the consumer's and resolving under them would answer from a same-named secret in the WRONG account, the #1957 disclosure shape; the assumedRoleArnis scoped to a state read and carries nosecretsmanager:GetSecretValuepromise, so widening it is a permission-model change rather than a silent fallback. Both CloudFormation fallbacks (ListExports,DescribeStacksoutputs) are deliberately NOT re-resolved: those values never passed through cdkd's redaction, so a token there is a literal the producer chose to publish. The consumer's own state stays redacted (the resolved plaintext is recorded intorecordedSecretValues, so the deploy engine's save choke point rewrites it back),cdkd diffstill compares expression-vs-expression (skipDynamicReferencesis honoured), and a value carrying no{{resolve:is returned BY IDENTITY, so every ordinary import is untouched and pays no walk. TheFn::ImportValuelog line is deliberately emitted BEFORE the re-resolution so it keeps printing the expression rather than the secret. Bundled #1994 in the same lane because its two sites are in this file: theAWS::EC2::VPCIpv6CidrBlocksandAWS::ServiceDiscovery::*NamespaceHostedZoneIdlookups each built a fresh SDK client per CALL — never destroyed, so one client and socket pool per lookup for the life of the process — fromresolverRegion, whoseAWS_REGION→us-east-1substitution is the FAIL-OPEN shape #1957 removed from the dynamic-reference lookups, and without the ambient--profile/ credentials. The EC2 site now readsclientsForRegion(explicitRegion).ec2like itsDescribeInstances/DescribeLaunchTemplatessiblings. ServiceDiscovery has noAwsClientsmember (adding one would put a static@aws-sdk/client-servicediscoveryimport into a module every command loads), so the REGION DECISION is stillclientsForRegion's — its ambient-reuse rule and its refusal of a non-client-safe region included — and only the construction is local, memoized per region as a PROMISE and evicted on a rejected import. Covered bytests/unit/deployment/cross-stack-secret-reresolve.test.ts(20 cases against the realAwsClientswith region-capturing SDK fakes: the resolved value, the producer-vs-consumer region discriminator, the scan arm, the diff path, both CFn-fallback verbatim arms, identity return, the deep walk over list / object / embedded-token / own-__proto__shapes, both failed-lookup surfacing arms, the log lines carrying the expression rather than the plaintext, the producer-region binding when the consumer resolver was given no region, and the guest not pinning a verdict into the process-global store), 5 new cases intests/unit/deployment/intrinsic-getstackoutput-cross-account.test.ts(the refusal, an ordinary cross-account value still resolving, the refusal being non-retryable, its re-raise out of anFn::Sub, and the diff path NOT refusing), 1 intests/unit/deployment/intrinsic-refusal-non-retryable.test.ts(a source-level enumeration, so a refusal site added later cannot arrive unclassified), and 5 intests/unit/deployment/dynamic-reference-region-scoped-clients.test.tsfor #1994. 22 mutation probes were run against the real file, each emitting a receipt proving the mutation landed; 21 red the expected cases, and the 22nd is recorded in-code and in the test: dispatching ten namespace lookups together produces ONE client whether the promise or the client is memoized, because they serialize ongetAccountInfo's in-flight promise, so the promise memoization is defensive rather than fenced. - The retry classifier and the rollback executor are now inside both integ gates' scope, and the two hand-duplicated lists behind them are fenced by a test (issue #2042) —
.markgate.yml,.claude/hooks/integ-broad-gate.sh,.claude/hooks/integ-destroy-gate.sh,.claude/skills/{verify-pr,pick-integ}/SKILL.md,CLAUDE.md,tests/unit/scripts/cross-cutting-list-sync.test.ts(new).src/deployment/retry.tsandsrc/deployment/retryable-errors.tssat in NO integ gate whilewithRetrywrapped every provider'screate/update/deleteanddestroy-runner.tsconsultedisRetryableTransientErrordirectly — so a change deciding which AWS errors are retryable reached every mutating call cdkd makes and triggered neitherinteg-destroynorinteg-broad. Both are now ininteg-destroy.includeand inCROSS_CUTTING_REGEX.src/deployment/rollback-executor.tswas found unscoped in the same pass and was NOT named by that issue: its reverse-replacement path callsprovider.delete()on the new physical resource and re-creates the old one, which is delete logic by any reading. The.markgate.ymlhalf alone would have been inert, which is the non-obvious part:integ-destroy-gate.shcarries its OWN activation patterns and passes a PR through before ever consulting the marker, so a scoped-but-unmatched file produced an invalidated marker plus a gate that never reads it. The three files joined the hook'sstrict_deletegroup (any change triggers) rather than the hunk-filtered one, because a real change here adds an HTTP status code or an error name — text carrying none of thedelete/rollback/ENIvocabulary the hunk filter greps for. Auditing that asymmetry mechanically then found the INVERSE gap live on main, which is the worse of the two:src/cli/commands/destroy-runner.tsandsrc/provisioning/region-check.tsboth ACTIVATED the hook while sitting outsideinteg-destroy.include, so the gate blocked, ranmarkgate verify, and got a 0 back — the marker'shash: diffdigest never saw those files — and the merge proceeded with no destroy verification at all. A gate that has silently stopped gating is indistinguishable from a working one. Both are now in the include, and the two halves are compared in BOTH directions by the new test, which names the fail-open and the inert case separately with the remedy for each. Theinteg-destroyentry inCLAUDE.mdgained a prose copy of that scope in this same PR (the entry previously named a scope without saying the two lists must agree) — a third hand-copy, added inside the change whose thesis is that hand-copies drift, so it is fenced against both executable halves rather than left to the same "keep in sync" convention this PR exists to replace. The audit was mechanical (expand the hook's three EREs, diff against the include) rather than by eye, and it reported exactly those two and nothing else. Enforcement of the sync itself: the cross-cutting file list is hand-duplicated in FIVE spellings (the hook regex,/verify-prstep 6's bullet list AND its detection-snippet copy of the same regex, theCLAUDE.mdinteg-broadentry, and/pick-integ's changed-path table) and the broad-set test-name list in SEVEN. Both had already drifted: all five file-list copies were missing the three files above, and the broad-set list stood at 9 entries everywhere except the hook's own header comment, which had 8 and omittedexport(fixed here). The new unit test compares every copy against the executable one, expands the regex and brace spellings rather than skipping them, asserts a per-extractor floor so a blinded parser cannot report green having compared nothing, and checks that every listed path and fixture still exists..claude/skills/work-issues/SKILL.md's triage list is deliberately excluded and the test says why — it is a different list for a different purpose and already diverges in both directions. Hook coverage: 3 activation cases plus 2 near-miss pass cases ininteg-broad-gate.test.sh, and 3 activation cases plus 2 controls ininteg-destroy-gate.test.sh(which required the suite's first fixture repo carrying a realrefs/remotes/origin/main, since every pre-existing case there deliberately had none and so never exercised the diff filter at all). Each activation case was verified against the pre-change hook and FAILS there. Four mutation probes were run against the sync test, including a replay of the realexportdrift.
Recently Implemented (2026-08-19):
- ✅ The
maskSecretsprovider contract is adopted by the five remaining providers that log a RESOLVED property value (issue #1997) —src/provisioning/providers/{apigatewayv2,asg,dynamodb-globaltable,dynamodb-table,sns-topic}-provider.ts,tests/unit/provisioning/{apigatewayv2-provider,asg-provider,dynamodb-provider,sns-topic-provider}-masked-warn.test.ts(4 new). Issue #1932 item 3 added the contract —SecretMaskingContext.maskSecrets, inherited byCreateContextandUpdateContext— and wired exactly ONE consumer,CognitoUserPoolProvider. Every other provider interpolating a resolved property value into its ownlogger.warnstayed outside cdkd's masking boundary:propertiesreaches a provider already RESOLVED, so a{{resolve:secretsmanager:...}}scalar is plaintext by then, and the other two boundaries (the deploy engine's error / reason text, the resolver's debug line) do not cover a provider's own log line. Same risk class as theEnabledMfascase that motivated the contract, and log-only for the same reason (these values do not reachdeployments/*.jsonl), which is why they were deliberately deferred rather than overlooked. Shape, copied frombuildMfaConfigRequestrather than reinvented: the masker is taken as a trailingSecretMaskerparameter defaulting to IDENTITY (so the diff / import paths and every existing test are unchanged), and each function installs ONE masked sink —const warn = (m) => this.logger.warn(maskSecrets(m))— rather than masking at each call, so a warning added later is masked by construction. The type is imported fromsrc/types/resource.ts, which re-exports it for exactly this reason;src/provisioning/**still imports nothing fromsrc/deployment/**. BOTH layers, per the contract's own correction: the sink is the OUTER one and can only reachmaskSecretsInText's SUBSTRING arm, which ignores needles belowMIN_NEEDLE_LENGTH(4) and — more importantly — never matches onceJSON.stringifyhas escaped a", a\or a newline inside the value. Each site therefore also masks the raw value BEFORE stringifying: amaskLeafwalk for API Gateway v2'sDestination/Source, a per-ARN.mapfor ASG's expected / observed target-group sets, and a shared module-levelmaskLeafValuewalk in each DynamoDB provider (a walk, not a top-leveltypeoftest, because the arm that fires is precisely the one where the value is NOT a usable string — an array or an object with the secret nested inside). BOTHcreate()andupdate()per provider, which is what makes it a fix rather than a hole in the shape of whichever path a deploy takes:apigatewayv2gainedcontexton both (threaded tocreateIntegration/updateIntegration→toSdkResponseParameters),sns-topicgained it onupdate()(create already had one) and threads it tobuildDeliveryStatusAttributeMap's desired-side walk, and both DynamoDB providers gained it onupdate()and install sinks increate()andupdate()plus the warm-throughput / GSI / auto-scaling helpers those two reach.asgcovers both paths too — an earlier revision claimed its create path had nothing to mask, which review disproved:AutoScalingGroupNamefalls back to a generated name only when ABSENT, so a DECLARED name is a resolved property value and it is interpolated into two create-path debug lines. Debug level is not an exemption (this provider already masks its convergence-poll debug line), socreate()takes the masker as well, and a test still pins that the create path emits ZERO warnings so a future create-side warning fails rather than shipping unmasked. The issue's site list was re-derived, not trusted. All 11 named sites are real (line numbers had drifted by up to 48). The scan found FIVE it missed, all fixed here: the GlobalTable per-indexWarmThroughputrefusal, its auto-scalingMinCapacity/MaxCapacityrefusal, its dropped replica-override and live-only-index warnings, and its immutable-GSI warning.delete()has the same exposure and is deliberately NOT fixed here — both DynamoDB delete paths interpolatephysicalId, which for those types IS the resolvedTableName— becauseDeleteContext(src/provisioning/region-check.ts) carries no masker by design; the contract states why binding one to the executor's re-resolved PREVIOUS-generation map would fence nothing, and defers it to issue #2007. The six delete-path callers ofapplyAutoScalingDiffcorrectly take the identity default. Tests: 78 across 4 new files, each verified by a mutation probe against the real source — 16 probes in the first round and 16 in the review round, all RED. Two of those probes changed the tests rather than confirming them: reverting the SNS and ASG warn sinks left the suite GREEN, because every fixture put its secret where the inner leaf mask also caught it, so each provider gained a case that ONLY the sink can reach (a NUMERIC value, which the leaf walk declines by design since a number cannot be a whole-value needle; and for ASG the resolvedAutoScalingGroupNamein the message'sphysicalIdposition). The API Gateway v2 status-code key mask needed the same treatment at the length where the sink cannot help — a 3-character HTTP status code sits below the 4-character needle floor. Every suite also pins the DEFAULT identity path emitting unmasked text unchanged, a non-secret value surviving verbatim, and a context carrying no masker not throwing. An independent spec + security review round then found one BLOCKER and six smaller defects, every one of them a place the FIRST round's own rule was applied unevenly rather than a disagreement about the rule. The blocker:warmThroughputOpFormaskedindexNamewhere it built two helperscopestrings and then interpolated the SAME value RAW into its own decrease warning and its already-matches debug line, so a resolved secret asIndexNameprinted in plaintext at DEFAULT verbosity oncdkd deploy— no test reached that method, which is why the first probe sweep missed it. It now builds real sinks and masks the name ONCE into asafeIndexNamethe four sites share. The others: API Gateway v2's inner mask was a scalar test rather than a walk, in the one arm that fires PRECISELY because the value is not a string (so the realistic{ Ref: '<secret>' }shape escaped both layers — confirmed leaking against the pre-fix code);DynamoDBTableProvider.create()installed a masker but built no sink, leaving its partial-create rollback warning unmasked; the SNS'throw'arms were left unmasked on a rationale that was FALSE (the deploy engine maskserror.messageonly on its events-store path — its CLI path logs it raw), so all three throw sites now mask and the comment says why; four further create/update helpers were carrying unthreaded sinks (applyCrossRegionReplicaTagsDiff,waitForTableActiveAfterUpdateincluding its ProvisioningError,applyKinesisStreamingDestination, and SNS's create cleanup); and four comments claiming "ONE masked sink" were inline-wrapping a single call, which is the shape that lets the NEXT warning ship unmasked — they are now real sinks. Writing the missing tests found two further leaks review had not named, both caught because an assertion read the debug stream rather than the warn stream: a second ASG create-path line (Successfully created AutoScalingGroup ... ${groupName}) and BOTH DynamoDB providers'update()entry lines (Updating DynamoDB table ${logicalId}: ${physicalId}), each printing a resolved name raw. The review round's own probes changed three tests rather than confirming them, all the same way the first round's did: when a fixture's secret sits where BOTH the sink and the inner per-value mask catch it, neither layer is fenced. Each layer now has a case only it can reach — a NUMERIC value or a long needle for the sink, a JSON-escaped or sub-4-character value for the walk (3 characters is not contrived here: a DynamoDB index name and table name may both be 3 characters, and an HTTP status code always is). No CLI flag, dependency, doc or state-schema change:docs/provider-development.mdand.claude/rules/providers.mdstill describebuildMfaConfigRequestas the reference implementation, which this change does not falsify. cdkd scrubcan now repair a DELETED output's stored plaintext, which is the population it was documented as the remedy for (issue #2005) —src/cli/commands/scrub.ts.outputSecretsis built from today's DECLARED outputs and the outputs redaction was gated on it being non-empty, so an output removed in an ordinary refactor contributed no needle: the plaintext sitting instate.outputssurvived a scrub that REPORTED SUCCESS, and that is exactly the recordcdkd diffwithholds from display (#1948) and points at scrub to fix. The outputs bag is now repaired in TWO passes. A stored key today's template can still NAME — a declared output, or anExport.Namethis run could FULLY compute (literal, or an intrinsic whose best-effort resolution landed) — keeps the pre-existing pass, which POSITIONS it against the template; a name that did NOT fully resolve is excluded from the accounted set, sincedeploy-engine.tswrites an alias key ontypeof exportName === 'string'with no unresolved-value test, so a warn-and-kept${Foo}really is a key some deploy wrote and accounting for it would SUPPRESS the repair.redactUnaccountedOutputsthen repairs every OTHER key by value match alone, againstallRecordedSecrets(the union of the outputs map and EVERY resource's, filtered to plaintexts at or abovesecret-redaction.ts'sMIN_NEEDLE_LENGTH, now exported for exactly this: its no-source arm whole-value-matches at ANY length, which is sound for a position-scoped bag and not for a union with no position source at all) — the usual shape after deleting an output is that only a RESOURCE still references the secret, so the union is what makes the needle reachable. That union is built in exactly one place and never handed to the resource walk, because the two bags are separate precisely so one resource's secret cannot rewrite another's coinciding literal. Each unaccounted key is scanned ONCE, from the STORED value rather than from the first pass's output: re-scanning an already-positioned MIXED leaf splices a union needle INTO the expression pass 1 just inserted (postgres://admin:{{resolve:secretsmanager:{{resolve:ssm:/app/env}}/db:SecretString:password}}@app-db), which is the corruptionsecret-redaction.ts's token guard exists to prevent, and the single union scan subsumes pass 1 for these keys anyway. TWO refusals bound the widening, both becausestate.outputsis re-applied VERBATIM to CONSUMER stacks — cdkd's exports index (src/state/export-index-store.ts) and everyFn::ImportValue/Fn::GetStackOutputread it — so a FABRICATED redaction ships a literal{{resolve:...}}token into a consumer's own AWS call: nothing is rewritten unless its value genuinely MATCHES a recorded plaintext (redactSecretsForState's value scan with no source), so an unrecoverable needle — secret deleted, rotated away, or the reference gone from the template too — leaves the value untouched and invents / removes no key (delivered by no single guard, measured rather than assumed:scrubStack'stotalSecrets === 0early return,redactUnaccountedOutputs's own empty-map guard, and the fact that an empty secrets map makes every scan the identity are three REDUNDANT reasons — delete any one and the record still comes out byte-identical, so none of them may be cited as the fence; the empty-map guard is additionally reachable on its own, sincetotalSecretscounts the raw maps while the union is filtered toMIN_NEEDLE_LENGTH, so a stack whose every recorded plaintext is sub-floor walks past the early return); and an ACCOUNTED key is never scanned against the union, or a resource's secret value would rewrite a declared output's coinciding literal into that resource's expression. Two residuals are stated rather than implied: a DECLARED output whose template value no longer resolves a secret but whose STORED value is still a stale plaintext is not repaired here either (a redeploy rewrites it), and the repaired population is WIDER than "an output you deleted" — a parameterizedExport.Nameleaves the deploy's real alias key unaccounted on EVERY run, so those keys take cross-resource value matching as a standing condition. The--failKEY scan deliberately keeps readingoutputSecretsrather than the union: it feeds an exit code, and widening it would start failing builds over a finding no scrub can remedy (a key is never rewritten; the remedy is anExport.Namechange plus a redeploy, per #1919). Thecdkd diffrefusal is unchanged — it still cannot decide from a stored string alone whether a value is a plaintext. Covered bytests/unit/cli/commands/scrub-deleted-output.test.ts, every case mutation-probed.- A custom resource's own SDK calls are no longer single-shot, so a mid-propagation authz denial stops failing the deploy on attempt 0 (issue #2033) —
src/provisioning/providers/custom-resource-provider.ts.CustomResourceProvidersetsdisableOuterRetry, and its JSDoc justified that by saying it retries internally instead. That claim was backed by code for exactly ONE error shape:invokeCustomResourceWithRetry's predicate keys on the handler's RETURNEDcfnResponse.Status === 'FAILED', and the loop body contained notry/catchat any point, so a THROWN exception leftcreate()directly. Every AWS call the provider itself made was therefore single-shot with no retry anywhere in the stack — anAccessDeniedExceptiononlambda:InvokeFunctionwhile the deploying principal's freshly-attached policy was still propagating failed the deploy immediately, while every other resource type got 26 retries over 47.75s for the same wording. The loop body is now wrapped, and a thrown error is replayed only when three things hold: the request was not delivered, the PRE-DELIVERY budget is not spent, and the newisTransientAuthzThrow(IAM_PROPAGATION_ERROR_MESSAGE_PATTERNSplusCR_THROWN_AUTHZ_EXTRA_SIGNALS, the two spellings the shared list carries in no form) classifies it. That budget is its OWN, and review is what made it so: the first cut shared the FAILED-response arm'stransientAuthzMaxRetries(2), i.e. 250ms + 500ms of coverage against an IAM-propagation window this repo has measured at 7-12s, so the issue's own scenario still failed with the fix in place. The pre-delivery arm now carriesIAM_PROPAGATION_MAX_RETRIES(26 retries / 47.75s, the same dense schedule every other resource type gets) and the two are counted separately so neither can spend the other's. The reason the big budget is affordable there and not on the FAILED-response arm is thedeliveredfence: this arm fires only when the handler has been invoked ZERO times, so a replay re-runs nothing.CDKD_CR_AUTHZ_MAX_RETRIESaccordingly bounds handler RE-INVOCATIONS only (and is now clamped to 10 —1e9passed the old finite />= 0gate and re-invoked until the 1h per-resource deadline).sendRequestgained anonDeliveredcallback fired the instantInvoke/PublishRETURNS, and that flag — not the wording — is what makes a post-delivery throw un-retryable: the handler is running and will write to THIS attempt's pre-signed URL, which is the hazarddisableOuterRetryexists for.isRetryableTransientErroris deliberately NOT used, because a throttle / 5xx / socket timeout can arrive after acceptance and replaying would invoke a non-idempotent handler twice; a test pins that. The placeholder S3PutObjectinstead gets its own full-schedulewithRetry, since it is idempotent, pre-delivery, and touches no response-URL lifecycle. Two premises in the issue were CORRECTED by measurement rather than assumed:waitUntilFunctionActiveV2/UpdatedV2are not single-shot — the SDK's generatedcheckStatecatches every exception and returnsRETRY, polling tomaxWaitTime(600s) — and theGetFunctiondelete-path probe already fails OPEN, so retrying it would only delay fall-through by up to 47.75s on a genuine denial. TheCR_TRANSIENT_AUTHZ_SIGNALS-vs-IAM_PROPAGATION_*narrowing the issue asked about is INTENDED and stays: the narrow set is matched against handler-authored text where a match buys a re-INVOKE of the user'sCreate(with the documented risk of a Provider-frameworkonEventcreating a second physical resource), while most shared-list entries describe a DOWNSTREAM call the handler made, where a re-invoke is not the remedy. It is a NARROWING and not a strict subset, which the first cut's prose got wrong: three of the six CR entries are in the shared list in no form, and its bareis unable to assumeis there only ANCHORED — so the thrown-error classifier takes the shared list plus the two genuinely-missing spellings rather than the whole CR list, or it would re-introduce un-anchored what the shared list refuses on purpose (a permanent explicit-deny would burn 47.75s). Stale prose fixed alongside: thedisableOuterRetry/transientAuthzMaxRetries/invokeCustomResourceWithRetry/sendRequestJSDoc,.claude/rules/providers.md, anddocs/troubleshooting.md's "invoked exactly once" bullet, which told users aCustom::*create is never retried. Four further defects came out of the same review round and are fixed here rather than filed. (a) The readiness waiters were being REPLAYED:@smithy/util-waiterserializesobservedResponsesinto its TIMEOUT message and those keys read403: … not authorized to perform: lambda:GetFunction …, so the classifier matched and a PERMANENT denial cost 27 x 600s instead of 600s. The fence ismarkNonRetryableon the wrap plus anisMarkedNonRetryablerefusal in the classifier — a property of the error object, so unlike a wording test it cannot be defeated by AWS rephrasing the denial; the exhausted placeholderPutObjectis marked the same way, which is what stops it getting the loop's budget on top of its own (measured: 27 x 27 PUTs before the fix). (b) The waiter's non-TIMEOUT arm serialized the ENTIREGetFunctionresponse —Configuration.Environment.Variablesincluded — into a message thatextractDeploymentEventErrorpersists todeployments/{runId}.jsonl, a durable store contractually free of resource properties; that arm now reports the error NAME plus the function's ownState/StateReason/StateReasonCodeand withholds the payload. (c)delete()'s lenientcatchswallowed the throw and returnedundefined, whichdeleteSkipReasonreads as DELETED — so a permanentlambda:InvokeFunctiondenial printed✓ … deleted, dropped the state record and exited 0 over a handler that never received aDelete, silently orphaning everything it manages; it now returns{ outcome: 'skipped' }with a FIXED reason (the AWS message stays in the warning, since a reason is rendered into theErrorthe deploy-side replacement sites throw and their catch classifies already-deleted by SUBSTRING). (d) The classifier now walks.cause(the issue #2040 class) and the new wait sites are interruptible perdocs/provider-development.md— one SIGINT watch per invocation, threaded into thewithRetryasisInterrupted/onInterruptedand polled by the hand-rolled backoff — while each abandoned response placeholder is swept before its retry, sincecdkd gcscans the ASSET bucket and not this prefix. Covered bytests/unit/provisioning/custom-resource-provider-thrown-retry.test.ts(25 cases, no*Onceprimers); 14 mutation probes were run and every one failed a distinct assertion — including one that caught a new test failing to discriminate: deleting thePutObjectwithRetryleft it green because the loop-level arm absorbed the same throw and issued the same three PUTs, so it was rewritten around what actually differs (one re-PUT key and no sweep, versus three signed keys and two swept). - The rollback replay no longer prints or PERSISTS a resolved secret when its AWS error quotes the value back (issues #2038 / #2031) —
src/deployment/rollback-executor.ts,src/deployment/deploy-engine.ts.resolveReplayPropsre-resolves the journaled operation's properties, so the payload handed toprovider.update/provider.createcarries Secrets Manager / SSMSecureStringPLAINTEXT; AWS validation errors routinely quote the offending value back (Value '<secret>' at 'password' failed to satisfy constraint ...), and this file imported nomaskSecretsInTextat all. FIVE sinks were open — three on the rollback path plus two the audit of the deploy path turned up. (1) ThewithRetryoptions threaded the RAWLogger, soretry.ts's give-up summary printed the verbatim message atwarn— DEFAULT verbosity. (2) TheRollback failed for ...warns printed it directly. (3) The one that matters most, found by the trace #2031 required rather than by the report:extractDeploymentEventErrorcopieserr.messageverbatim into the durabledeployments/{runId}.jsonlS3 sink, and the two rollback entry points did not agree — the standalonecdkd rollbackcommand wiredrecordEventwith no masking at all, while a deploy's in-process auto-rollback masked with the DEPLOY'sperResourceSecrets, a different generation from the one the replay resolved out of the journal (so a rotated secret, or a reference only the previous generation carried, was missed). The fix installs the establisheddrift.tsshape — aRetryLoggermasking the CONCATENATED string, which is whyRetryLogger.warnis optional in the first place — as a localmaskingRetryLogger, plusmaskedRollbackEventErrorfor the eventreason/errorfields;name/awsErrorCode/requestIdare left alone as AWS enum-shaped identifiers. The per-opsecretsbag is HOISTED above thetryinreplaySingleand in thereplayFailedOperationsloop body: it was declared inside each case block, so the sharedcatchthat logs and persists the message structurally could not see it. Masking at the shared executor makes both callers equal and double-masking is a no-op. (4) The audit of the deploy path found the SAME class one caller over and at a strictly HIGHER log level:DeployEngine.provisionResource'sFailed to <op> <logicalId>: <AWS message>line logged the raw message aterrorwhile therecordEventone statement below it was already masked — so the durable sink was clean and the terminal printed the secret. (5) Found while verifying (4), and the one no log-site fix could have closed: the raw provider error is attached as theProvisioningError's CAUSE,formatErrorrenders aCdkdError's cause asCaused by: <cause.message>, andhandleErrorlogs that aterrorlevel for anything escaping the command — so the plaintext reached the CLI boundary even with both log sites masked, because that sink reads the error OBJECT rather than any string cdkd formatted. The newmaskSecretsInError(insecret-redaction.ts, which stays a no-import leaf) masks the message of EVERY link in thecauseCHAIN, cloning each withObject.getOwnPropertyDescriptorssomarkNonRetryable's non-enumerable symbol marker,$metadata/Codeandnameall survive forisMarkedNonRetryable/isThrottlingError/isTransientServerErrorandextractDeploymentEventError; it returns the original by identity only when nothing ANYWHERE in the chain changed. The chain rather than the top link is a review-round correction, and the direction it was wrong in is the instructive part: a provider that wraps an AWS failure in a generic sentence leaves the plaintext one link DOWN, where a top-level-only mask takes its identity-return, reports nothing to mask and hands back an object still carrying it — so the narrow fix was weakest in exactly the shape a caller would trust it for.formatErrorwalking a single level today does not make that safe; it makes it one edit from re-opening. A CYCLIC chain terminates via a visited-set plus a depth cap, andcauseis rewired to the CLONE in a second pass so a cycle is rebuilt among the masked copies rather than re-entering the originals.extractDeploymentEventErrorneeded no change: it copies only the TOP link's message and walks the chain solely for$metadata.requestId/Code. Do not repeat the claim an earlier revision of this entry's code comments made, that the engine "already masked its own error text"; it masked the EVENT only. The two--replacewrap messages that feed that line (Failed to delete old resource .../Failed to re-create ... after the --replace delete-first fallback) are masked at CONSTRUCTION as well, so the plaintext never exists inside a thrownError'scausechain. #2038's acceptance item 3 (thewithRetrycall-site audit) is discharged in the PR body and extended the fix toDeployEngine.withRetryplus the two direct--replacere-create sites, whereperResourceSecretsis populated before the provider call and so is in scope at every retried call; a resource with no recorded secret forwards byte-identically. The maskingRetryLoggerobject itself is now ONE definition in the newsrc/deployment/masking-retry-logger.ts—rollback-executor.tsanddrift.tscarried byte-identical eager copies, and it cannot live insecret-redaction.tsbecause that module is a documented no-import leaf while the helper needsRetryLogger. The deploy engine keeps a second, LAZY variant for its genericwithRetrywrapper, whose call sites (DELETE, the observed-capture drain, the Outputs pass) hold no bag; every site that DOES hold one binds it eagerly, so the file no longer states one rule about looking a bag up by logical id and then breaks it three lines on. The provider-layer sites (elbv2-provider.ts,servicediscovery-provider.ts) carry a resolved payload but hold themaskSecretsCAPABILITY rather than a secrets map — a different mechanism needingCreateContext/UpdateContextthreading, filed as #2050 rather than invented here. Covered bytests/unit/deployment/rollback-executor-log-masking.test.tsandtests/unit/deployment/deploy-engine-retry-log-masking.test.ts, both mocking onlyretry.js's sleep so assertions land on production's actual give-up summary; every new mask site is pinned by a case verified to FAIL against the pre-fix form, and every suite carries a non-redaction control so a blanket-redaction bug fails too. Two fences exist because the review round found them missing rather than because the shape is obvious: the deploy suite captureserroras well aswarn(it discardederrorwith a() => {}stub, which is exactly why sink (4) survived the first cut), and a two-resource case asserts one resource's give-up summary is NOT rewritten with a SIBLING's expression — replacingperResourceSecrets.get(logicalId)with a session-wide union of every bag passed the entire deployment suite, i.e. the per-resource scoping the JSDoc calls load-bearing was unfenced and would have re-opened the over-redaction class of #1912 / #1918. - A transient HTTP 500 no longer terminates an IAM-propagation retry at 12% of its budget (issues #2026 / #2018) —
src/deployment/retryable-errors.ts,src/deployment/retry.ts. The reporting added for #2018 immediately paid for itself: it showed a healthy propagation sequence being ended by ONE unclassifiable error after 5 clean retries, at 5.75s of a 47.75s budget the sequence needed roughly 10s of.withRetrycomputes retryability per attempt from that attempt's message alone, so a single unreadable error ended the sequence regardless of how much budget remained or how many attempts had already classified cleanly. The issue deliberately did NOT prescribe a fix, because the failing error's HTTP status had never been captured and three different remedies followed from three possible values. That capture is now part of the give-up line — it carries[name=... http=... requestId=...], the two fields the classifier actually decides on plus the id AWS support needs — and it selected the branch on the FIRST recurrence:tests/integration/iam-propagation-stressreproduced on round 11 of 11 real-AWS runs (us-east-1, 2026-08-19 08:57:30Z) withUnknownError [name=InternalFailure http=500 requestId=ebf581cc-6072-5ffc-943a-e33312488615]. SQS answered a mid-propagationSetQueueAttributeswith HTTP 500 and an empty body, so the SDK substituted itsUnknownErrorplaceholder, no message pattern could match, and 500 was in no status set.isRetryableTransientErrornow also consultsisTransientServerError— HTTP 500 / 502 / 503 / 504, mirroring@smithy/service-error-classification's ownTRANSIENT_ERROR_STATUS_CODES— ahead of the message patterns, because it is the only check that still works when the response carried no message. The four statuses live in a NEW set rather than being added toRETRYABLE_HTTP_STATUS_CODES: that one is read byisThrottlingError, which SEVENisRetryablecall sites across four files (describe-type.ts:67,dynamodb-globaltable-provider.tsx4,export.ts:1744,intrinsic-function-resolver.ts:5216) pass as a deliberately NARROW classifier —describe-type.tsstates the intent outright ("retry ONLY throttle-shaped failures") — so widening the shared set would have silently converted every one of them into "retry throttles and server errors"; three further sites call it as a bare classification (drift.ts:518,export.ts:1755,dynamodb-index-busy-delete.ts:381) and make the case stronger, sincedrift.tswould have begun reporting "cannot compare" for a resource whose read merely 500'd. 501 stays terminal: the rule is the SDK's four transient statuses, not "5xx". Because a second retryable class would otherwise be INVISIBLE — a sequence spending its whole budget on 5xx rethrew the raw error with nothing printed, the exact silence #2018 removed for propagation — the give-up line now also counts transient-server retries (gave up after 8 transient server-error retries (HTTP 5xx)), and renders byte-identically to #2018's when only propagation retries were spent. Two accepted risks are recorded rather than left to be discovered: a retried 500 can duplicate a non-idempotent create (#2039), and the classifier stays inert for provider catch sites that dropcause(#2040). The issue's more invasive third option (letting a sequence tolerate a bounded number of unreadable errors) was implemented and then REMOVED once the measurement landed, because the issue gates it on the first two branches not covering the observed case and this one does — it can mask genuine terminal errors, and nothing now justifies that risk. Two existing tests asserting 500 as non-retryable were inverted rather than deleted: neither documented a rationale for 500 specifically, both used it as a stand-in for "a status outside the set", and the fence they provided is re-pinned on 501 so the boundary is still tested from inside the 5xx range. This discharges #2018's acceptance item 2, which its own lane handed to this one, and kills its branch 1 (an insufficient budget) on 11 runs of evidence — propagation never consumed more than 11.75s of 47.75s. #2018 stays OPEN deliberately: its own closing criteria require either that this reproduce THE REPORT or that the reporter's failing resource type be obtained, and neither holds — the capture is an SQSSetQueueAttributes, while the report is a LambdaCreateFunctionwhose message (role ... cannot be assumed) classifies fine, so an unreadable-message fix does not explain it. That residue is external input. Covered bytests/unit/deployment/retry-transient-server-error.test.ts(5 cases, built from the captured error shape, 2 verified to fail without the fix) andtests/unit/deployment/retry-classification-signals.test.ts(7 cases, 1 verified to fail without the instrumentation). - The IAM-propagation retry now REPORTS itself, so a propagation failure is diagnosable from the run alone (issue #2018) —
src/deployment/retry.ts,tests/integration/iam-propagation-stress/verify.sh. A real deployment failed withThe role defined for the function cannot be assumed by Lambda.even though the dense propagation retry that absorbs exactly that error was already in place and that path had not changed since. What made it undiagnosable was not the retry but its SILENCE: an exhausted sequence rethrew the raw AWS error, so at default verbosity a run that retried 27 times over 47.75s printed output byte-identical to a build carrying no retry at all — establishing that cdkd had retried required reading the source and diffing two releases, and the three candidate explanations (budget too short / budget exhausted / the retry never engaged) were indistinguishable from outside. A give-up now emits ONEwarnline —MyFn: gave up after 26 IAM-propagation retries over 47.75s of propagation backoff (the full propagation budget) - <AWS message>— and each per-attemptdebugline carries the running total (attempt 15/26, 25.75s backoff through this attempt), so--verboseyields a measurement rather than an inference.warnis the load-bearing choice: the point is surviving a run without--verbose; the per-attempt lines stay at debug because 27 of them is a flood. The summary is gated on having actually retried, so the overwhelmingly common fast-fail prints nothing, and it fires on the non-retryable exit too (a propagation sequence ending in an explicit deny reports the budget it already spent, which is what tells a fast deny apart from one that surfaced after a wait).RetryLogger.warnis OPTIONAL becausedrift.ts's revert threads a MASKING logger (issue 1914), so a requiredwarnwould have been silently satisfied by an unmaskedlogger.warn; that caller now threads a maskedwarntoo, having been the one production caller that genuinely lacked it. Review also corrected two defects in the first cut: the exhaustion note keyed on the retry COUNT, which under-reported a genuine exhaustion whenever a throttle consumed an attempt without advancing the counter, and now keys on the loop's own exit condition; and the counters advanced BEFORE the sleep, so the first debug line claimed 0.25s slept while nothing had been slept yet. No retry BEHAVIOR changed — both counters are reporting-only and feed no control decision, which is what kept this off theinteg-broad/ schema paths. The investigation narrowed the issue's third branch FOR A PLAIN, SDK-ROUTED RESOURCE, by execution as well as by reading (it does NOT settle the reporter's case, whose failing resource type is unrecorded -- a custom-resource or nested-stack handler sets disableOuterRetry and is single-shot by design): the propagation patterns are a structural subset of the retryable table (RETRYABLE_ERROR_MESSAGE_PATTERNSspreadsIAM_PROPAGATION_ERROR_MESSAGE_PATTERNS),ProvisioningErrorembeds the AWS message so the classifier matches, Cloud Control's failure path embedsStatusMessageso the CC route classifies too,ProvisioningErroris not self-marked terminal (onlyResourceUpdateNotSupportedErroris), and the 30-minute per-resource deadline cannot race a 47.75s budget — leaving branches 1/2 as the live hypothesis for that shape, which is what the fixture now measures -- and the fixture's own runs then contradicted branch 1 in THIS account (propagation completed in 7-12s of a 47.75s budget), which is evidence about cdkd's account rather than the reporter's. Covered bytests/unit/deployment/retry-propagation-legibility.test.ts(8 cases: the exhausted summary naming both numbers, its warn LEVEL, silence on a recovered race, silence on a never-propagation fast-fail, the budget-already-spent report when a sequence ends on a different non-retryable error, singular/plural, the cumulative figure on each debug line, and a generic transient retry NOT being annotated with a propagation budget — 5 of the 8 verified to fail without the fix).tests/integration/iam-propagation-stress/verify.shnow deploys with--verboseand prints a[measure]block (wall clock, retries observed, deepest slept budget of the 47.75s allowed, whether the budget was exhausted), recorded whether the run passes or fails — because a passing run has not proved the race is absent, only that this run won it, and it says so explicitly when zero retries fired. - ⚠️ BEHAVIOR CHANGE:
cdkd deploynow exits 2 when it leaves a resource unaddressed (issue #1960) —src/cli/commands/deploy.ts,src/cli/options.ts.cdkd destroyhas treated "cdkd could not address this resource, so it may still be alive in AWS" as a partial failure (exit 2) since issue #1752;cdkd deployprinted the same finding and exited 0, for BOTH of its equivalents — a skipped DELETE (Skipped (not deleted): N, issue #1762) and a replacement whose predecessor survived (of which left an orphaned predecessor: N, issue #1819). A pipeline reading only the exit code was told the template had been applied when it had not. Both cases now feed one run-level counter, summed across every stack, and raisePartialFailureError(exit 2) after the work graph drains — placed there so a genuine deploy failure still wins the exit code, mirroring howdestroy.tscheckstotalErrorsbeforetotalSkipped. This can turn a currently-green pipeline red. The new--allow-unaddressedflag restores exit 0, taking the run-levelPartialFailureErrormessage with it (that message is the only place the run-level remediation text and the cancelled-stack count appear); the summary rows, the per-resource warnings, the⚠banner and theRunCounts.skippedfigure incdkd eventsare untouched. The flag exists because the orphaned-predecessor case has a real not-yet-fixable window — an ACM certificate replacement rejected while a CloudFront consumer still references the old certificate clears itself onceDescribeCertificate.InUseByempties — and because a shell|| [ $? -eq 2 ]wrapper is NOT an equivalent workaround:cdkd deployalso exits 2 forMacroExpansionErrorandResourceUpdateNotSupportedError, so the wrapper would swallow unrelated real failures. Note the two cases differ in recoverability and the docs now say so rather than repeating the issue's framing that both leave the resource "no longer in state": a skipped DELETE deliberately KEEPS its state record and self-heals on the next deploy, while a replacement's survivor is untracked and never retried. Two further surfaces said the run had succeeded and were changed with it, because fixing only the exit code would have left the mis-report half-shipped. (a) The console banner: a run that left a resource alive no longer prints✓ Deployment completed successfully, switching to a⚠ Stack X deployed, but N resource(s) were left unaddressedwarning that mirrors destroy's⚠ Stack X partially destroyed. A pipeline that greps the log for that success string breaks too, independently of the exit code — and note the line also moves from stdout to stderr, since it is now alogger.warn, so a scraper reading only stdout loses it entirely rather than merely failing to match. (b) The events store:RUN_FINISHEDnow recordsresult: 'FAILED'for such a run, matching whatcdkd destroyhas recorded for the identical outcome since #1752 — recordingSUCCEEDEDwhile the same run returned 2 is a split verdict rather than a nuance. That is the third pipeline-visible break in this entry: a consumer filteringcdkd eventsonresultsees these runs flip from SUCCEEDED to FAILED.--allow-unaddresseddoes not touch either: the banner still warns and the run is still recordedFAILED, because the events store records what happened, not what the operator chose to tolerate.recordRunSucceededwas accordingly replaced byrecordRunOutcome(same counts payload, caller-chosen result);recordRunFailedcould not serve it, since it carries error metadata and no counts and theskippedfigure is the only thing in the summary saying a resource survived. Covered bytests/unit/cli/deploy-unaddressed-exit.test.ts, which drives the real commander command and asserts onprocess.exit(20 cases: the clean run staying 0 with its banner intact, each case alone exiting 2, both summed across a two-stack run, per-stack counts not leaking the run total into a clean stack's banner, the flag forcing 0 while the warning and banner switch survive it, and a genuinely failing stack keeping exit 1 with and without the flag; plus both polarities of theRUN_FINISHEDresult and of the cancellation note, and--dry-runcounting nothing). A 21st case intests/unit/cli/deployment-events-run.test.tspins theresultat the layer that PERSISTS it — the deploy-side casesvi.mockthat module, so they assert what deploy passes and never what the store records, and hard-coding'SUCCEEDED'insiderecordRunOutcomepassed the whole unit suite until it existed. A stack the user CANCELLED (declined prefix-migration gate, or an interrupt before it started) unwinds with a barereturn, so its work-graph node completes and the run reaches this throw — the message now says how many stacks never deployed, since naming only the survivors would read as "everything else was applied". - ✅ A provider log line can no longer echo a RESOLVED secret in plaintext:
CreateContext/UpdateContextnow carry amaskSecretscapability (issue #1932 item 3) —src/types/resource.ts,src/deployment/secret-redaction.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts,src/cli/commands/drift.ts,src/provisioning/providers/cognito-provider.ts,tests/unit/deployment/{secret-redaction-masker,deploy-engine-provider-secret-masker,rollback-executor-secret-masker}.test.ts,tests/unit/provisioning/cognito-provider-masked-warn.test.ts,tests/unit/cli/drift-secret-redaction.test.ts,docs/provider-development.md,.claude/rules/{providers,code-layout}.md. The bug:CognitoUserPoolProvider's dropped-factor warning interpolates a resolved property value straight intologger.warn(${JSON.stringify(properties['EnabledMfas'])} (not a list)). cdkd's secret masking lived at TWO boundaries only — the deploy engine's error / reason text and the intrinsic resolver's own debug line — so a provider's OWN log line sat outside all of it, and a{{resolve:secretsmanager:...}}scalar printed in plaintext at warn level. A mis-shaped value is exactly the input that warning exists to surface, so the unhappy path is the one that prints. Bounded honestly: the exposure is LOG-ONLY. The value does not reachdeployments/*.jsonl(only thrown errors do, and those already pass throughmaskSecretsInText), so this was log exposure, not persisted exposure. Why it needed more than a one-line mask:maskSecretsInTextrequires aRecordedSecretValuesbag as its second argument, and no provider undersrc/provisioning/could reach the one its caller's resolution pass produced — so the fix is a new provider-facing CONTRACT, not a call. The contract: a sharedSecretMaskingContextbase with one optional field,maskSecrets?: (text: string) => string, which BOTHCreateContextandUpdateContextextend. Declared once and inherited rather than written twice, becausecreate()andupdate()receive the same resolved bag from the same callers — a masker on one path only is not a partial fix, it is a fix with a hole in the shape of whichever path a given deploy takes. Optional, so none of the ~130 registered providers needed editing and a provider that ignores it behaves exactly as before. A FUNCTION, not the bag, and the alternatives were rejected on record:RecordedSecretValuesis keyed by PLAINTEXT, so handing it over would make every provider a place a[...secrets.keys()]can leak from — strictly worse than the leak being fixed; the function also keepssrc/provisioning/**free of asrc/deployment/secret-redaction.tsimport, and can be WIDENED later (to coverNoEchoparameters) by changing the callers alone. The codebase already had this shape:drift.tshandswithRetrya maskinglogger.debugrather than the bag. A masked LOGGER was rejected too — providers are registered as SINGLETONS serving concurrent resources, so there is no per-call logger seam and nowhere safe to stash one; the masker is per-CALL for the same reason, and a provider must never cache it onthis. NO LENGTH CAP, deliberately: the issue offered one as an alternative, and it buys nothing here. A cap is not a confidentiality control — a SHORT secret survives it untouched (the realistic content of an MFA-factor enum field), while a LONG value it truncates is one the masker already judged not to be a secret — and its real cost is this warning's whole job, naming the offending entry. Threaded at all THREE external callers that hand a provider a resolved bag —deploy-engine.ts,rollback-executor.tsandcdkd drift --revert; the five providers that re-create inside their ownupdate()pass no context, since they forward the outer call'spropertiesand have none of their own. Thedrift --reverthalf was found by review, and it is the same class as the rollback one:resolveStateSecretExpressions(its own doc calls it "the counterpart of the rollback replay'sresolveReplayProps") re-resolves the state record back to plaintext, so a state record holdingEnabledMfas: "{{resolve:secretsmanager:...}}"re-resolves to a plaintext string, tripsnot a list, and printed the secret on the one command a user reaches for when something is already wrong. Per caller: the deploy engine's CREATE, UPDATE, and all four replacement creates (each bound to that resource's OWN resolution pass, matching the per-resource scoping the persisted-state redaction already has), plus the rollback executor's two reverse-replacement re-creates and both UPDATE arms. The rollback half is not optional:resolveReplayPropsdeliberately re-resolves every redacted{{resolve:...}}expression back to plaintext before the provider call, so a replayed bag is GUARANTEED to carry the concrete secret whenever the resource has one — threading only the deploy engine would have left the contract applied at one caller and absent at the one whose bag is provably plaintext. The rollback re-create arms spread the sharedREPLAYING_STATE_CREATE_CONTEXTconstant rather than mutating it, sincemaskSecretsis per-op and that object is shared by every op in a run.DeleteContextdoes NOT get the capability, and the first rationale for that was FALSE. It claimed everydelete()bag is read from cdkd state, which holds the redacted expression. Review's counter-trace is right: a CREATE writes resolved PLAINTEXT into the IN-MEMORYstateResources, redaction happens at the save choke point on a COPY (redactStateForPersist→scrubResourceRecord), and the in-process rollback hands that same in-memory map toreplayRollback, whose delete arms readcurrent.propertiesstraight off it. A delete bag CAN carry a secret. What is true is narrower and is now what the code says: no providerdelete()interpolates a property value today. The capability was still not added, on correctness rather than effort grounds — that plaintext was resolved by the DEPLOY, whose bag lives inDeployEngine.perResourceSecrets, whileRollbackExecutorContexthas no secrets field and the executor's own per-op map is re-resolved from the PREVIOUS generation, so a masker bound to it would miss exactly the value it exists to catch whenever the generations differ: protection that fences nothing, which is worse than none because it stops the next author looking. Doing it properly needs a new field onRollbackExecutorContextplumbed from the engine, filed separately. The contract now states it as a MUST for the next author: thread the capability BEFORE adding any delete-side line that names a property value. Scope of what it masks, stated because the issue overstated it: cdkd's dynamic-reference secret model only. ANoEcho: truetemplate PARAMETER is OUTSIDE that model by construction — the resolver redacts it in its own debug line (stringifyParameterForLog) but never RECORDS the value — soEnabledMfas: {Ref: SomeNoEchoParam}is NOT masked by this, and no masker built from aRecordedSecretValuesbag could be: that map exists to rewrite a plaintext back onto the{{resolve:...}}expression it came from, and aRefhas none. RecordingNoEchovalues into it would also change whatredactSecretsForStatePERSISTS, so it is filed separately (issue #1998) rather than smuggled in here. That residual is persisted, not log-only — aNoEchovalue quoted back inside an AWS error reachesdeployments/*.jsonl, since the event masker reads this same bag — which is the opposite of the dynamic-reference case fixed here, whose errors already pass throughmaskSecretsInText; the two must not share a framing. Masking the finished LINE is not sufficient, and that is the correction that mattered most. The first draft masked only the assembled message, which review probed live and found defeated byJSON.stringify: masking matches by literal occurrence, and stringification escapes",\and newlines, sosuper"secret-plaintext-valuecame through completely unchanged — i.e. every Secrets Manager JSON document, the commonest real secret shape. A second, independent reason points the same way:maskSecretsInTextmasks an exact whole-value match at any length but only SCANS for substrings of at leastMIN_NEEDLE_LENGTH(4), and a message is always longer than the value inside it, so a 1-3 character secret survives a message-level mask too. The contract's guidance said "applying it to the whole line is never worse than applying it to the parts" — backwards on both counts, and it is that paragraph every future provider copies. It now states the rule: mask the VALUE before it is stringified or interpolated; the message is a fallback. In the provider:buildMfaConfigRequesttakes the masker (typed as the contract's ownSecretMasker, re-exported fromsrc/types/resource.tsthe wayDeleteContextis, so providers still import only fromtypes/and never name the secrets bag — an earlier structural re-declaration bought nothing oncetypes/resource.tsalready imported the alias, and only created room to drift from the contract) and applies BOTH layers: a boundedmaskDeepwalk masks every string leaf and key beforeJSON.stringify, and ONE masked sink —const warn = (m) => logger?.warn(maskSecrets(m))— carries all three of its warnings, so a warning added later is masked by construction. The walk is a walk rather than a top-leveltypeof v === 'string'test because a secret nested in an object leaf is escaped identically.CognitoUserPoolProvider.update()gains acontext?: UpdateContextparameter it did not have; it readsmaskSecretsalone and deliberately does NOT consultdesiredFromAwsReadback(no motivating shape, and theMfaConfigurationrefusal stays unconditionally downgraded). Tests: 29 added — 28 across 4 new files (6 masker / 13 Cognito / 5 deploy-engine / 4 rollback-executor) plus 1 in the drift redaction suite, every one mutation-verified against the real source rather than assumed. Dropping the mask from the provider's warn sink fails 4; dropping ONLY the update-side forward fails exactly the 2 update cases while the create cases stay green (the half-applied-twin probe); removing the pre-stringify walk fails 4 (the escaped-secret, nested-secret and two short-secret cases); reducing that walk to a top-level string test fails exactly the nested case; binding the CREATE-path masker to an empty bag fails 2 engine cases; removing the UPDATE-path context fails 1; dropping the masker at thedrift --revertcall fails 1; sharing one stack-wide bag across resources on the UPDATE path fails the per-resource isolation twin; and injectingreplayingState: trueat the main CREATE site fails 3 of the tightened fences. Cross-resource isolation is fenced on BOTH the create and update paths — a wrong-bag binding is the highest-severity failure this contract can have. Back-compat is pinned in both directions — no context at all still warns UNMASKED (byte-identical to before), a context without a masker does not throw, and a non-secret value is never mangled. One test holds up a property nothing else does:createSecretMaskercaptures its bag BY REFERENCE with nosize === 0short-circuit, and adding one passes every other test in the repo (measured), because every caller today happens to fill its bag before binding. Fence updates rather than deletions, including two that were nearly downgraded in silence: the three NAMED#1463inverse fences assertedcall.length === 3, which this change makes impossible; they now assert the context's key set is EXACTLY['maskSecrets'], keeping the original power (still failing onreplayingStateand on any other field a future edit adds), and their titles were corrected — they no longer claim "passes NO CreateContext", which had stopped being true. Review then caught thattoHaveBeenCalledWith(a, b, c)is itself ARITY-STRICT, so five further assertions were IMPLICIT#1463fences; the first pass relaxed them toexpect.objectContaining({ maskSecrets }), which would have admittedreplayingState: true— and those five were the only cover over the main CREATE site and the property-driven replacement, the two most-travelled paths. They are now EXACT{ maskSecrets: expect.any(Function) }, verified by injectingreplayingState: trueat the main CREATE site and watching exactly those fences red. One weak negative found on the way —not.toHaveBeenCalledWith(...)inrollback.test.tsis arity-sensitive and would have gone vacuously true — was rewritten to assert on the logical ids instead. No CLI flag, dependency, or state-schema change. - ✅
ELBv2Provider/ServiceDiscoveryProviderno longer emit a RESOLVED secret in plaintext, on either their retry-logging or their error-throwing path (issue #2050) —src/provisioning/providers/elbv2-provider.ts,src/provisioning/providers/servicediscovery-provider.ts, newsrc/provisioning/masked-retry-logger.ts. Found by the #2038 acceptance-item-3 audit, and a DIFFERENT mechanism from the one that issue describes. Two surfaces, and the second is the wider one. (a) FivewithRetrycall sites threaded the rawthis.logger: twoModifyListenerAttributescalls whoseAttributescome fromListenerAttributes(create + update), andUpdateServiceAttributes/DeleteServiceAttributeswhose payload comes fromServiceAttributes(create + both update arms).withRetryinterpolates the AWS message VERBATIM into its per-attemptdebugline and into the give-upwarnsummary added for #2018, and that summary prints WITHOUT--verbose. (b)withRetrythen rethrows the RAW error, and all four of these providers' create / update methods interpolatederror.messageinto the wrappingProvisioningError, whichdeploy-engine.tsprints at ERROR — default verbosity again. So masking only (a) left the identical text unmasked one line later, which is what a second review round caught by execution. (b) is also the ONLY surface for a NON-RETRYABLE rejection: the give-up summary is gated onpropagationRetries > 0 || serverErrorRetries > 0, so a validation error failing on attempt 0 logs nothing at all and can escape only through the throw. Both are now masked on the Listener AND TargetGroup arms of both providers, plus three lower-risk lines in the sametryblocks (the partial-create cleanup warns) and two warns that name RESOLVED map data:convertTargets' malformed-entry line, which stringifies a whole rejectedTargetselement, and the TargetGroup attribute-removal line, which names an attribute KEY — a key can itself be a resolved secret, the same question the ServiceDiscovery side already answered forDeleteServiceAttributes, and the two files must not disagree about it. TheTargetsline masks BEFOREJSON.stringify, not after, and that ordering is the fix rather than a detail:maskSecretsInTextmatches by literal occurrence andstringifyescapes",\and newlines, so a Secrets Manager JSON DOCUMENT — the commonest real secret shape — no longer occurs in the stringified text and passes a later mask through verbatim. This is gap 1 of the threesecret-redaction.tsdocuments ("Mask before you stringify"). The first cut masked after, and its scalar-secret test passed under both orderings; only a JSON-document fixture discriminates them, which is what the test now uses. By the time a provider is called a{{resolve:secretsmanager:...}}scalar is already plaintext, so the value AWS quotes back IS the secret. The obstacle was plumbing, not masking: the #1932 item-3 capability (CreateContext.maskSecrets/UpdateContext.maskSecrets, exactly the(msg: string) => stringshapeRetryLoggerneeds) could not reach these sites becauseELBv2Provider.createdeclared nocontextparameter at all. Both providers' publiccreate()/update()now accept the contract's optional context and thread the capability down to the listener / target-group / service methods; absent stays unmasked, so the import path,cdkd drift --revert, and every existing test are unaffected.warnis threaded, not omitted — dropping it would have silenced the give-up summary on these paths only, trading a disclosure for the reporting hole #2018 closed. The masker is applied toerror.messagerather than to the assembled sentence, because only the raw value can reachmaskSecretsInText's WHOLE-VALUE arm (any length); a longer sentence reaches only the substring arm, which ignores needles under 4 characters. Thecausechain is deliberately left untouched soisRetryableTransientError's$metadatawalk still classifies identically — asserted by a test, not just by reading. On the issue's acceptance item 3 (shouldRetryLoggertake the capability directly instead of each caller hand-rolling the object): NO.RetryLoggeris a structural type any caller already satisfies with a masking object —drift.ts's revert has done exactly that since #1914 — so amaskSecretsoption would be a SECOND spelling of one intent across ~50 existing call sites, and two spellings is how a later author reaches for the unmasked one. The first cut instead put one private factory in EACH provider, which review correctly called out as the thing that lets the two FILES drift; the factory now lives once in the leaf modulesrc/provisioning/masked-retry-logger.tsand both providers call it. Scope is these two providers only — the generic provider-call retries indeploy-engine.tsandrollback-executor.tsare the same mechanism one layer up and are NOT addressed here. Covered bytests/unit/provisioning/elbv2-listener-attributes-retry-masking.test.ts(20 cases) andtests/unit/provisioning/servicediscovery-service-attributes-retry-masking.test.ts(12 cases), which assert the STRING that escaped — the logger's argument, or the thrown error'smessage— because a presence-only assertion is satisfied by the very code this issue is about. Each carries its non-vacuity controls (the submitted payload really did contain the plaintext; the retry really did exhaust so the default-verbosity summary really was emitted; and for the non-retryable cases, thatwithRetrylogged NOTHING, so the throw really was the only surface) and a no-masker back-compat arm on every create AND update path proving the identity fallback still emits, unmasked, exactly as before. Every site was mutation-proven independently: reverting any ONE of the five retry sites, either of the four throw sites, or theTargetswarn reds only the test that fences it; reverting the shared factory reds three tests in EACH provider suite, which is what shows the extraction is load-bearing for both; and reverting theTargetsline to mask-after-stringify reds the JSON-document case while leaving the scalar case green, which is the evidence that the ordering — not merely the presence of a mask — is what closes it. Still out of scope and NOT fixed here:createLoadBalancerand the four ServiceDiscovery namespace arms receive no masker at all, and the generic provider-call retries indeploy-engine.ts/rollback-executor.tsare the same mechanism one layer up; both are filed separately. No CLI flag, dependency, or state-schema change. - ⚠️ BEHAVIOR CHANGE: two Cognito user-pool MFA combinations AWS rejects 100% of the time are now refused BEFORE the first API call, instead of partial-applying on the update path (issues #1977 / #1975) —
src/provisioning/providers/cognito-provider.ts. Both combinations reached AWS and were refused there, and on the UPDATE path that refusal arrives too late:UpdateUserPoollands FIRST, so the pool keeps the NEWPolicies/ mutable fields whileSetUserPoolMfaConfigis rejected andupdate()throws with no provider-side unwind. The half that applies is the one LOOSENING authentication and the half that does not is the MFA configuration — a partial apply on an auth surface, unrecoverable by retry until the user reverse-engineers an AWS-worded error naming a field they did not think they were changing.describeUnsupportedMfaCombinationis one pre-flight seam running ahead of every mutating call on BOTH paths: increate()it sits outside the try and beforeCreateUserPool; inupdate()beforereadLiveMfaConfiguration, i.e. before any AWS call at all. Rule 1 (#1977): the value cdkd would SEND forMfaConfigurationresolves to OFF while theSetUserPoolMfaConfigrequest carries a factor sub-block (hasAnyMfaFactorBlock— exactly{Sms, SoftwareToken, Email}MfaConfiguration, deliberately NOTWebAuthnConfiguration, which rides beside OFF on every passkey-only pool). MEASURED us-east-1 2026-08-19 (#1968) as a 100% rejection includingSoftwareTokenMfaConfiguration {Enabled: false}, which is what settles the key on the block's PRESENCE. Rule 2 (#1975): the same request resolvesMfaConfigurationto ON whilePolicies.SignInPolicy.AllowedFirstAuthFactorsallowsEMAIL_OTPorSMS_OTP. Both members and both MFA modes are MEASURED (us-east-1, 2026-08-19):[PASSWORD, EMAIL_OTP]and[PASSWORD, SMS_OTP]are each REJECTED under ON and ACCEPTED under OPTIONAL — so scoping the rule to=== 'ON'is measured-correct rather than merely conservative, and widening it to!== 'OFF'would refuse a working deploy. TheSMS_OTPprobe needed a pool with a validSmsConfiguration(SNS-caller IAM role + ExternalId): without one,CreateUserPoolrefuses the factor outright with a different error, which is why that arm had gone unmeasured. It is a DENY-list even though the AWS message states an allow-list (Only PASSWORD and WEB_AUTHN (if configured) ...), for the same reason the unrecognized-EnabledMfascase warns rather than throws: an allow-list refuses a factor AWS adds later on the day it starts working. The two members ARE today's wholeAuthFactorTypeenum outside the accepted pair, and the constant isas const satisfies AuthFactorType[]so an SDK rename is a compile error rather than a silently-never-matching list. SCOPE LIMIT, deliberate: rule 2 fires only when aSetUserPoolMfaConfigrequest is actually BUILT. With no MFA-routed property,MfaConfigurationrides on the SINGLECreateUserPool/UpdateUserPoolcall, so there is no two-call window and structurally nothing to partly apply — and refusing there would be an over-refusal on a shape AWS ACCEPTS (measured: a pool created withAllowedFirstAuthFactors: [PASSWORD, EMAIL_OTP]and no MFA call comes up fine; only the laterSetUserPoolMfaConfig(ON)fails). CloudFormation parity is settled by A/B rather than assumed, which both issues made an explicit precondition: CFn hits the same two rejections and reachesROLLBACK_COMPLETEon both shapes ({OFF, EnabledMfas: [SOFTWARE_TOKEN_MFA]}and{ON, EnabledMfas: [SOFTWARE_TOKEN_MFA], AllowedFirstAuthFactors: [PASSWORD, EMAIL_OTP]}on ESSENTIALS). So the refusal is PARITY-PRESERVING, not a divergence — cdkd refuses the same SET of templates CloudFormation refuses, only EARLIER and in its own wording; nothing that deploys under CloudFormation is blocked. Both rules read the sent value through ONEbuildMfaConfigRequestcall (with no logger, so the pre-flight is silent andapplyMfaConfig's warnings still fire exactly once), so the refusal and the request cannot disagree. The refusal is UNCONDITIONAL — a STATED EXCEPTION to.claude/rules/providers.md's replay-downgrade rule, recorded at the refusal itself: that rule presupposes the replay could otherwise SUCCEED, and here it cannot, so downgrading would trade a clear cdkd-worded refusal for the identical AWS failure arriving later with a pool to roll back or a partial apply to unwind. Each error names BOTH offending properties, quotes AWS's own sentence, and leads with the remedy that KEEPS MFA on — the MFA-disabling alternative is demoted to a parenthetical rather than offered as co-equal. KNOWN LIMIT, recorded at the refusal: the pre-flight reads the TEMPLATE, never the live pool, so two edits reach the same partial apply without tripping it — deleting theSignInPolicyblock while settingMfaConfiguration: ON, and omittingMfaConfigurationwhile addingEMAIL_OTPto a pool whose live MFA is already ON. Both need a live-state guard and are tracked in #2051. The #1977 warning is REPLACED, not supplemented: theMfaConfiguration is pinned to OFFwarn added by #1932 and reworded by #1968 is gone, and so is the dropped-entry consequence arm that predicted the same rejection — with the pre-flight in front, OFF at that point really does imply no factor block, which makes the survivingdeploys with MFA DISABLEDarm true again rather than merely usually true. The suite grows from 118 to 143 runtime cases intests/unit/provisioning/cognito-provider.test.ts(21 newit/it.eachblocks, 7 removed or rewritten), including EIGHT must-still-deploy negatives that fence the rules against over-refusal (PASSWORD + WEB_AUTHNunder ON,EMAIL_OTPunder OFF,EMAIL_OTPunder the ACCEPTED OPTIONAL,EMAIL_OTP + ONwith noSetUserPoolMfaConfigon both the create and the update path, a factor cdkd does not know, a passkey-only pool under OFF, and seven mis-shapedPoliciescontainers). Every case is mutation-proven against the real provider: removing either seam, deleting either rule, widening rule 2 to!== 'OFF', dropping its scope limit, keying rule 1 on the call rather than the factor block, countingWebAuthnConfigurationas a factor block, rewriting rule 2 from the allow-list, droppingSMS_OTPfrom the deny-list, naming only the first offending factor, dropping theSmsMfaConfigurationarm of the named-block list, moving either seam after its mutating call, and each surviving shape guard inreadAllowedFirstAuthFactorsall turn the intended cases RED. Two comments were corrected to say only what a test holds, since a false fence claim suppresses the retest:readAllowedFirstAuthFactorstraverses with?.rather than thetypeof === 'object'guardconfig-shape.tsuses because the guarded version was MEASURED inert (no template value indexes to anything at those keys), and its string filter is documented as an INERT type narrowing —includesis SameValueZero, so a non-string can never match a deny-list member and deleting the filter fails no test and could not be made to. ItsArray.isArraysibling IS a runtime guard and IS fenced.
Recently Implemented (2026-08-18):
- ✅ The DynamoDB DELETE path spends ONE wall-clock allowance instead of stacking three independently-capped waits inside one per-resource deadline (issue #1955) —
src/utils/elapsed-budget.ts(new),src/provisioning/providers/dynamodb-delete-budget.ts(new),src/provisioning/providers/dynamodb-globaltable-provider.ts,dynamodb-table-provider.ts,src/provisioning/dynamodb-index-busy-delete.ts,tests/unit/utils/elapsed-budget.test.ts(new),tests/unit/provisioning/dynamodb-delete-budget.test.ts(new),tests/unit/provisioning/dynamodb-index-busy-delete.test.ts. The gap, in three terms. Every wait on the delete path was individually capped and individually justified, and the caps did not know about each other:waitForReplicaGoneat 600 polls (~12 min) per NON-LOCAL replica, the #1521 pre-delete index-settle gate at 900 polls (~18 min), the #1830 / #1931 index-busy retry loop (~10.4 min at this type's budget of 8), andwaitForTableGoneat 600 polls (~12 min) on a late loop SUCCESS.destroy-runner.tsruns the whole thing under ONEDEFAULT_RESOURCE_TIMEOUT_MS(30 min), so a replicated GlobalTable whose index was transitioning reached ~40 min and a single-region one reached ~40.4 min on the late-success path (1).withResourceDeadlinedoes NOT cancel what it wraps, so an overshoot both replaced AWS's own actionableCannot delete table while indexes are being ...sentence with a genericResourceTimeoutErrorthat never mentions indexes AND left the polling loop running behind a run that had already reported failure (2). AnddeleteTableWithIndexBusyRetry's innerisRetryableis index-busy-ONLY by design (the classifier is a single message regex, which is the whole safety argument for retrying aResourceInUseExceptionat all), so a THROTTLEDDeleteTableescaped todestroy-runner.ts's outer loop — which DOES class throttles as retryable and re-entersdelete()from the top inside the SAME deadline, paying the whole settle-and-retry sequence a second time (2 x ~19.6 min ≈ ~39 min on theAWS::DynamoDB::Tablepath after issue #1950 raised that type's retry budget to 14) (3). What shipped. A shared elapsed budget, acquired once per physical table at the top ofdelete()and drawn on by every wait below it: the #1521 gate,waitForReplicaGone, the per-retry re-arm,waitForTableGone, and (on theTableside) the--remove-protectionACTIVE wait each askpollsWithinDeleteBudget(ownCap, budget)for the SMALLER of their own constant and what the allowance can still afford, so the path spends the deadline once rather than three or four times (1).deleteTableWithIndexBusyRetrygained an optionalshouldKeepRetryingpredicate, ANDed with the index-busy classifier so a terminal error stays terminal regardless of budget; both providers pass one that goes false when the allowance is spent, which makes AWS's refusal terminal and surfaces ITS message rather than the deadline's, with nothing still polling afterwards (2). The budget lives in anElapsedBudgetRegistrykeyed by physical id rather than being created per call, so the outer loop's re-entry CONTINUES the same clock instead of restarting it — it is released on a terminal success and on the already-gone skip arm, and deliberately RETAINED on a throw, which is exactly the compounding case (3). Sizing.DYNAMODB_DELETE_BUDGET_MSis 26 min, one number for both types (the two per-type RETRY budgets are unchanged and still decide how the allowance is SPENT; this decides how much there is). It covers theTablesingle pass (~19.6 min) outright and clamps the GlobalTable single-region exhausted-budget case (~28.4 min) by ~2.4 min — which costs nothing, since that path ends by throwing AWS's own index-busy sentence either way, just sooner. Every other overshooting shape (the late-success gone-wait, any replicated table, and both types' throttle-compounded double pass) previously ended in the genericResourceTimeoutErrorwith the loop still running, so a bounded stop is a strict improvement there rather than a trade. Both providers now declaregetMinResourceTimeoutMs()returning26 min + a 4 min margin= 30 min, which EQUALS today'sDEFAULT_RESOURCE_TIMEOUT_MS, so at default settings nothing moves (max(30m, 30m)); what it buys is that a lowered global--resource-timeoutcan no longer re-create the crossing, while a per-type override still wins as the documented escape hatch. The margin absorbs what the poll arithmetic does not price: theDeleteTable/UpdateTablecalls themselves, the outer loop's own 5 + 10 + 20s backoff (which elapses betweendelete()calls and therefore inside the budget's clock but not inside any wait), and the one-poll floor each wait is granted so it never reports "did not settle" without having looked. Review round (four reviewers) added five more edges of the same design. (1) The GlobalTable--remove-protectionACTIVE wait was MEASURED by the allowance but not CLAMPED by it — it runs FIRST and its predicate is ACTIVE and no transitional index, so oncdkd destroy --remove-protectionagainst a table with a building GSI it spent its full ~12 min at t=0, and on a throttle re-entry it started already drained and polled another 600 times unclamped. ItsTabletwin had been budgeted; both now are. (2) The GlobalTable auto-scaling teardown — table-level plus one call per GSI, per non-local replica — runs before the first budgeted poll and is not poll-bounded at all; each call retries account-wide application-autoscaling throttles onwithRetry's ~47s schedule, so a two-replica twenty-index table could spend over half an hour there. Its RETRY count is now bought down by the remaining allowance while every call is still ISSUED, because skipping leaks a scalable target a future table of the same name inherits (PR #403). (3) A clamped wait reported its CLAMPED count as though it were the wait AWS was given (did not disappear within 1safter cdkd had been deleting for twenty-six minutes); every such message now names which clock ended the wait, via one shareddeleteBudgetExhaustedNoteand aclampedFromCapfield onwaitForIndexesSettled. (4) Worse, a budget-clampedwaitForTableGoneturned a SUCCESSFUL delete into a reported failure —DeleteTablehad already returned 200, so the deletion was no longer in cdkd's hands. A budget-clamped gone-wait now WARNS and returns; a genuinely exhausted 600-poll cap still throws, unchanged. (5) A replica-wait failure inherited the enclosingcatch'sFailed to describe ... before deletewrapper — a describe failure that never happened — so an already-actionableProvisioningErrornow passes through verbatim. Plus four smaller ones:attemptsWithinfailed OPEN on a non-finite per-attempt cost (returning the full cap, i.e. the pre-fix behaviour) and now fails closed; the registry key is region-qualified, since a DynamoDB table name is unique per region; the default clock isperformance.now()rather thanDate.now(), because a forward NTP correction on a wall clock drains the allowance instantly mid-delete; and bothgetMinResourceTimeoutMsJSDocs plusdocs/cli-reference.mdnow state that the self-report is per TYPE, so--resource-timeout 5mfor fail-fast CI does not shorten these types' CREATE / UPDATE deadline either (the per-type override is the escape hatch). The test reviewer also found two of the new tests VACUOUS — both release-semantics arms passed with therelease()calls deleted, because the stub left the table gone and the seconddelete()took the skip arm before any budgeted wait — and five paths with no coverage at all (waitForReplicaGone, whose stub only ever had a LOCAL replica; theTableretain-on-throw arm, i.e. the one type #1955's third term was actually filed against; both flip waits; the providers' registry ARGUMENT as opposed to the registry's own keying; and a floor-vs-ceil rounding case whose fixtures divided evenly). All are now covered with paired polarities, and each was re-run under the exact mutation that motivated it to confirm it reds.
Second review round turned the allowance from a PREDICTION into a bound, and corrected one of the first round's fixes. (a) Every wait computed its poll count once at entry from attemptsWithin, which prices a poll at the measured ~1.2s and then treated that prediction as a bound. It is not one: under sustained DescribeTable throttling the SDK's internal retries push a poll to ~3s, so a wait entered with 12.1 min of allowance left was granted its full 600 polls and ran ~30 min — past the allowance AND the margin, with withResourceDeadline firing behind it, i.e. term (2) re-entering through the mechanism built to remove it. The grant is still computed at entry (it is what gives a caller a sensible cap and a truthful polls-granted figure) but every loop now re-checks the allowance per iteration through a DeleteWait object, and the check sits AFTER the first probe so the one-poll floor survives. (b) The first round's auto-scaling clamp was inert: the claim that the teardown inherits withRetry's default schedule was false — both withRetry sites in applyAutoScalingDiff are in the REGISTER branch, and every delete-path caller passes newSettings: undefined, which takes the bare-send teardown branch. Rather than delete the parameter, the teardown was wrapped in the same throttle-only retry, because the asymmetry it exposed is a real gap: the register branch retries so a swallowed throttle cannot leave a target silently UNregistered, and the teardown had no retry while a swallowed throttle there leaves one silently REGISTERED — the PR #403 leak a future table of the same name inherits. The retries are funded from the SURPLUS above a 20-minute reserve, so a throttled burst cannot spend the allowance the delete itself needs (bounding it by the whole remaining allowance would have converted a silent leak into a destroy that fails on the first index-busy refusal), and at zero retries withRetry still runs each call once, so the leak-prevention never depends on the retry. (c) The warn-versus-throw split on waitForTableGone was decided by attempts < cap, i.e. by arithmetic: with ~14 min of the allowance spent the wait is still granted ~500 polls — ten real minutes of DescribeTable — and that warned and dropped state while the code called twelve minutes "something is actually wrong". A named DELETE_SHORT_WAIT_POLLS (60 polls, ~72s, twice the top of the provider's own recorded 5-30s typical delete) now decides both the split and whether the strong exhaustion note is emitted at all; above the floor the note degrades to a factual capped-at clause with no "re-run the destroy" advice. (d) The note quoted the shipped constant rather than budget.totalMs, so under the test seam it claimed 26 minutes for a 60-second budget. (e) Registry entries are retained on a throw by design, but "retained" now means "until the deadline this allowance was sized against has certainly fired" — acquire takes a reuse window, so a provider instance shared across operations (the deploy engine hands the same one to rollback-executor.ts) cannot hand a fresh delete a spent allowance. (f) The 600-poll waits were described as "~10 min" in the providers and "~12 min" in the budget arithmetic; 12 is right at the measured ~1.2s per poll and all sites now agree. Each of the eight changes was re-run under the exact mutation that motivated it — including removing the in-loop check, moving the exhaustion check ahead of the probe, and unwrapping the teardown — and all eight red.
Deliberately NOT done: the raw caps are unchanged — nothing was shrunk, so a table that genuinely needs the #1521 gate's 900 polls still gets them when the allowance can afford it; the fix bounds the PATH, not the individual waits. Tests: the new elapsed-budget.test.ts pins the primitive (draining, the backwards-clock clamp, the one-poll floor and its cap-clamp, registry REUSE on re-acquire, per-key isolation for the concurrent-sibling case, release); dynamodb-delete-budget.test.ts drives REAL delete() calls through an injected clock — real time cannot drain a 26-minute allowance and vi.useFakeTimers() would freeze the providers' own polling loops — with one block per term, plus the discriminating halves (the gate keeps its FULL 900 while the budget can afford it; a budget with time left still spends the full retry count) so the fences cannot pass by simply switching the waits off. Each CI-blocking assertion was verified to fail against the pre-fix behaviour by mutating the real providers: dropping the gate's budget reds 3 tests, dropping shouldKeepRetrying reds 2, releasing the budget on every entry reds the compounding test, and dropping the gone-wait's budget reds its own. The existing #1950 fence in dynamodb-index-busy-delete.test.ts asserted the ~40.4 min late-success total as a TRACKED overshoot and said in-code that a fix bringing it back under the deadline should red it; it does not, because the fix bounds the path rather than the caps — so that fence now asserts BOTH halves (the raw caps still sum past the deadline, AND the allowance the path may actually spend is under it with margin), which is what stops a change deleting the budget from leaving the file green.
- ✅ The resolved dynamic-reference cache can no longer carry one region's secret into another region's resource, nor make a later stack's secret scan come up empty (issue #1933, PARTIAL — see below) —
src/deployment/intrinsic-function-resolver.ts.{{resolve:secretsmanager:...}}/{{resolve:ssm:...}}values are REGIONAL, but the cache was a process-global map keyed by the expression STRING alone and never reset between stacks, so the first region to resolve an expression won it for every later stack in every other region, andcdkd scrub --allcould report a later stack clean because its cache hit re-recorded nothing into that stack's secrets map. The cache now lives on the RESOLVER INSTANCE (one per stack, each pinned to its own region), which settles both dimensions at once; a key change alone would have left the cross-stack half open. Each entry also carries the secret VERDICT that produced it, so another region's resolver retracting the process-global memo cannot stop this one redacting its own secret — and the cache-hit arm reads that verdict ALONE, since honouring the global store there would re-admit a foreign resolver's answer and could turn a PUBLIC value into a redaction needle. An ssm value whoseTypeis not a definitiveSecureStringis no longer cached at all, so the next pass re-asks instead of inheriting a transient verdict; both lookups gained throttle-only retries and a per-expression warn dedupe to absorb the extra call volume, androllback-executor.tsnow builds ONE resolver per replay rather than one per op. Still open as issue #1957: the lookups themselves run through the process-ambientgetAwsClients()singleton, whichcdkd deployre-pins per stack (correct when serial) but the default--stack-concurrency 4races for, and whichcdkd scrubnever re-pins — so the wrong-region READ this issue's title names is not fully closed by the cache fix. Live-covered by a newtests/integration/dynamic-ref-cross-regionfixture (two regions, one shared parameter name,StringandSecureStringarms, asserting each stack resolves its own region's value and that state holds the unresolved expression and neither plaintext). - ✅ A dynamic-reference lookup now runs against the STACK's region instead of whatever region the process-global client singleton last held (issue #1957) —
src/deployment/intrinsic-function-resolver.ts,src/utils/aws-clients.ts,tests/unit/deployment/dynamic-reference-region-scoped-clients.test.ts,tests/unit/utils/aws-clients-region-scoped.test.ts. The gap: issue #1933 closed the CACHE as a cross-region carrier, but every AWS lookup in the resolver readgetAwsClients()— the process-global singleton — so a resolver constructed for region B could still make its FIRST lookup against region A's client, with no cache involved. Three commands reach it:cdkd deploydefaults to--stack-concurrency 4and re-points the singleton per stack, so two stacks in different regions race for one mutable global and stack B'sGetSecretValue/GetParametercan execute against stack A's client (the value is redacted on its way into state, so nothing downstream shows which region answered);cdkd scrub --allinstalls its clients ONCE while resolving per-stack regions, so a region-BSecureStringwhose region-A namesake is a plainStringwas classified PUBLIC and its plaintext left instate.json— the GHSA-p5qg-v9gv-hc7w disclosure class rather than merely a wrong value; andcdkd drift --revertWRITES the resolved value to a live resource, where a wrong-region read is a wrong write. The fix is one mechanism in the resolver rather than a patch at each of the tensetAwsClientscall sites, because the resolver already fixes its region at construction (#1933's instance scoping) and every construction site already passes the per-stack region — what was ambient was never the region it KNOWS, only the clients it reached for.AwsClientsgainedconfiguredRegion(the EXPLICITLY configured region and nothing else),credentialConfig(theprofile+ explicitcredentialshalf, deliberately WITHOUT the region, and CLONED so a caller cannot mutate the source instance's credentials through it) andwithRegion(region); the resolver'sclientsForRegionderives and caches one sibling per foreign region. The ambient bag is reused only when its region is CONFIGURED, and getting that condition right took three attempts, each of which looked sufficient. Readingprocess.envwas the first and is worse than not knowing: the SDK memoizes a region-less client's region at its first resolution (@smithy/node-config-provider'sloadConfigwraps the chain inmemoize) whileswitchRegionkeeps mutatingAWS_REGIONper stack, so an env-derived answer can report region X for a client long since pinned to P. Asking the SDK (ssm.config.region()) was the second and is still wrong:clientOptionsOMITSregionfrom an unconfigured instance, and the getters are lazy, so the bag is not a bag of clients but a bag of DEFERRED constructions — each member resolves and memoizes its own region at its own instant, andssmpinningus-west-2says nothing about asecretsManagerconstructed a moment later, after a sibling stack'sfinallyrestoredAWS_REGION, which pinsus-east-1. That is Site 1 of this very issue surviving inside the arm meant to fix it. Only a CONFIGURED bag passesregionto every member and is therefore internally consistent, andwithRegionalways sets one — so an unconfigured ambient is not "of unknown region" but "of not-yet-decided region", and it SCOPES. With no region left to await, the seam is synchronous again. What keeps the ~260 suites that stubgetAwsClients()with a plain object green is now a separate, explicitly named arm — an object that cannot DERIVE a sibling (withRegionabsent) is a test double and is used as-is — rather than a side effect of the region guard, so the security arm no longer has a testing job to do. On credentials, one framing in the first revision of this entry was wrong and is retracted:--profileDOES reach a freshly constructed client on its own, becausesrc/cli/program.tssetsprocess.env.AWS_PROFILEin apreActionhook for every command, so carryingprofileis belt-and-braces rather than what stands between a user and the wrong account. What genuinely has no environment path is an explicitcredentialsobject, plus any library caller that constructsAwsClientsdirectly and never runs that hook.--role-arnneeds nothing carried at all, sinceapplyRoleArnIfSetexports the threeAWS_*credential variables. Rebound sites: both dynamic-reference lookups ({{resolve:secretsmanager:...}}/{{resolve:ssm:...}}), theAWS::SSM::Parameter::Value<>template-defaultGetParameter, the EC2DescribeInstances/DescribeLaunchTemplatesattribute lookups (a foreign-region client answersNotFound, degrading to the physical-id fallback those branches exist to avoid), andFn::GetAZs— which additionally fixes an explicit foreign region (Fn::GetAZs: us-west-2from a us-east-1 deploy), sinceDescribeAvailabilityZoneslists the AZs of the region the CLIENT points at and itsregion-namefilter narrows rather than redirects, so the call returned an EMPTY list and cached it as the resolved value. Two template-derived regions are additionally GATED, because a region stopped being only a filter value and started selecting an ENDPOINT.Fn::GetAZsis the first. Its argument is TEMPLATE-derived and can arrive through anFn::ImportValueor a parameter, and the SDK substitutes a region into the service hostname —evil.example.com#yieldshttps://ssm.evil.example.com/#.amazonaws.com, a SigV4-SIGNED request (access key id + signature) to an attacker-controlled host — so a value that is not region-shaped is now REFUSED before it can reachwithRegion(charset-based: lowercase alphanumerics and hyphens, which cannot express a host delimiter, and deliberately not an AWS region registry, sinceeusc-de-east-1and friends keep arriving;clientsForRegionkeeps a softer backstop for any future caller). And an EMPTY availability-zone list is now refused rather than cached: every enabled region has at least one AZ, so an empty answer means the wrong region's endpoint replied or the region is opt-in and not enabled, andcachedAvailabilityZonesis module-global so one degenerate answer would be replayed for the rest of the process. The second gated path isFn::GetStackOutput'sRegion, which is PRE-EXISTING rather than introduced here but has two trusted sinks and is one call away from the same gate: aCloudFormationClientregion (a SigV4-signedDescribeStacksto an attacker-named host) AND an S3 STATE-KEY segment (cdkd/{stack}/{region}/state.json, where a traversal reads a key the template never named). It now refuses a non-region-shaped value and passes the CANONICAL form to both sinks so they agree on one spelling. An unsafe region reachingclientsForRegionitself now THROWS rather than falling back to the ambient: forscrub/drift/import, whose region is state-derived, falling back means reading ANOTHER region — which is the disclosure, so a stopped command is strictly better. TheGetCallerIdentityinresolveAccountIdentityis deliberately NOT rebound — it resolves the ACCOUNT and is region-agnostic by construction. BEHAVIOUR CHANGE worth calling out, accepted deliberately: for a REGION-AGNOSTIC stack (noenv.region) run with neither--regionnorAWS_REGION, the region cdkd computes is its hard-codedus-east-1fallback, so the lookup now goes there — where before it followed the ambient clients to whatever~/.aws/configsaid. The new behaviour is the consistent one, sinceus-east-1is already the region cdkd keys that stack's state file, lock and export index under, so the resolved value and the record storing it finally agree; previously they did not, which is this issue's own defect one layer up. A stack WITH an explicitenv.regionis unaffected — every caller prefers it. Tests: 32 units across two new files. The resolver file fakes the SDK client CLASSES rather thangetAwsClients(), which is exactly what makes the bug observable — the usual plain-object fake has no region, so "which region answered?" cannot be asked of it — and asserts the region AND the profile the client was BUILT with, plus the acceptance-criterion-3 case (a region-BSecureStringrecorded as secret while region A holds aStringnamesake) and four negative cases pinning that the common path constructs no extra client. TheAwsClientsfile uses no mocks at all, reading the resolved region and credentials back off REAL SDK clients. Both were verified against mutations: disabling the seam reds the positive resolver cases while the negative ones correctly stay green, removing theFn::GetAZsregion gate at BOTH layers reds the refusal case with the fake client recording a construction forevil.example.com#, reverting each of the two EC2 rebinds, theFn::GetAZsfallback arm, the empty-AZ refusal and the client cache's region key reds exactly one case each, deletingcanonicalizeRegionfrom the seam reds the case-folding test, removing theFn::GetStackOutputgate letsUS-WEST-2reach the S3 state key verbatim and stopsevil.example.com#being refused, and restoring the one-member short-circuit reds the divergent-bag case with the resolver forus-west-2receivingus-east-1's password — the disclosure itself, reproduced. Live coverage extendstests/integration/dynamic-ref-cross-regionwith a fourth arm and a new phase: a MIXED-TYPE source parameter (one shared name that is a plainStringin region A and aSecureStringin region B — the only arm whose failure mode is a disclosure rather than a wrong value, since secret-ness is decided by the TYPE), and acdkd scrub --allphase run from region A against state deliberately seeded to hold region B's plaintext the way a pre-GHSA-p5qg-v9gv-hc7w binary would have left it. Scrub is the right vehicle for the issue's acceptance criterion 3 because it installs its clients ONCE and then resolves stacks in several regions, so the wrong-region read is STRUCTURAL rather than timing-dependent — pre-fix the injected plaintext is classified against region A'sStringnamesake, judged public, and survives the scrub. The issue's acceptance criterion 2 (a cross-region deploy at the DEFAULT--stack-concurrency) is deliberately NOT covered and cannot be, yet: the same process-global singleton is read by 42 provider files, most of them at CALL time, andswitchRegionmutatesprocess.env.AWS_REGIONfor the whole process, so a lost race at the default concurrency of 4 CREATES resources in the wrong region — a billed orphan invisible to region-keyed cleanup. That provisioning half is filed as issue #1981; until it lands, a cross-region deploy at the default concurrency is unsafe to run at all, and the fixture's header records that decision so the arm is not "helpfully" added later.
Recently Implemented (2026-08-18):
- ✅ The resolved dynamic-reference cache can no longer carry one region's secret into another region's resource, nor make a later stack's secret scan come up empty (issue #1933, PARTIAL — see below) —
src/deployment/intrinsic-function-resolver.ts.{{resolve:secretsmanager:...}}/{{resolve:ssm:...}}values are REGIONAL, but the cache was a process-global map keyed by the expression STRING alone and never reset between stacks, so the first region to resolve an expression won it for every later stack in every other region, andcdkd scrub --allcould report a later stack clean because its cache hit re-recorded nothing into that stack's secrets map. The cache now lives on the RESOLVER INSTANCE (one per stack, each pinned to its own region), which settles both dimensions at once; a key change alone would have left the cross-stack half open. Each entry also carries the secret VERDICT that produced it, so another region's resolver retracting the process-global memo cannot stop this one redacting its own secret — and the cache-hit arm reads that verdict ALONE, since honouring the global store there would re-admit a foreign resolver's answer and could turn a PUBLIC value into a redaction needle. An ssm value whoseTypeis not a definitiveSecureStringis no longer cached at all, so the next pass re-asks instead of inheriting a transient verdict; both lookups gained throttle-only retries and a per-expression warn dedupe to absorb the extra call volume, androllback-executor.tsnow builds ONE resolver per replay rather than one per op. The other half — the lookups themselves running through the process-ambientgetAwsClients()singleton — was issue #1957 and is now closed too (see the entry above it). Live-covered by a newtests/integration/dynamic-ref-cross-regionfixture (two regions, one shared parameter name,StringandSecureStringarms, asserting each stack resolves its own region's value and that state holds the unresolved expression and neither plaintext). - ✅
AWS::Cognito::UserPoolMFA: an enable-on-update no longer hits AWS's refusal, a malformedMfaConfigurationis refused instead of silently disabling MFA, and three silent cases now warn (issues #1925 / #1932) —src/provisioning/providers/cognito-provider.ts.update()now withholdsMfaConfigurationfromUpdateUserPoolwhen an MFA factor is declared, the same gatecreate()already had: measured against real AWS,UpdateUserPoolwithONand no factor enabled is REJECTED (InvalidParameterException: Cannot turn MFA functionality ON, once the user pool has been created— a message that reads like a blanket prohibition but is in fact conditional on the factor existing), so a template first enabling MFA used to fail the deploy. The value still lands via the laterSetUserPoolMfaConfig. Withholding is safe becauseUpdateUserPooldoes NOT reset an omittedMfaConfiguration— also measured, with controls proving the same call DOES reset an omittedAutoVerifiedAttributes, i.e. that API's "unspecified attributes take their default" rule holds field by field rather than API-wide. A declarednull/ non-string now goes throughrequireConfigString(refuse on a template create, warn on replay / update) rather than??-defaulting, which could DISABLE MFA; a blank or whitespace-only value warns; a recognized factor pinned toMfaConfiguration: OFFwarns; and every substituted value is reported aseffectivePropertiesso the substitution cannot read back as permanent drift. Live-covered by a newCDKD_TEST_UPDATEphase intests/integration/cognitoexercising both transitions (OFF -> ON, and an undeclared downgrade announced against the liveOPTIONAL). - ✅ The index-busy
DeleteTableretry budget is PER TYPE, andAWS::DynamoDB::Table's covers more than the FLOOR case (issue #1950) —src/provisioning/dynamodb-index-busy-delete.ts,dynamodb-table-provider.ts,dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-index-busy-delete.test.ts,dynamodb-table-provider-delete-index-busy.test.ts,dynamodb-globaltable-provider-delete-retry-warm-throughput.test.ts. The gap: the retry that absorbs AWS'sCannot delete table while indexes are being created, updated, or deletedshipped atwithRetry's default of 8 retries, shared by both DynamoDB types. The livedynamodb-gsi-updaterun on 2026-08-18 measured a FIVE-item table's GSI create consuming 7 of those 8 — five items is about as small as a backfill gets, so those ~8 minutes are AWS's roughly fixed index-create latency rather than a data-proportional cost, and the budget cleared only the floor of the condition it exists to absorb. The change: the budget became a PARAMETER of the shared entry point, with one derivation and two constants —TABLE_DELETE_INDEX_BUSY_MAX_RETRIES= 14 (raised) andGLOBAL_TABLE_DELETE_INDEX_BUSY_MAX_RETRIES= 8 (unchanged, exactly what that type has shipped since #1830). A re-arm poll costs ~1.2s (INDEX_SETTLE_POLL_INTERVAL_MSof sleep plus aDescribeTableround trip — the live per-attempt gaps were 73/74/77/80/80/80s), so a 60-poll re-arm is ~72s and the loop isN x 72 + (8N - 17)seconds: ~10.4 min at 8 (the~8.8 minpreviously quoted everywhere priced a poll at the bare 1s interval, which the live run disproved) and ~18.4 min at 14. Why per type: what each provider has already spent out of the same 30-minDEFAULT_RESOURCE_TIMEOUT_MSby the time it reaches the loop differs.Tablehas spent at most a<=60-poll ACTIVE wait (~72s), so loop(14) + that wait is ~19.6 min — two thirds of the deadline, leaving ~10.4 min.GlobalTablemay have spent the #1521 pre-delete gate's 900 polls (~18 min): on the EXHAUSTED-budget path — the one a budget bounds, and the one wherewaitForTableGoneprovably does not run because the loop throws — gate + loop(8) is ~28.4 min and fits, while gate + loop(14) would be ~36.4 min and does NOT — a crossing the raise would have CREATED on the common single-regionTableV2shape, whose user-visible result is the genericResourceTimeoutErrorthe bounded re-arm exists to prevent. (On the late-SUCCESS path the gone-wait's 600 polls DO run and the same shape reaches ~40.4 min, over the deadline at the current budget already — not a consequence of this calibration, and part of #1955.) So that type keeps 8, which also honors #1950's own point that moving a shipped type's wall-clock destroy behaviour deserves its own change. Nothing aboutGlobalTable's AWS-facing behaviour moves in this PR. Its replicated shape (gate + ~12 min per non-local replica + loop) is ~40 min, over the deadline before this change and unchanged by it — #1955. On theTableside, 16 ("just double it") does not survive the same arithmetic (~27.4 min if a poll costs 1.5s rather than the measured 1.2s) and 15 is already past the two-thirds fence, so 14 is the largest value with real margin — margin rather than "just under" becausewithResourceDeadlinedoes NOT cancel what it wraps, and an overshoot leaves the loop issuingDeleteTablein the background after the run reported failure. What it buys is ~2.3x the measured floor, NOT coverage of an arbitrary backfill, which is data-proportional and unbounded; past the budget the outcome is unchanged and still actionable (AWS's own sentence, and a re-run that succeeds once the index is ACTIVE). The warning line now states how long cdkd will not give up for, as a floor (will not give up for at least ~14 more minutes), derived from the caller's budget like its attempt count — phrased as patience rather than as a floor on the WAIT, since the re-arm returns the moment the index settles and a table clearing in 90s prints the same line. Its attempt count and minutes are clamped at zero, and its poll cadence is now derived fromINDEX_SETTLE_POLL_INTERVAL_MSinstead of a literal~1ssitting next to a derived total;waitForTableActiveAfterUpdatereads that same constant, so the ACTIVE-wait term of the arithmetic is denominated rather than asserted. Tests: the budget fences MEASURE the real loop — attempts, re-arms and every millisecondwithRetryasks to sleep, through a recording sleep seam — instead of restating a 47s backoff total that was itself derived from the old budget. One per type:Tablemust clear 2x the measured ~8 min floor (reds at 8: 623000 < 960000) and stay inside two thirds of the deadline (reds at 15: 1255000 > 1200000);GlobalTablemust fit the OBSERVED #1521 gate cap plus its loop under the deadline (reds at 9) AND still clear the same measured floor (reds at 1, which previously left all 40 tests green), with theTablebudget asserted as a premise not to fit there and the late-success total asserted as the tracked overshoot it is. TheTableprovider's re-arm wiring gained the binding fence its sibling already had — a 900-poll mutation there previously left every test green while the real worst case became ~3.5h — and the fence now IMPORTSTABLE_ACTIVE_WAIT_ATTEMPTS/TABLE_GONE_WAIT_ATTEMPTSrather than copying them, after a 60 -> 900 mutation of the first left all 51 tests green.waitForReplicaGoneandwaitForTableGonenow readINDEX_SETTLE_POLL_INTERVAL_MStoo, so every poll count this arithmetic prices is denominated in the unit it claims. - ✅ The stack-outputs key space is guarded at every writer: a colliding
Export.Nameis skipped, a suppressed output no longer reserves its name, and a secret-bearing export name is refused (issue #1919) —src/deployment/outputs-export-alias.ts(new),src/deployment/deploy-engine.ts,src/cli/commands/scrub.ts,tests/unit/deployment/deploy-engine-outputs-export-name-collision.test.ts,tests/unit/cli/commands/scrub-export-name-collision.test.ts,docs/cross-stack-references.md. Bug:state.outputsis keyed by output NAME and an output carryingExport:is ALSO aliased under its export name in the same bag, while a parallel bag holds each key's unresolved template value as the redaction POSITION source (issue #1910). Those two bags disagreed whenever anExport.Namewas spelled like another output's name: the alias put the exporting output's resolved value under the colliding key while the source pass put the OTHER output's unresolved value there, soredactByPathpositioned that leaf by a source belonging to a different output and persisted one output's{{resolve:...}}reference as the other's value intostate.outputsand the exports index — the wrong-reference class #1910 removes, one layer up. Order-dependent: it needed the exporting output to be iterated AFTER the colliding-name output.cdkd scrubhad the same two writers with the winner REVERSED (its alias write runs after the owner's, in one loop), which is worse: its bag is legacy state holding plaintext, and the command is the advisory's own remediation path, so it rewrote a CORRECT public output into a reference naming a different output's secret. Fix, in three parts. (1) The key-space rules and their user-facing messages move to a sharedoutputs-export-alias.tsso the deploy engine and scrub cannot drift again; an alias whose name is also a PUBLISHED output name is skipped and warned. (2) A condition-suppressed output no longer writes a position source and no longer reserves its name — it publishes no value, so reserving it would drop a WORKING export (and delete its exports-index entry on the next producer deploy) because an unrelated condition went false. That is sound becauseresolveOutputsruns at most once per deploy: its two call sites are mutually exclusive anddeploy()resets the bag, a property now pinned by a test since the decision rests on it. (3) AnExport.Namethat is an intrinsic can resolve to secret PLAINTEXT, and the resolved name becomes a state KEY while redaction walks VALUES only — so such an alias is refused, detected from the name's OWN resolution map (exact, rather than a containment scan that was wrong in both directions) and masked exhaustively in the warning, which omits the name entirely rather than claim a masking it could not perform. That name resolution's RECORDINGS are merged back into the pass: resolving warms the resolver's cache and its hit arm re-records only what it can still prove is secret, so discarding them left an unpinned ssm reference (#1901) invisible to every later output — a regression against the previous shared-context behavior, closed at both sites (the engine, and scrub's export-name loop, which runs before its value loop). Scrub additionally drops its whole outputs position source when an intrinsic export name cannot be resolved at all (the deploy keyed state under a name this run cannot reproduce, and it could be any output's), and REPORTS — never rewrites — a state KEY that already holds plaintext, so--dry-run --failstops calling such a state clean; the key is the export name consumers resolve by, so the remedy is a template change plus a redeploy, counted and reported SEPARATELY from the stacks actually rewritten so no summary claims a remediation it did not perform, and bounded at four characters like cdkd's own substring redaction so a degenerate short secret cannot fail the CI gate repo-wide. Every user-facing name this path prints — an export name, a state key, an output logical id — is stripped of control characters through the helpersrc/utils/regexp.tsnow shares withcdkd diff's renderer, since a resolvedExport.Namepasses no CloudFormation validator and can carry ANSI escapes into a CI log. The recording invariant behind all of this is stated once and applied at every exit: the resolver records and warms its module-global cache AS IT GOES, so every path that leaves a resolution early — acontinue, a discarded local, athrowfrom anFn::Joinsibling element — must still carry the recording out, or the next consumer cache-hits and records nothing. Scrub's collision remedy deliberately differs on two points, both forced by what it can know about state an earlier binary wrote: it cannot know which output a legacy value under the colliding key came from, so it drops that key's position source and falls back to the value scan; and it tests collisions against DECLARED names with conditions ignored — resolving an intrinsicExport.Namebest-effort for that test alone, since the legacy population is exactly the binaries that resolved such names into state keys — because its own condition evaluation is best-effort from template defaults and assumes false on failure, so trusting it would let scrub miss a real collision and write a wrong-secret reference (measured both ways in the test file). User-visible: where the alias previously won the key, a consumer'sFn::ImportValueon that name now resolves to the colliding output's value — a CloudFormation-parity divergence (CFn keeps exports in a separate namespace and publishes both), warned on the producer's deploy and documented indocs/cross-stack-references.md. A FOURTH writer of the same key space,analyzer/outputs-diff.ts, applies the same two refusals: it previews the bag the deploy persists, so publishing an alias the deploy refuses reported a phantom Outputs change on every run and keptcdkd diff --failred forever — and the anti-drift fence built for exactly that (#1928) stayed green through it, because it grepped for a literal alias write that survived inside the new guard'selsearm; the fence now pins the guard on both sides, for the COLLISION rule and the SECRET rule alike. Parity is row-by-row and documented as a table in the module, because making the twin refuse by SPELLING traded one divergence for two: a LITERAL name spelled as a{{resolve:...}}token is PUBLISHED by both (the deploy never substitutes a string name, so the key holds the expression state stores anyway), while a LITERAL name in a secret-resolving stack makes the diff SUPPRESS its Outputs delta (recording the alias key so the suppression notice cannot blame the wrong cause), since the deploy refuses it only when it contains the resolved plaintext and the preview never resolves one. The secret-bearing scan behind that decision walks the value DEEPLY:secret.secretValueFromJson(...)renders the ARN as aRef, so the value is anFn::Joinobject and a string-only test reported no secret for the dominant CDK shape, disarming both this guard and the pre-existing legacy-plaintext withholding that keeps a stored secret out of the rendered diff. The literal-name refusal is also no longer DECLARATION-ORDER dependent: the outputs pass is split into resolve-values then decide-aliases, so every alias is judged against the complete secrets map instead of whatever had been recorded so far — the same order-dependence class this issue's addendum flagged for the original defect, reappearing in its fix, and closed with no extra AWS calls. Unit matrix covers both collision polarities in the corrupting order with the owner/exporter NAME ORDER reversed between them (so an order-keyed guard fails one), an all-public collision, the two-secret shape, the reversed non-corrupting order as a control, the suppressed-name case, both intrinsic export-name cases, a non-colliding alias, self-export, the no-change re-check call site, and the single-resolution property; nine mutations were probed and each is caught by its intended case. - ✅
AWS::DynamoDB::Tabledestroy absorbs the transient index-busyDeleteTablerefusal (issue #1931) —src/provisioning/dynamodb-index-busy-delete.ts(new),dynamodb-table-provider.ts,dynamodb-globaltable-provider.ts,tests/integration/dynamodb-gsi-update/verify.sh. The gap: the type'sdelete()issued a singleDeleteTable, so a table whose GSI was mid-transition when destroy reached it surfaced AWS'sAttempt to change a resource which is still in use: Cannot delete table while indexes are being created, updated, or deleted.as a hardPartialFailureErrorwith state preserved — for a condition that clears itself, and which application auto-scaling can start on any table with an autoscaled GSI. Issue #1830 / PR #1930 had fixed exactly this for the siblingAWS::DynamoDB::GlobalTableand leftTableuntouched. The change: rather than spell the classifier, the budget, the bounded re-arm and the warning a second time, they were LIFTED into a newsrc/provisioning/dynamodb-index-busy-delete.tsthat BOTH providers read — the waydynamodb-warm-throughput.tsalready holds the twoWarmThroughputrules. No AWS-FACING behaviour changed forGlobalTable— call order, budgets, the #1521 pre-delete gate and replica teardown are identical, and thedynamodb-globaltableinteg was re-run as the regression arm (12 deleted, 0 errors). Two of its LOG lines did change, deliberately: the unclassified settle-error arm now names only the error class at default verbosity (see Security below) and the retry warning was reworded so its own arithmetic reads consistently, so that provider's suite was updated to match rather than passing untouched. Why message-keyed: AWS reports the refusal as a plainResourceInUseException, the same NAME it uses for terminal conflicts, so a name-keyed predicate would spend ~47s of backoff before failing identically. Why a BOUNDED re-arm: the settle poll runs per retry, so the loop's wall clock is the product — at the #1521 gate's 15-minute default it would be ~2h againstdestroy-runner.ts's 30-minute per-resource deadline, turning an actionable AWS message into a genericResourceTimeoutError. That deadline wraps the runner's whole OUTER loop (up to 4delete()calls), and the budget survives it only because this refusal matches noRETRYABLE_ERROR_MESSAGE_PATTERNSentry (quoted as ~8.8 min at the time, with the surrounding ~2h and ~47s figures likewise computed at the then-current 8-retry budget and an idealized 1s poll; issue #1950 re-derived all three at the measured poll cost and raised the budget forAWS::DynamoDB::Tableonly — see the #1950 entry above) — verified against the real classifier for both AWS's raw sentence and the wrapped form. Security: the settle poll's unclassified-error arm logged AWS's RAW message at default verbosity; onAccessDeniedthat carries the account id, assumed-role ARN and session name, and the warning reaches the persisted deployment-events store as well as the terminal. It now names only the error CLASS and points at--verbose, with the raw message at debug; the arm still PROCEEDS, since a throw there would strand the resource. Live coverage ridestests/integration/dynamodb-gsi-update, which creates a GSI out of band immediately before the destroy so AWS really refuses — an index CREATE rather than a capacity change, because a capacity change on an empty index settles in under a second and let #1930's earlier design pass with the fix REVERTED. Verified live 2026-08-18: AWS refused and cdkd absorbed 7 retries before the delete succeeded, destroy 0 errors / 0 orphans. That run also measured the budget clearing only the FLOOR case (7 of 8 retries on a 5-item table), filed as #1950. The arm is attempted up to TWICE, since the window is a genuine race: a destroy that exits 0 with no refusal can only mean AWS never refused, because a reverted fix exits non-zero andset -o pipefailaborts at the destroy itself — so the re-arm absorbs a missed window but structurally cannot absorb a broken retry, and two missed arms still FAIL. The fixture'scleanupalso gained a re-entrancy latch: the INT / TERM traps call it and then exit, re-firing the EXIT trap, which paid ~20 min of teardown twice on every Ctrl-C. - ✅ A secret whose plaintext IS a
{{resolve:...}}string no longer escapes redaction, an array-nested secret is redacted on the UNCHANGED-resource path, and no state writer can rewrite a reference onto a DIFFERENT generation (issues #1917 / #1915) —src/deployment/secret-redaction.ts,src/cli/commands/scrub.ts,src/deployment/rollback-executor.ts,tests/unit/deployment/secret-redaction-provenance.test.ts,tests/unit/deployment/secret-redaction-array-identity.test.ts,tests/unit/cli/commands/secret-redaction-scrub-generation-skew.test.ts,tests/unit/deployment/rollback-executor-secret-redaction.test.ts,tests/integration/secrets-array-nested/,.claude/rules/code-layout.md,docs/cli-reference.md. Bug 1 (#1917):redactByPathopened by keeping any bag leaf that was already a complete{{resolve:...}}token verbatim. That guard is load-bearing — without it an edited-but-undeployed template rewrote state onto the undeployed expression, reportedrecordsChangedfor a record holding no plaintext, and made the next deploy compare expression-vs-expression, see NO_CHANGE, and never push the new reference. But a SECRET whose resolved plaintext literally IS such a string satisfied the same predicate, so it was kept verbatim and PERSISTED IN PLAINTEXT with no redaction on that leaf at all (the GHSA-p5qg-v9gv-hc7w class). Testingsecrets.has(bag)is not the fix: it is precisely the rewrite the guard prevents, one input over. Fix:PathSourceRulesgains a third rule,sourceIsSameGeneration, and the retention becomes a FALL-BACK TO A WHOLE-VALUE REDACTION rather than a verbatim return. Whole-value and NOT the full value scan: the scan's substring arm would splice a short recorded secret value occurring inside the token's own text into the reference (an ssm SecureString holdingprod, inside{{resolve:secretsmanager:prod/db:SecretString:pw}}), corrupting a persisted expression on everycdkd scrubover already-clean state. The same bound is spelled out in the generic walk, which is where a leaf reached with NO source (the journal'spreviousState, everyattributesbag) is protected. That is what makes the rule safe to set conservatively: a token-shaped plaintext the pass resolved is a key of the value map and is still rewritten onto its own expression, while a previous generation's expression is not a key and survives. A first draft keyed that rule on the CALLER and was wrong in a way worth recording, because the wrong version reads plausible: callers know what they INTEND their bag to be, not what it IS.DeployEngine.redactStateForPersistwalks EVERY record in the state map whileperResourceTemplatePropsis populated right after resolution and BEFORE the provider call, so a resource that merely ENTERED the create/update arm hands today's template to a record still holding the PREVIOUS generation — reachable through an intermediatesaveStateAfterResource, the pre/post-rollback saves, and Ctrl-C. Keyed on the caller, a rotation that FAILED and was rolled back to:AWSPREVIOUSwas persisted as:AWSCURRENT, so the next deploy saw NO_CHANGE and the rotation was silently never applied — invisible tocdkd driftbecause the baseline was rewritten in the same walk. The rule is therefore keyed on the SOURCE's generation: only a source that is THIS record's own persisted bag can certify it, which is true for exactlySTATE_DERIVED_RULES(the replay resolved its bag from the journaled record) andSTATE_SOURCED_READBACK_RULES(the #1900 observed walk projects from thepropertiesbeside it). The draft's separate constant for thecdkd scrubbag collapsed intoTEMPLATE_SOURCED_RULESonce the axis moved (every template source is a different generation, whichever command supplied the bag), and a newSTATE_SOURCED_CROSS_GENERATION_RULEScovers the one caller a derivation cannot serve —cdkd scrub'sobservedPropertieswalk, which repositionspropertiesonto today's template first, so its "own-record" source has already moved a generation.cdkd scrub's OUTPUTS call site was deliberately left on the default rules. It looks like it should move for the same reasonpropertiesdid — its bag is persisted state and its source is today's template — but the two constants differ ondescendArraysalone, and that flag cannot fire there: a template OutputValueis a string or an intrinsic OBJECT, never a literal array, so the array arm is unreachable however the persisted bag is shaped. Measured byte-identical across every shape a CloudFormation Output can take, so moving it would have put a third, inert, behavior-shaped change in a PR that ships two issues. The condition that would make it wrong —outputsTemplateSourcegaining a source whose value can be an array — is recorded at the call site. Bug 2 (#1915): a secret nested in an ARRAY stayed plaintext inobservedPropertieson the unchanged path, because positional descent is refused for an AWS readback (list order is not preserved) AND the fallback value scan has no needles there (the resource was never resolved this deploy, so itsperResourceSecretsentry is empty — the #1900 shape). Fix: a KEYED array descent pairing elements by an identity field (Name/Key, the shapecanonicalizeTagListsDeepalready keys on) with uniqueness required on both sides, so pairing is by string equality and cannot mis-align; a non-identity key refuses the whole array and an unpaired element falls to the value scan. It runs BEFORE positional descent, not only where positional is refused, becausedescendArraysrests on an order assumption the module states but cannot enforce — and an element the key does NOT pair takes its POSITIONAL partner whenever positional would have been exact for the whole array (descendArrays, equal lengths, and every pairing sitting at its own index — the last checking that the order assumption actually held rather than assuming it). The invariant is that keying must never pre-empt a positional descent that would have been exact: without it, keying ONE element sent every unpaired sibling to the value scan, which on a colliding pair writes a SIBLING's expression — the #1910 class, on the replay path that applies it to AWS. Where positional would NOT have been exact, an unpaired element stays on the value scan, because on the unchanged-resource path that scan has no needles and refusing the array until every element pairs would let one AWS-added element un-redact the secrets that did pair. Chosen over the issue's other candidate — seeding the value scan from the source bag's expression set — because a value scan needs PLAINTEXT needles and none exist on that path. Two shapes stay out of reach and fail closed, documented and pinned: an array whose IDENTITY FIELD itself holds a secret, and an array of ARRAYS. Tests: one unit case per WRITE SITE, plus caller-BINDING cases that pin which constant each writer passes rather than only what the constants do — the gap a probe found when swapping the rollback writer's constant left all 13546 tests green while re-opening #1917. 21 mutation probes, all caught. The newtests/integration/secrets-array-nestedfixture carries the secret two arrays deep in an ECS task definition, redeploys UNCHANGED, and asserts the task definition REVISION is unchanged so the fixture cannot pass by taking the update path instead. - ✅
cdkd diffreports an Outputs-only change instead of "No changes detected" (issue #1921) —src/analyzer/outputs-diff.ts(new),src/cli/commands/diff-recursive.ts,tests/unit/analyzer/outputs-diff.test.ts,tests/unit/cli/diff-recursive.test.ts,tests/integration/outputs-only-export/verify.sh,docs/cli-reference.md,docs/architecture.md,.claude/rules/code-layout.md. Bug:cdkd deploylearned to persist an Outputs-only change in #875, butcdkd diffnever did —DiffCalculator.calculateDiffcompares onlycurrentState.resourcesvstemplate.Resources, and the deploy engine's compensating logic lived entirely in its own no-change branch. A stack whoseOutputsgained, changed, or lost an entry with a byte-identicalResourcessection still printed✓ No changes detectedandcdkd diff --failexited0, while the apply DID write new outputs and republish the exports index. In the motivating chain (a producer that gains anExport.Namebecause a downstream stack started referencing it) the preview actively steered the user away from the deploy that repairs the consumer'sFn::ImportValue; the reverse case — an export REMOVED, which can break a consumer — was hidden identically.cdk diffand a CFn change set both surface an Outputs delta, so this was also a CDK-CLI parity gap. Sharing strategy, decided up front because it changes the gate cost: the natural extract-and-share editssrc/deployment/deploy-engine.ts, which is in BOTH theinteg-broadCROSS_CUTTING_REGEXandinteg-destroy's scope, so a diff-only fix would have acquired two real-AWS merge gates — and that file was concurrently held by the livefix/1919lane, so editing it would have violated file-disjointness outright. This shipped diff-side only, and the resulting duplication is PAID FOR rather than merely accepted:computeOutputsDiffcompares bag KEY by bag KEY, which IS theoutputMapsEqualpredicate the deploy engine gates its persist on (no interpretive layer that could drift), and a unit anti-drift fence readsdeploy-engine.tsand asserts its three mirrored semantics — theExport.Namealias write, the condition-false skip, and the refusal to persist a partially-resolved bag — each verified to fire by a mutation probe against the real file. Fix:resolveTemplateOutputsrebuilds the exact bagDeployEngine.resolveOutputspersists (condition-false outputs skipped; anExport.Namestored as a SECOND key holding the same value, which is whatFn::ImportValueresolves against). Because the diff resolver is BEST-EFFORT — it returns the ORIGINAL value on failure rather than throwing, unlike the deploy side whose catch storesundefined— the unresolved detector is a DEEPcontainsIntrinsicwalk; a shallow check would pass a half-substitutedFn::Jointhrough as resolved and diff it against state as a phantom. A partially-resolved bag reports NO delta, mirroring the deploy engine declining to persist one, and nothing is lost because an output only fails to resolve when it references a resource this deploy has yet to CREATE — already on the resource side of the diff.computeStackDiffnow returns{changes, outputChanges}, computing the delta THERE rather than behind a second entry point, since the parameter binding / condition evaluation above it can issue SSM calls a second pass would pay for twice.nodeHasChangesis true for an Outputs-only delta (the arm that stops "No changes detected" and makes--failexit 1),renderOutputChangeLinesprints anOutputs:block, anddiffTreeToJsoncarriesoutputChanges(always present, likechildren, for key-set stability). Rows are keyed by the PERSISTED bag key, so an output with anExport.Nameshows TWO rows and the[export]-tagged one is the literal string a failing consumer needs; the Outputs counts go on their OWN summary line because an Outputs write drives no AWS resource operation and would otherwise read as "1 to update" for a stack whose resources are untouched.--recursiveis consistent — every child computes / renders / serializes its own delta, and a removed nested child reports its persisted outputs as REMOVE with no special case.src/cli/commands/diff.tsneeded no change (the gate and the renderer both live indiff-recursive.ts). Four defects found by the review round, each now pinned by a mutation probe: (1) the unresolved detector missedundefined—resolvereturns it WITHOUT throwing for a constructible-but-unknown attribute (StreamArn/PolicyId/VpcId/RoleId), and sinceJSON.stringifydrops anundefined-valued key, state can never hold one, so such an output was a PERMANENT phantomADDprintingnew: undefinedwith--failexiting 1 forever on a stack deploy considers clean; this is precisely deploy's own signal (some((v) => v === undefined)), which the diff twin had failed to mirror. (2) It also missedFn::Sub's laundered placeholder:resolveSubcatches a genuineRef/GetAttmiss, WARNS, and keeps the literal${Foo}in the output STRING — no throw, no intrinsic object — so a half-substituted value passed as resolved; the detector now also flags an unsubstituted${...}string, accepting that${!Literal}escapes are indistinguishable post-resolution and that the resulting false positive merely SUPPRESSES the section (pre-#1921 behavior) rather than reporting a phantom every run. The module's original rationale for the deep walk citedFn::Join, which was WRONG —resolveJoinpropagates the throw — and is corrected. (3) A top-levelFn::IfselectingRef: AWS::NoValuereturns a bare sentinel symbol (resolveValuestrips it only INSIDE containers), likewise unpersistable and likewise a permanent phantom. (4) Security: the new renderer prints the STORED previous value, andStackState.outputsis not guaranteed redacted for every schema version — a record written before the GHSA redaction holds resolved plaintext (the conditioncdkd scrubexists to repair), socdkd diff, a command routinely run in CI, would have put a secret in build logs.oldValueis now WITHHELD (replaced byoldValueRedacted: true) whenever the desired side is still a{{resolve:...}}expression and the stored side is not, in the text renderer and--jsonalike. Also from the review: a suppressed delta now WARNS (parity with deploy's twin warning) instead of being silently absent; template-controlled output / export NAMES are stripped of control characters before printing, since anExport.Nameis a RESOLVED value that never passed a CFn validator and can carry ANSI escapes; the anti-drift fence was re-pointed from the line that CONSUMES deploy's failure flag to the line that DEFINES it, because the original would have stayed green through defect 1; the unit resolver mock was corrected to THROW like the realIntrinsicFunctionResolver(computeStackDiffpasses it directly, not throughDiffCalculator.resolveBestEffort's returns-the-original wrapper), so the tests stop agreeing with a wrong contract; andINTRINSIC_KEYSis now exported fromdiff-calculator.tsand shared rather than copied. A SECOND review round found five more, all fixed and probed: (a) the${...}test applied to EVERY string, so an IAM policy body's${aws:username}or a UserData shell${VAR}would suppress the whole Outputs section for that stack forever — it is now consulted only for a value whose raw template source actually usedFn::Sub(templateUsesSub, walking nestedFn::Join/Fn::If), which confines the remaining false positive to the${!Literal}escape the trade actually admits. (b) The plaintext withholding fired only on the MODIFY arm, so a secret output that was condition-SKIPPED or DELETED from the template — neither of which has a desired side — still printed in full as a REMOVE row, andbuildDeletedSubtreedumps a removed nested child's WHOLE bag as REMOVEs. A second signal was added:secretSourceKeys, the keys the TEMPLATE declares as dynamic references, collected over every declared output including skipped ones. A hit on either signal now makes the whole record suspect — it was written by a pre-GHSA binary, so every value in it is unredacted — so the withholding is RECORD-level rather than per-key. (c) The new suppression warning fired on the ORDINARY case: unlike deploy (which keeps an unresolved key asundefined) this resolver DROPS it, so every failed key read as a phantom REMOVE and the warning triggered on the expected pending-resource path, including the first diff of a never-deployed stack;failedKeysis now returned and excluded before the warn is computed. (d) Control-char stripping reached names but not rendered VALUES, andJSON.stringifyescapes below 0x20 while passing C1 and bidi marks through — the class now covers C0/DEL/C1 plus the bidi overrides and is applied to values too, while--jsonis left byte-faithful on purpose (mutating a name a machine consumer matches on would be a correctness regression traded for a display concern). (e) The "mirrors the deploy engine declining to persist" claim was overstated — it holds for deploy's NO-CHANGE branch, while its changed-resources branch has no such gate (correctly, since by then every resource exists); code comments and docs now say so. Also fixed: the newINTRINSIC_KEYSconst had been inserted betweenCanonicalizePropertiesFn's JSDoc and its declaration, orphaning the doc, anddiff-recursive.tskept a third hand-maintained copy of the same key list, which now imports the shared one. Tests: 63 new unit cases — bag shape incl. the alias key, condition skip and unknown-condition keep, deep-intrinsic detection, intrinsicExport.Nameboth ways, non-throwing resolver failure, template non-mutation, the ADD/MODIFY/REMOVE matrix, key-order-independent structural equality, array order treated as significant, absent state bag, explicitnullvs absent key, the no-phantom case,treeHasChangestrue for an Outputs-only delta and still false for a clean stack, an unresolvable output suppressing the delta but NOT the resource CREATE, the human render incl. both summary lines, and the JSON projection incl. the omitted absent side per kind — plus the 3-assertion fence. Neutering thenodeHasChangesOutputs arm fails exactly the two user-visible tests. Live:tests/integration/outputs-only-export(the existing #875 fixture) gains the preview half — a positive arm BEFORE the redeploy requiring--failto exit 1, no "No changes detected", anOutputs:section naming[+] CdkdOutputsOnlyBucketArn [export], and the entry in--jsonoutputChangeswithexport: truewhile reporting ZERO resource changes; plus TWO negative arms. The phase-1 one (freshly-deployed producer diffs clean) stops a fix that always reports a delta, but on its own it only compares empty-vs-empty; the review noted that, so a second runs AFTER the export is persisted, where the output resolves from real state through anFn::GetAtt— that is the arm that actually discriminates a RESOLUTION disagreement between preview and apply, i.e. the whole phantom class defects 1-3 above belong to. Verified against real AWS 2026-08-18 (us-east-1), re-run after each review round: 13/13 checks, destroy clean, 0 orphans. No CLI flag, dependency, or state-schema change;outputChangesis an additive--jsonkey. - ✅
cdkd driftreconciles the redacted state baseline with the resolved AWS snapshot, so it no longer prints secrets, misses real drift, or reverts the literal{{resolve:...}}token (issue #1914) —src/cli/commands/drift.ts,src/analyzer/drift-calculator.ts(comment),tests/unit/cli/drift-secret-redaction.test.ts,tests/integration/secrets-dynamic-ref/verify.sh,docs/cli-reference.md,.claude/rules/code-layout.md. Bug:drift.tsimported nothing fromsecret-redaction.ts. It was the one write-to-state surface the GHSA-p5qg-v9gv-hc7w work never reached, and worse than a plain write leak because it also FEEDS A LIVE AWS CALL. Since PR #1899 the drift baseline (observedProperties ?? properties) holds the unresolved expressions whilereadCurrentStatereturns the resolved plaintext, and nothing reconciled the two:--reverthanded that baseline toprovider.update, setting the live property to the literal token;--acceptpersisted the AWS-current plaintext intostate.json; and the comparison could not compare at all — PR #1899 had bought quiet by makingcalculateResourceDriftSKIP every state leaf holding a{{resolve:string, which also made a console edit of a secret-bearing property invisible tocdkd driftand unreachable by--revert. Fix:resolveStateSecretExpressionsre-resolves the secret references a STATE bag holds — the synth-free counterpart ofrollback-executor.ts'sresolveReplayProps. The comparison resolves the baseline FOR COMPARISON ONLY (observedProperties/propertiesuntouched) andrunRevertre-resolves before the provider call, so AWS receives the concrete secret. The resolved baseline no longer trips the #1899 skip, which becomes the FALLBACK for a reference cdkd cannot resolve. Redaction is keyed on the PATH as well as on the value, and the path half is the half a value-keyed map structurally cannot supply. The resolution records a per-resourceRecordedSecretValuesAND the set of paths that produced one;redactDriftChangesrewrites both sides of everyPropertyDriftat the moment the outcome is built — the single choke point behind the human report, the--jsonpayload, both plans, and the value--acceptpersists. The value pass restores a recorded plaintext to its own expression. The path pass answers what the map cannot: the map holds what the reference resolves to TODAY, so after a Secrets Manager rotation the deployed resource still carries the previous version, the path drifts, and the AWS side matches no key — a real secret that a value-scan-only redaction prints verbatim and--acceptwrites to state. At a known-secret path the only value ever shown is the expression itself; anything else becomes***(undefined/nullexcepted, since an absent key discloses nothing), and--acceptrefuses such a path with a warning naming it rather than persisting a mask that would corrupt the baseline. The refusal is per-PATH, so non-secret drifts on the same resource are still accepted.change.pathis masked too —redactSecretsForStatewalks values and never object KEYS, so a readback keyed by a secret rendered the plaintext into every printer's path segment; a masked path also counts as secret-bearing, and--acceptrefuses it through the SAMEacceptRefusalReasonpredicateprintAcceptPlanasks (so a--dry-runcannot promise a write the run refuses), becausesetAtPathwould otherwise invent a key literally named***and, with anundefinedvalue, INSERT it instead of removing the real one. The revert PLAN's two AWS-key lists (findRevertPreservedTagKeys/findRevertUnbaselinedAwsKeys) are masked where they print, since they build path names straight from the deliberately-unredactedawsProperties.carriesRecordedSecret's substring branch carries the same 4-character needle floorsecret-redaction.tsuses when it builds needles, so it errs toward NOT marking — a sub-threshold plaintext is one the redactor would not substitute anyway, and marking it would mask a PUBLIC reference that merely contains those characters. The path answer survives a failed lookup too:collectDynamicReferencePathsseeds a second path set OFFLINE from where the{{resolve:strings simply are, and the report uses the proven set when resolution succeeded and that seed when it did not. Without it the two failure modes compose into a leak — the comparator's{{resolve:skip only re-arms for a LEAF whose state side is a string, so a resource whoseobservedPropertieslack the secret's parent drifts at the ANCESTOR with the whole AWS subtree, plaintext included, asawsValue. Every failure mode of the new lookups is per-resource, never fatal.cdkd driftpreviously made no secret calls at all, so a deleted secret, a rotated-away version, or a least-privilege role lackingsecretsmanager:GetSecretValue/ssm:GetParameterwould have aborted the whole command — every remaining resource and, under--all, every remaining stack. Detection now warns per resource and falls back to the unresolved baseline (where the #1899 skip suppresses the phantom drift), and--revertreports it as a per-resource failure with its own message rather than a misleadingAWS update failed. A SURVIVING{{resolve:...}}token marks its path secret-bearing and is reported rather than treated as an error — failing would abandon every other drifted property on the resource and exit 2.ssm-secureis the one spelling for which "the token is already what AWS holds" is false: CloudFormation resolves it SERVER-side, so a record adopted bycdkd import --migrate-from-cloudformationcarries the literal in state while AWS holds the plaintext. Both halves follow — the path must be masked (diffAtnever descends arrays, so an ECSSecrets[]drift otherwise reports the whole array with the plaintext in it), andpreserveLiveValuesAtUnresolvedTokensruns beforeprovider.updateso a revert triggered by any SIBLING key leaves the live value at those positions untouched instead of overwriting a working secret with a literal string. Deciding by what AWS HOLDS rather than by provenance needs no state flag and is exact both ways: a cdkd-deployed record has the token live, so copying it back is the same no-op the old premise described. The report names only the TOKENS, never the leaf:resolveDynamicReferencessubstitutes token by token, so a mixedsecretsmanager+ssm-secureleaf comes back carrying real DECRYPTED plaintext, and interpolating it into the warning printed a secret from the command whose purpose is not to. For the same reason the detection failure arm masks its message BEFORE clearing the value map. The new IAM requirement is documented indocs/cli-reference.md. Every AWS error string a revert prints now goes throughmaskSecretsInText: the payload carried resolved secrets and AWS quotes the offending value, the fencedeploy-engine.tsalready applies to its own provider calls.--acceptkeeps its own positioned redaction (existing.properties,STATE_SOURCED_READBACK_RULES) and it is not a duplicate of the change-level pass: what it reaches is the untouched clone of the record's baseline, which is exactly where a plaintext survives for any user who ran--accepton a pre-fix binary — measured, that state round-trips the plaintext without it — and the positioned form additionally names a stored plaintext that a rotation has made unrecognisable to the map. Its old pinning hazard is gone now that a drifted secret-bearing path is refused before it can reach the bag. The revert's narrowing write (#1644) is positioned against the bag the revert RESOLVED rather than againstproperties, withSTATE_SOURCED_READBACK_RULESrather than the rollback executor'sSTATE_DERIVED_RULES, because the delta descends from a merge with the AWS-CURRENT snapshot and positional array descent over a reordered list would write a sibling's expression onto the wrong element. An ABSENTawsValueat a position known to hold a secret is dropped rather than reported — a write-only credential (MasterUserPasswordand friends; RDS / DocDB / Neptune / ElastiCache / Cognito declare nogetDriftUnknownPaths) is returned by no readback, and PR #1899's skip used to cover exactly that, stopping only because this PR makes the baseline arrive RESOLVED. Reporting it was three bugs at once: permanent exit 1,--acceptwritingundefinedand so DELETING the{{resolve:...}}reference out ofproperties, and--revertre-pushing the credential every run. AndpreserveLiveValuesAtUnresolvedTokensregisters the live values it pins — gated on the leaf being a WHOLE token, the spelling being secret by definition, and the value clearing the needle floor, since registering a partially resolved mixed leaf writes the resolvable half's plaintext into state and registering a short value makes a needle that rewrites unrelated data — into the secrets map before the update call, the narrowing redaction and the error masking — that map is empty by construction on the unresolvable path, so moving plaintext into the payload without registering it reopened the round-3 error leak through a fifth door and dropped an array-nested value straight intostate.json. The rule worth carrying: a mechanism that deliberately moves plaintext into a bag must also register it with everything that masks. The drop is scoped to the EXACT leaf, not to the ancestor match, so a whole subtree vanishing from AWS is still reported — and refused by--accept, which would otherwise delete the key and the{{resolve:...}}reference with it. Preservation is gated on the leaf being a WHOLE token, which is a disclosure boundary: copying a live value into a MIXED leaf would move thessm-secureplaintext into the payload while the registration guards correctly decline to register it, so the mechanism would create an exposure it cannot mask. Declining there restores the pre-#1914 behaviour for that narrow shape. A residual remains that this command's own bags do not create — a provider echoing its readback ineffectiveProperties, persisted by the #1644 narrowing with nothing able to recognise it; that write previously had no redaction at all, and closing it needs span-masking (issue #2102; it was filed against #1935 until that lane shipped and found the two are different mechanisms — #1935 reaches only a leaf the value scan can MATCH). Documented indrift.tsanddocs/cli-reference.md. Tests: 64 unit cases. Every mechanism mutation-probed, every red classified as targeted or cascading, and a length / call-count guard placed ahead of each payload index so a detection regression reds on an assertion rather than aTypeError. Thesecrets-dynamic-refinteg gains Phase 1d: clean drift on a fresh deploy with no plaintext; an injected console-style overwrite detected as EXACTLY one path (phantom drift on the untouched references fails it) with the injected value masked rather than printed;--acceptrefusing that path while leaving every secret leaf on its OWN expression and no mask instate.json; a PRE-FIX-shaped state record seeded straight into the state bucket (plaintext inobservedProperties, expression inproperties— the shape a user has after running--accepton an older binary, and one the CLI cannot be driven into) so the positioned--acceptredaction has real-AWS coverage rather than only a mutation probe;--revertleaving the live Lambda holding the RESOLVED value with every sibling reference still resolved; and a second--acceptrecording an ordinary out-of-band env addition. The phase restores the stack before Phase 1e (the rollback arm). A review-driven correction worth recording: the--acceptredaction was deleted mid-PR on the evidence of a zero-red mutation probe, and the probe was a false negative — no existing case covered the pre-fix-state shape. A zero-red probe measures the SUITE, not the code; "no test distinguishes it" only means unfenceable after the distinguishing case has actually been constructed.
Recently Implemented (2026-08-15):
- ✅ A colliding secret pair behind an
Fn::Join/Fn::Subleaf is separated by SHAPE, closing the position pass on the dominant CDK form (issue #1916) —src/deployment/secret-redaction.ts,src/deployment/intrinsic-function-resolver.ts,tests/unit/deployment/secret-redaction-intrinsic-source.test.ts,tests/integration/secrets-dynamic-ref/,.claude/rules/code-layout.md. Bug: the position pass #1904 / #1910 built persists a leaf's own{{resolve:...}}expression by copying the UNRESOLVED source leaf — which requires the source leaf to BE a{{resolve:...}}string. When it is an intrinsic OBJECT there is nothing to copy, so the leaf fell through to the value scan, and that map is keyed by the resolved PLAINTEXT, so a colliding pair collapsed exactly as it did before #1904. The module header documented the fall-through as intended; what was not appreciated is that this is the DOMINANT CDK shape. Any secret reached through an L2 token (secret.secretValueFromJson(...)) renders the secret's ARN as aRef, hence anFn::Join— so the fix worked for a hand-written template naming a literal secret and missed a typical CDK app. Measured against real AWS (us-east-1, 2026-08-15) by thesecrets-dynamic-reffixture once #1910 restored the collision it had been dodging: the live Lambda received both values correctly while state persisted the:AWSCURRENTspelling for BOTH leaves — a permanent spurious UPDATE, and on the rollback-journal replay path a re-resolution of the WRONG reference once the two version stages diverge. Fix, in two steps where the first is a prerequisite for the second. (1) The resolver records EVERY secret expression intorecordedSecretExpressions, not only thessmSecureString ones. Nothing had to ask the set about asecretsmanagerreference (spelling settles it), but the set is also the CANDIDATE LIST the redaction path needs, and holding only the ssm half made the losing member of a collapsed secretsmanager pair nameable NOWHERE. Recording is gated on the SPELLING rather than onisSecret: an ssm reference whoseTypecame back unclassifiable is secret for that resolution but deliberately unpinned so the next pass re-asks AWS (#1901), and recording it would pin it for the process — so that pair still falls back to the value scan. (2)positionByIntrinsicSkeletonbuilds an anchored pattern from the intrinsic — literal parts escaped, non-literal parts wildcarded as[^}]*so a wildcard cannot cross a token terminator, withFn::Sub's${!Literal}escape treated as the literal text it is — and matches it against those candidates. It persists a match only when THREE conditions hold, each removing a different way of being wrong: the bag leaf's WHOLE value is a recorded secret plaintext (an EMBEDDED secret is not this shape and must keep going to the substring scan, which would otherwise be replaced wholesale and destroy the surrounding text), EXACTLY ONE candidate matches (two means the skeleton genuinely cannot separate them, and guessing is the collapse this fix removes one step over), and the match is not DEMONSTRABLY another value's expression per the pass's own map (the fence against a bag/source misalignment). The wildcard COUNT is capped and adjacent wildcards are COLLAPSED, which is a correctness fix rather than tidiness:[^}]*[^}]*is semantically identical to[^}]*, but the engine must try every split between them, so N adjacent wildcards make a FAILING match exponential — and failing is the common case, since the pattern is tried against every recorded expression that is not this leaf's. The COLLAPSE alone closes only the ADJACENT arrangement -- two legal, CDK-emittable shapes produce those (anFn::Joinwith an EMPTY delimiter and consecutive non-string parts; anFn::Subwith adjacent${a}${b}) -- while wildcards separated by a literal cannot merge and backtrack identically, so the CAP (MAX_SKELETON_WILDCARDS) is what actually closes the class:Fn::Sub '${a}x${b}x...'took 17.7 s at six wildcards and did not finish in two minutes at eight, and a realisticFn::Join['-', 9 x {Ref}]against a hyphen-rich secret ARN cost 855 ms per candidate. Refusing is not free -- a four-wildcard join with substantive literals can position uniquely, and a refused leaf falls to the value scan, which for a colliding pair means the sibling's reference -- but the alternative at the top of that trade is a deploy that hangs after its AWS mutations. A companion boundMAX_SKELETON_CANDIDATE_LENGTH(512) caps the BASE the same way, since cost at the wildcard cap is polynomial in the template-authored candidate's length (an adversarial 3000-char candidate measured 4.1 s); it refuses the whole pass rather than SKIPPING the long candidate, because skipping would shrink the set condition 2 counts "exactly one" over and silently weaken the fence. This runs on the state-persist choke point, i.e. AFTER the AWS mutations and BEFORE state is written, so a hang there strands real resources. Measured on a ~120-char candidate before the collapse: 4 wildcards 39 ms, 5 ~1 s, 6 20 s, 8 did not finish in two minutes. Found by the code + security reviewers, who each measured it independently. Its fence asserts the built pattern'ssourcerather than timing the match, and that too is a measured decision: catastrophic backtracking is SYNCHRONOUS, so a timing-based case does not fail on vitest's timeout — it wedges the worker and the run never terminates (the first version of the fence hung for the full harness limit, which is a worse CI outcome than the defect and cannot be told from a slow machine). Every refusal degrades to the value scan, i.e. to the pre-#1916 behavior, so no case gets worse than it is today. There is deliberately NOisKnownSecretExpressiontest on this arm, and the asymmetry with the plain-string arm is principled: that arm's candidate is arbitrary TEMPLATE text and genuinely can be a public ssm reference that must stay resolved in state (#1901), while these candidates come only from stores the resolver populates on a proven-secret verdict — so the test could never answerfalse, and an unfalsifiable guard reads as protection while fencing nothing. Tests: 32 new unit cases, each mutation-probed against real code — disabling the skeleton arm, relaxing each of the three conditions, mishandling theFn::Subescape, removing the resolver's recording, and widening the recording gate toisSecreteach fail exactly the cases that claim to fence them. Eight of those probes initially did NOT fail and the fixtures were rebuilt: the ambiguity case had put the differingRefin a trailing position, where a wildcard cannot match a candidate's}}terminator at all, so ZERO candidates matched and ambiguity was never reached — and even once matched, a survivor among the matches makes "refuse and fall back" and "take the first match" produce the same string; the whole-leaf case had used the SURVIVOR as the match, where condition 3 fires first and the two rules become indistinguishable. Thesecrets-dynamic-refinteg needed no fixture work: it already reproduces the defect and already carries the failing assertions. - ✅ Every state writer now redacts by POSITION, so two secret references sharing one resolved value no longer collapse (issue #1910) —
src/deployment/secret-redaction.ts,src/deployment/intrinsic-function-resolver.ts,src/deployment/deploy-engine.ts,src/deployment/rollback-executor.ts,src/cli/commands/scrub.ts,src/cli/commands/import.ts,tests/unit/deployment/secret-redaction-ssm-collision.test.ts,tests/unit/deployment/deploy-engine-sibling-redaction-writers.test.ts,tests/unit/deployment/rollback-executor-secret-redaction.test.ts,tests/unit/cli/commands/scrub.test.ts,tests/unit/cli/import.test.ts,tests/integration/secrets-dynamic-ref/,.claude/rules/code-layout.md. Bug: PR #1912 gave the deploy engine's state-save choke point a POSITION source (the unresolved template bag) so a leaf is redacted back to ITS OWN{{resolve:...}}expression, closing issue #1904 — but four sibling writers still calledredactSecretsForState/scrubResourceRecordwith the value-keyed map ALONE, even though the unresolved bag was available at each call site. Since that map is keyed by the resolved PLAINTEXT, two expressions resolving to the same value collapse onto whichever was recorded last, so each of those writers persisted an expression the template does not have at that leaf. A fifth site the issue did not list,rollback-executor.ts'sredactRollbackRecord, had the same shape and is reachable only through a replay. Fix: each site now passes the source it already had — the rollback JOURNAL and the stack OUTPUTS indeploy-engine.ts(the outputs' unresolved values are captured into a newoutputsTemplateSourcefield, the sibling ofperResourceTemplateProps, and all three outputs-redaction sites route through oneredactOutputshelper so they cannot drift),cdkd scrub's synthesized template,cdkd import's pre-resolution bag (captured BEFOREresource.propertiesis overwritten), and the rollback executor's JOURNALED previous properties. The journal is the consequential one:resolveReplayPropsRE-RESOLVES it against AWS, so a leaf collapsed onto a sibling's expression does not merely report a spurious change — it ships the WRONG secret version to the live resource once two version stages diverge. Its two sides take DIFFERENT sources and conflating them would be a fresh defect:properties/attemptedPropertiesare this deploy's desired bags and take the TEMPLATE bag, whilepreviousStatewas read back from STATE and positions itself throughscrubResourceRecordwith no source (the #1900 fallback). The ssm/ssm residue the issue also names is closed by givingsecret-redaction.tsa module-levelrecordedSecretExpressionsSET, uncollapsed by value: asecretsmanagerreference is secret by SPELLING, but anssmone is secret only when its parameter is aSecureString, so the losing member of a colliding ssm pair was unanswerable from the map. The store is homed in the LEAF module (it IS the resolver's own SecureString verdict store, moved there) precisely soisKnownSecretExpressionreads it with no caller threading it — which is what kept the change to a source argument per site instead of a fifth parameter through six call sites and 47 resolver-mocking suites. Tests: 9 new unit cases across five files, each mutation-probed against real code (reverting the site under test must fail the case, and does). Thesecrets-dynamic-refinteg restored the collision it had been dodging: its version-stage reference read a different JSON key precisely to avoid this bug while it was unfixed, and now readspasswordagain, so the state-expression anddiff --failassertions are discriminating rather than vacuous; new assertions pin that neither leaf takes the other's spelling at deploy time, that the rollback replay re-resolves BOTH leaves to the live value, and that the post-rollback state keeps each leaf's own expression. A pre-existing GHSA test was found vacuous in the same pass and fixed:import.test.ts's "persists the expression, not the plaintext" case mocked a bare-string secret against a reference naming a JSON_KEY, so the resolver REFUSED,resolveImportedPropertiescaught it and persisted the RAW template shape — which equals the redacted form, so every assertion passed while redaction never ran. Itsexpect(smSend).toHaveBeenCalled()guard could not catch it (the fetch DID happen; the parse after it refused); the mock now returns valid JSON and the test additionally asserts no resolution-failure warning. Verified: deleting the import redaction now fails it, where before it passed. - ✅ A SecureString SSM parameter reached through the plain
{{resolve:ssm:...}}form is redacted out of persisted state (issue #1901, GHSA-p5qg-v9gv-hc7w follow-up) —src/deployment/intrinsic-function-resolver.ts,src/deployment/rollback-executor.ts(comment),tests/unit/deployment/dynamic-references.test.ts,tests/integration/secrets-dynamic-ref/,docs/cli-reference.md,docs/architecture.md,docs/supported-features.md,docs/troubleshooting.md,.claude/rules/state-schema.md,.claude/rules/code-layout.md. Bug: PR #1899 redacted{{resolve:secretsmanager:...}}plaintext out of every persisted bag, butresolveSSMReferencefetches withWithDecryption: true, so a plainssmreference to a SecureString parameter also resolves to a real secret — and it was NOT redacted. Same disclosure class as the reported advisory (the decrypted value landed instate.json, the rollback journal,cdkd state show/diff/driftoutput), just out of the advisory's scope. It was left as a documented "Known limitation" because the resolver'sisSecretgate keyed off the reference's SPELLING, and plainssmis the same spelling for both a publicStringparameter and a secretSecureStringone. Fix: decide by the parameter's TYPE, which is free —Parameter.Typerides on the sameGetParameterresponse that carries the value, so classifying a reference costs no extra API call.resolveSSMReferencenow returns{ value, secure }, and asecurereference is recorded intorecordedSecretValuesexactly like a secretsmanager one, so every existing choke point inherits it unchanged:redactStateForPersist/scrubResourceRecordstore the expression, the rollback-journal writer redacts the same way,maskSecretsInTextkeeps it out ofFn::Join/Fn::Subdebug lines,cdkd scrubcleans it out of state an older cdkd wrote, and the rollback replay'sresolveReplayPropsre-resolves it before handing the bag to the provider (its comment claimed plainssm"never appears as an expression in the journal", which this change makes false — the replay path was already correct, since it resolves whatever the resolver classifies). AString/StringListparameter is untouched and stays RESOLVED in state, or every parameter-backed property would become a perpetual spurious UPDATE. The diff / no-op path was the actual design problem, and is why the issue was filed as "not a one-line change":skipDynamicReferencesmust leave a SecureString reference unresolved so the comparison stays expression-vs-expression, but secret-ness is not knowable from the spelling. Resolved by fetching the TYPE without the value — the comparison path passesWithDecryption: false, which returns aSecureString's encrypted blob (never substituted, never cached, never persisted) whileString/StringListare unaffected by the flag and resolve exactly as before. The verdict is then remembered per expression in a module-levelsecureStringSsmReferencesset (cleared alongsidecachedDynamicReferencesbyresetAccountInfoCache), so a later comparison short-circuits with no AWS call at all and the cache-hit arm re-records the secret for each resource's own per-resource map. Tests: 10 new unit cases covering theSecureString/String/StringListsplit, theWithDecryptionpolarity per path, the ciphertext never being cached (a diff pass followed by a deploy pass must still hand the provider the plaintext), the no-call short-circuit, the per-resource re-record on a cache hit, anFn::Sub-embedded reference, and the end-to-endredactSecretsForStateround trip. Thesecrets-dynamic-refinteg gained a real-AWS arm:verify.shcreates aSecureStringparameter out of band (CloudFormation cannot create one) and asserts it is really aSecureStringbefore proceeding, the consumer Lambda references it through the plainssm:form, and the run asserts the live Lambda carries the DECRYPTED value while state holds the expression, theStringparameter beside it stays RESOLVED (the discriminator — a fix that redacted by spelling fails this half, one that redacts nothing fails the other), the decrypted value appears nowhere in the whole state document /diff/scruboutput, andcdkd diff --failexits 0 on the unchanged stack (the no-perpetual-UPDATE half, which the pre-existing guard only asserted in its comment). Destroy additionally asserts cdkd left the unmanaged parameter alone and then deletes it explicitly, so the run ends with no orphan. Thatcdkd diff --failassertion immediately earned its keep: its first real-AWS run FAILED, exposing a shipped defect independent of this change — the redaction map is keyed by the RESOLVED VALUE, so the fixture's two references to the same secret (:SecretString:passwordand:SecretString:password:AWSCURRENT) collapsed to one entry, state persisted the staged spelling for BOTH, and the stack took a permanent spurious UPDATE. The pre-existing guard only checked that the diff output carried no plaintext and never its exit code, which is why the fixture had been shipping green over it. Filed as issue #1904 (re-keying the map is not a one-liner: value-keying is what finds a secret embedded in anFn::Joinresult); the fixture's version-stage reference now reads a DIFFERENT JSON key so it stops tripping the collision while it is unfixed, with the reason recorded in-code so it is not tidied back. - ✅
Fn::Splitover an already-list value names the situation and the remedy (issue #1874) —src/deployment/intrinsic-function-resolver.ts,tests/unit/deployment/intrinsic-split-list-value.test.ts,docs/supported-features.md.resolveSplitrefused any non-string value withFn::Split: value must be a string, got object, which named neither the situation nor the fix. The shape that hits it is specific:{"Fn::Split": [",", {"Fn::GetAtt": ["Zone", "NameServers"]}]}is exactly the workaround a user wrote against the pre-#1868 bug whereAWS::Route53::HostedZone.NameServersresolved to a comma-delimited STRING; once PR #1868 made it a real list, the workaround started failing on upgrade (a resource property fails the resource, a stack Output degrades to a warning and a dropped output). Any list-returningFn::GetAtt—AWS::EC2::VPC.Ipv6CidrBlocks, ... — reaches the same refusal. The BEHAVIOR is deliberately unchanged: an array is still refused, because real CloudFormation rejectsFn::Splitover a list too, so such a template was never valid CFn — accepting it would let cdkd deploy templates thatcdkd export/cdkd import --migrate-from-cloudformationthen cannot hand back to CloudFormation, breaking the bidirectional-migration guarantee, and the post-upgrade failure is a correct rejection rather than a regression. What changed is the MESSAGE. An ARRAY now gets its own refusal stating that the value is ALREADY a list (with the item count), that CloudFormation rejects the same template, and that the fix is to remove theFn::Splitand use the value directly. A non-array non-string keeps its own distinct refusal (and reportsnullrather thantypeof's misleadingobject), so the two cases can never be confused.ResolverContextcarries no referencing logical id and threading one through this cross-cutting file for a message would be out of proportion, but the UNRESOLVED value EXPRESSION is already in hand, so the message names it —(from Fn::GetAtt [Zone, NameServers])for both the list and the dotted!GetAttspellings (splitting on the FIRST dot only, so a nested stack'sChild.Outputs.Keyrenders[Child, Outputs.Key]the way CFn parses it; and rendering a non-string argument by its intrinsic NAME rather than as[object Object]), or(from Ref MyListParam)for aRefto aCommaDelimitedList/List<Number>parameter — the SECOND genuinely reachable array source, viacoerceParameterValue. The remedy is per-source: the Route 53 example and the #1868 workaround note are emitted ONLY when the value actually is anFn::GetAtt, a parameter reference gets the parameter remedy, and a literal array or any other intrinsic gets a neutral one — handing a hand-written literal a Route 53 story it has nothing to do with only misdirects. Both refusals throwIntrinsicResolutionRefusalErrorrather than a bareError, matching the deliberate refusals already in the file; the #1740 laundering path is NOT reachable here today (anFn::Sub${...}placeholder cannot contain anFn::Split, and the 2-arg variable-map form resolves values outside any catch), so the class changes no behavior — it keeps "deliberate refusal" a property of the THROW rather than of the one catch that inspects it. It is NOT a guarantee against a class-agnostic catch, and the comment says so rather than over-claiming: the same file'sevaluateConditionsabsorbs any failure per condition and downgrades it tofalse, so anFn::Split-over-a-list inside aConditionsentry is silently laundered today (by both classes alike — no regression). Five of the class's six throw sites are nowmarkNonRetryable(#1838) — bothFn::Splitrefusals plus the threeFn::Sub-reachable ones that were already shipping unmarked (guardedPhysicalIdFallback's ARN / URL hard-fail, the--strict-getattrejection,rejectPlaceholderArnAttribute). The criterion is the oneretryable-errors.tsdocuments — "can this ever succeed on a retry" — NOT "does today's wording collide with a pattern", which that same file calls insufficient ("Keeping the offending values OUT of the message narrows that surface but cannot close it") because the classifiers match by SUBSTRING and every one of these messages interpolates template-controlled text: an ordinary composite logical id likeMyDependencyViolationHandlerputsDependencyViolation(the table's only whitespace-free entry) into the message. Each of the five decides from an input a retry cannot change — a persisted state record, an attribute-name suffix, a CLI flag, an already-resolved value's type. Reachability of the retry loop is real even though resolution runs outsidewithRetryon the flat path, by two routes:NestedStackProvider.createruns a childDeployEngine.deploy()and re-throws, and the parent wrapscreate()inwithRetry, so inside a nested stack each retry re-runs a full child deploy plus rollback across the ~47s schedule; and under--strict-getattan OUTPUT-resolution failure was re-wrapped in a freshErrorwith nocause, which DROPPED the non-enumerable marker while inlining the refusal's full text (including the logical id) into the new message — sodeploy-engine.tsnow threads{ cause: error }there, whichisMarkedNonRetryable's.causewalk follows. Marked at each THROW rather than in the constructor because exactly ONE site can genuinely heal: the #1730 fabricated-account guard, wheregetAccountInfocaches a fabricated answer for only 10s so a later attempt succeeds.src/utils/error-handler.ts's JSDoc and.claude/rules/architecture.mdnow enumerate all six sites accurately, split by whether they areFn::Sub-reachable (the #1730 site was missing from both before this change) and by which are marked. The same defect class survives in two places this change deliberately does NOT touch: the resolver's terminal bareErrors (Resource ... not found for Fn::GetAtt,Ref ... not found) and three furthercause-dropping re-wraps indeploy-engine.ts— both need their own sweep and are tracked as issue #1889. The in-code comment also no longer over-claims that the error class prevents laundering: the same file'sevaluateConditionscatches everything per condition and downgrades it tofalse, so anFn::Split-over-a-list inside aConditionsentry IS silently absorbed today — by both classes alike, so nothing regresses, and a unit test now pins that behavior alongside theFn::Subvariable-map PROPAGATION the class rationale rests on. Tests: the array refusal (wording, item count, singular/plural), the real-worldNameServersshape against a list-valued state record, bothFn::GetAttspellings plus the literal case that omits the source clause, the non-array non-string cases (number/object/null) asserted NOT to claim "already a list", the ordinary string still splitting (including the supportedFn::Join-then-Fn::Splitroute), and the error class + code. - ✅ An IMPORTED hosted zone records the same
NameServersattribute a deployed one does (issue #1875) —src/provisioning/providers/route53-provider.ts,docs/import.md,tests/unit/provisioning/route53-provider-import-attributes.test.ts,tests/unit/provisioning/route53-provider.test.ts.importHostedZonereturnedattributes: {}from BOTH branches, so a zone adopted throughcdkd import/cdkd import --migrate-from-cloudformationhad noNameServersin state — and nothing downstream rescued it:IntrinsicFunctionResolver.resolveGetAtt's flat-attribute branch missed (taking PR #1868's legacy comma-string normalization with it, since that normalization lives in the same branch),constructAttributehas noAWS::Route53::HostedZonecase, and resolution fell through toguardedPhysicalIdFallback, which warns and hands back the zone id STRING. A downstreamFn::Join— what CDK'szone.hostedZoneNameServersemits — then threwFn::Join's second argument must be a list,resolveOutputscaught it per-output, and the Output was SILENTLY absent from a deploy that exited 0. That is the exact symptom #1868 fixed for the deploy-created case, still live for the imported one;provider.getAttributedoes return the list but is only reached from the orphan-rewriter path, never from ordinary template resolution. Fixed on the WRITE side rather than in the resolver, matching how the deploy-created case already works (the attribute is recorded at write time) and leavingconstructAttributeuntouched. The--resourceoverride branch pays NOTHING: itsGetHostedZoneverification response already carries the delegation set, so the same call now supplies the attributes. The NAME-lookup branch pays one extraGetHostedZone, becauseListHostedZonesByNamereturns no delegation set. Both normalize?? []— a LIST, never a comma string — exactly ascreateHostedZone/updateHostedZone/getHostedZoneAttributedo, so an imported zone and a deployed one resolve IDENTICALLY and no phantom drift is introduced; both also recordId, which is what the create path records. Both also recordId, which is what the create path records — STRIPPED of the/hostedzone/prefix with the samereplacecreateHostedZoneuses, because--resource MyZone=/hostedzone/Z123is accepted (the SDK'sidNormalizerMiddlewareremoves the prefix on the wire) and recording it verbatim would persist anIda deploy never produces. The extra read is BEST-EFFORT and never fails the import, matchingAppSyncProvider.childImportAttributes(issue #1728) andimportRecordSet's adopt-verbatim fallback:import.tsonly aborts when ZERO resources import, so a throw here would cost the row under--migrate-from-cloudformationat exactly the moment the CloudFormation stack is being retired, leaving the zone in NEITHER CloudFormation nor cdkd state. Its degraded answer is the EMPTY attribute map, not a partial one, and that is load-bearing:import.ts's carry-over is gated on the returned map being NON-EMPTY (a gate that exists because nearly every provider spellsattributes: {}explicitly), so a partial{ Id }would take the row branch and OVERWRITE a previously-recorded good map — a zone imported successfully once and re-imported whileroute53:GetHostedZoneis denied would LOSE its storedNameServers, i.e. state strictly worse than before this change.{}falls through and preserves it. The one failure that does NOT degrade isNoSuchHostedZonebetween theListHostedZonesByNameand the follow-up read: that row is DECLINED (skipped-not-found), matching what the override branch has always answered for the same condition, since adopting a zone AWS demonstrably no longer has is worse than declining it. The resolution failures themselves are unaffected: an ambiguous split-horizon name and a deniedListHostedZonesByNamestill propagate as afailedrow. The warning names a remedy that actually works — re-running the import for that row with--force, or a template change forcing an UPDATE — because a plaincdkd deploydoes NOT heal the record:deploy-engine.tscontinues onNO_CHANGE, so an unchanged zone never callsupdate(), andkickOffAutoRefreshObservedPropertiesrefreshesobservedProperties, a different field. Tests: a new file covering both branches x (populated / empty / absent delegation set), theNoSuchHostedZone->nullregression on BOTH branches, the degraded arm asserting adoption + an EMPTY map + the warning, animport.ts-level arm proving a priorNameServersSURVIVES a re-import whose read fails, the prefixed-overrideIdnormalization, a parity assertion that the imported and created attribute sets aretoEqual, and an END-TO-END block that feeds the import's OWN returned attributes to the realIntrinsicFunctionResolverand joins them — with the pre-fix{}shape kept beside it as a negative control. Six mutation probes (each branch reverted toattributes: {}; the?? []replaced by a comma string at both sites; the best-effort catch turned into a rethrow; the degrade arm restored to a partial{ Id }; theNoSuchHostedZonedecline removed) all RED; the--resource/auto-lookup assertions in the pre-existing provider test file were updated for the new return shape, priming the follow-upGetHostedZoneso those tests exercise the SUCCESS path rather than the catch. Live-tested by a newtests/integration/route53/verify.shPhase 2.5 against real AWS: the--resourcebranch re-adopts the deployed zone and a redeploy re-resolves BOTH Outputs (theFn::Joinand the bare list-valuedFn::GetAtt) identically to a deployed zone. Three things that arm had to get right, all found by running it (or by review) rather than by reading: the override is spelled with the/hostedzone/prefix so the recorded physical id CHANGES, which disablesimport.ts's attribute carry-over -- at the bare id the prior deploy's good map is preserved and the arm would pass with the fix reverted; the canonical id must be restored BEFORE the redeploy, because every RecordSet takes its immutableHostedZoneIdfrom aRefto the zone, so deploying while the prefixed id is recorded plans a replacement of all three records and create-first collides with the live RRSets; and the redeploy step DROPS the twoNameServersoutputs from state first, which is what makes it able to fail at all --cdkd importcarriesexistingState.outputsforward and the no-change deploy path keeps the PERSISTED map verbatim whenever resolution fails (resolutionFailed->outputs: persistedOutputs, a guard that exists so a partial resolve cannot clobber good outputs), so Phase 1's correct values would have satisfied every assertion and the arm was GREEN with the provider fix reverted until the deletion was added. With the keys removed a resolution failure leaves them absent (red) while success flipsoutputsChangedand re-persists them (green); proven by a mutation probe that blanks the imported zone'sattributesto{}-- the exact pre-fix record shape -- and REDs on the #1875 symptom message. The NAME-lookup branch has no live coverage in that fixture and the arm says so instead of implying otherwise: the zone name embeds the account, so the synthesizedNameis an unresolvedFn::Joinand auto mode correctly declines the row -- asserted as a decline, which doubles as a tripwire for issue #1897.
Recently Implemented (2026-08-13):
- ✅ The
handledPropertieswiring critic follows!-asserted reads, and now fails on evidence LOSS rather than only on a gap (issue #1842) —scripts/gen-handled-property-wiring.ts,vite.config.ts,tests/unit/scripts/gen-handled-property-wiring.test.ts. Two separable defects, both found by the #1808 reviewers. (1) The taint walk'sunwrappeeledas T/satisfies T/ parentheses but NOT the!non-null assertion, while its upward twinclimbDID — soproperties!['WarmThroughput']contributed NO evidence while the runtime-identicalproperties['WarmThroughput']contributed the full set. Both directions now share oneisTransparentWrapperpredicate over the five nodes that erase to nothing (parentheses,as T,satisfies T,!,<T>x); a node that can change, short-circuit or defer its operand (await,??) is deliberately excluded and pinned by a test. Optional chaining is NOT excluded:properties?.['X']IS credited, correctly, because the read happens when the bag exists — measured, and pinned with a counter-case that(properties ?? {})['X']is not. (2) The real problem:wiredis a floor of ONE surviving read, so--checkprinted its clean-treeOK — ... 0 gapsline byte-identically through a real degradation. MEASURED ondynamodb-table-provider.ts: respelling the ONE delegated read insidedeclaresWarmThroughput()dropsWarmThroughput'sdelegatedevidence plus itsgetDriftUnknownPaths/readCurrentStateseeds, with byte-identical output. #1808 rewrote that very read into theproperties !== undefined && ...spelling for exactly this reason, and its in-code comment says so — an independent confirmation. (Stated per-site because breadth matters: respelling EVERYWarmThroughputread instead removes the last evidence and is caught as an ordinarygap, proving nothing about this verdict.) The loss was visible only as a regenerated-file diff the SAME commit can carry — which is how #1808 shipped a degraded matrix through a fully green pipeline.--checknow also grades the fresh analysis against the COMMITTED matrix per (class, property) and fails when the evidence-shape set or the seeding-member set SHRANK, naming exactly what was lost. The WRITER refuses the same reduction, which is what makes the verdict reachable at all: regenerating otherwise moves the baseline underneath the comparison, and the writer is also the half CI exercises (the workflow writes before it checks). Both grading seams (--providers-dir=/--baseline=) are refused on the write path unless--out-dir=redirects the output SOMEWHERE ELSE, decided by directory IDENTITY (dev+ino) rather than by string equality — checking flag PRESENCE let--out-dir=docs/_generatedthrough, and comparingresolve()output still let through the two spellings that do not differ as paths at all: a case variant on case-insensitive APFS (docs/_GENERATED) and a SYMLINK, both measured writing the committed matrix at exit 0. An empty value is rejected too (resolve('')is the cwd, which dropped the matrix at the repo root). A verdict requires a baseline that could have failed. Grading against a file invites making the file stop being able to fail, and CI'sgit diff --quietstep covers only the UNSTAGED spelling of a deletion (the path stays in the index, so the re-created file reads as modified; a stagedgit rmmakes it untracked and the diff exits 0). Rather than a sixth guard, usability is defined POSITIVELY (assessBaseline) as three conditions: SELF-CONSISTENT (its WHOLEsummaryequal to the summary recomputed from its own classes — not two hand-picked count fields, so a field added later is constrained automatically), EVIDENCE-BEARING (everywiredproperty actually carryingevidenceandseededBy— the fields the comparison CONSUMES), and NON-VACUOUS (covering at least one (class, property) pair of the tree being graded — it must be able to FAIL). Review found five spellings, one per round: DELETED, TRUNCATED, EMPTY, SHRUNKEN (cut to one pair — it OVERLAPS, so non-vacuity waved it through) and STRIPPED (evidence/seededByblanked while every count stays intact, so the mitigation announcedgraded 1138/1138having graded nothing). The pattern behind all five is one bug: each round constrained something ADJACENT to the comparison — a pair count, then summary counts — and never the fields the comparison reads. Constraining exactly what the consumer consumes is the rule; the five spellings fall out of it. Free today: all 1136 wired properties carry both fields, and pair counting is by DISTINCTClass#Propertyso replication cannot fake a length. The threshold stays zero rather than a ratio because any positive number is a magic constant and legitimate drift (a PR adding a provider class) must not trip it; partial coverage is handled by visibility instead — EVERY run, writer included, printsgraded N/M pairsplus the total evidence DEPTH (evidence+seededByentries), and the suite pins that the committed matrix grades every pair that CAN fail (1136/1138; the 2 allow-listed properties carry no evidence to grade, so pinning 100% would have been pinning a falsehood). The residual bound is stated as exactly what those conditions ADMIT: a baseline that agrees with its own summary, whose labels match its evidence, and that overlaps this tree — so a disjoint or blanked one is refused, while a self-consistent SUBSET is usable and grades only what it covers. Four shapes are admitted and each now costs a visible number: a subset (one pair below the 1136/1138 ceiling), an OLDER committed matrix restored by a merge — accepting that is CORRECT, since it is a real baseline describing a real tree and reports real losses — and a property relabelled to a different VALID status with its evidence blanked. A fourth and minimal one, weakening an entry to a non-empty SUBSET of what the tree proves, moves no pair count at all, which is why the metric also reports DEPTH — it falls by exactly the number of entries removed (from the 5384 baseline: 5382 for the two seedsApiGatewayProvider#CloudWatchRoleArnloses, 5383 for trimmingACMCertificateProvider#CertificateAuthorityArnto one seed). Stated as a formula because three correct measurements disagree until the probe is named. The bound on that guarantee, documented rather than chased: depth is a global SUM and so COMPENSABLE — a tree losing one seed while gaining another, with the baseline hand-edited at both sites, produces byte-identical output; closing it needs a per-PAIR depth comparison, which is a different critic and not the accidental shape this one defends. A self-consistent SUBSET is therefore usable, grading only the pairs it covers.--checkpreviously printedOK — 0 evidence lossesat exit 0 with the matrix deleted, an unearned green for a comparison never made, and a checker must not borrow another CI step's ordering for its own honesty. The waiver is SPLIT in two because they suppress different artifacts:--accept-evidence-losswaives an ENUMERATED per-property reduction,--accept-missing-baselinewaives having no comparison at all (nothing to enumerate). Conflated, one corrupted byte was enough to write an ungraded matrix at exit 0 with the enumeration suppressed and the corruption overwritten. Both are writer-only and rejected on--check;--baseline=stays a test seam that selects WHAT is compared and can only weaken the loss comparison, never suppress the gap or stale verdicts. Escape hatch for a genuine, intended reduction:vp run gen:handled-property-wiring:accept-loss(--accept-evidence-loss), which prints every lost shape and seed before writing; the flag is REJECTED on--check, so the CI verdict can never be told to look away. Only pairs present on BOTH sides are compared, so retiring a property tounhandledByDesignis not a false failure. Probed against REAL code per the checker rule (a fixture built alongside the fix shares its blind spot): the pre-fix critic on the!-respelled provider printed the clean-tree line byte-for-byte undercmp, while the new verdict namesDynamoDBTableProvider#WarmThroughput — lost evidence [delegated] and seeded-by [getDriftUnknownPaths, readCurrentState]and exits 1; a second probe drives a degradation the FIXED parser still cannot follow (a computed key), so the verdict is shown standing on its own rather than only behind the!spelling. No matrix content changed: no provider spells a bag read with!today — #1808 shipped the!== undefinedworkaround instead — so the parser fix is a no-op on the current tree and a fence for the next one. - ✅
cdkd local invoke --assume-roleand--stack-regionstop carrying a raw region case (issue #1836) —src/cli/commands/local-invoke.ts,src/cli/commands/local-state-loader.ts,src/cli/commands/local-state-source.ts,src/cli/commands/local-{start-api,run-task,invoke-agentcore}.ts,tests/unit/cli/local-region-case.test.ts,tests/unit/cli/local-state-loader{,-bootstrap-repo}.test.ts,tests/unit/cli/local-state-source-region-case.test.ts(new),tests/unit/cli/local-state-loader-cross-stack.test.ts,tests/integration/local-invoke/{verify.sh,lambda/index.js,.scenarios.json},tests/integration/local-invoke-from-state/{verify.sh,.scenarios.json},scripts/build-scenario-coverage-matrix.ts. The two sibling exposures #1814 verified present and deliberately left out of its scope — same quiet failure mode as #1795: an upper-cased region is structurally valid, AWS SDK endpoint resolution and the partition table are both case-SENSITIVE, so nothing rejects it and the call simply goes to the wrong partition's endpoint or a comparison silently misses. (1) The--assume-roleSTS chain.cdkd local invoke's credential block resolved its STS region from--regionthenAWS_REGIONthenAWS_DEFAULT_REGION; the handler-entry fold covers only the first link, so with--assume-roleandAWS_REGION=CN-NORTH-1and no--regionthe RAW value reached both the AssumeRoleSTSClient(commercialsts.CN-NORTH-1.amazonaws.com) and the container's ownAWS_REGION, i.e. every SDK client the handler builds. That second consumer is NOT--assume-role-only, which the first cut of this fix got wrong: the DEFAULT path copiesAWS_REGION/AWS_DEFAULT_REGIONinto the container throughforwardAwsEnv, so folding inside the assume-role arm alone made ONE command answer two different ways for the same shell (cn-north-1with--assume-role,CN-NORTH-1without) —forwardAwsEnvnow folds exactly those two entries and copies the credential triple verbatim (an AKID is case-sensitive) — and so doeslocal-invoke-agentcore.ts's own copy, the unfixed twin the second review round found: #1814 folded that command'sstsRegionbut left its default arm copyingAWS_REGIONverbatim, socdkd local invoke-agentcorewithAWS_REGION=CN-NORTH-1gave the containercn-north-1WITH--assume-roleandCN-NORTH-1without — the exact asymmetrylocal-invoke.ts's comment describes, and outside #1843's scope (which names onlylocal-start-api.ts), so it was neither fixed nor filed. Its test asserts the two arms AGREE for one shell, because the defect was neverthe value is wrongbutthe value depends on a flag that has nothing to do with the region. The whole chain now folds throughcanonicalizeRegion, and the block was extracted into an exportedapplyLambdaCredentialEnvmirroringapplyAgentCoreCredentialEnv(the shape #1814 fixed) so the STS client's region is asserted DIRECTLY by a unit test rather than at one remove — the issue's own description namedargs.region, which is the agentcore spelling; cdkd'slocal invokecopy readoptions.regioninline in the handler, so the flag link was already covered and the env-var fall-throughs were the whole live exposure. (2)--stack-region. It is not a chain link but a FLAG whose raw value was compared against a state record's region and forwarded to cdk-local as the CFn client's region. It now folds at the handler entry of all four commands that DECLARE it (invoke/start-api/run-task/invoke-agentcore), beside the existingoptions.regionfold — the one-normalization-point shape — and, because that fold structurally cannot reach the ECS / CloudFront / AgentCore ENGINE commands (start-service/start-alb/start-cloudfront/start-agentcoreinherit the flag from cdk-local and cdk-local owns their handler), ALSO at the--from-statefactory inlocal-state-source.ts, the only cdkd-owned point those four pass through. Double-folding is a no-op, which is what makes both places safe rather than redundant. The state-record compare needed more than a fold of the flag, and folding only the flag would have REGRESSED the mirror image: a region's case is not the flag's to decide, it is whatever spelling the deploy that wrote the record used, and nothing foldscdkd deploy --region— DNS is case-insensitive, so an upper-cased COMMERCIAL deploy succeeds and keys its statecdkd/{stack}/US-EAST-1/state.json. Solocal-state-loader.tsfolds BOTH sides for the comparison whiletargetRegion— which becomes the S3 key — keeps the RECORD's own spelling; a folded key would 404 on exactly the population a flag-only fix would have broken. Its sibling comparison against the synth-derived region takes the same treatment, since leaving one raw while folding the other is the drift the rule exists to prevent. The match is EXACT-first, case-insensitive second — a fold-onlyfindINTRODUCED a silent-wrong-state read.S3StateBackend.listStacksdedupes on the exact{stack}\0{region}pair, socdkd/MyStack/US-EAST-1/state.jsonandcdkd/MyStack/us-east-1/state.jsonare two DISTINCT refs returned in ListObjectsV2's ASCII order (upper-cased first); with both present, a fold-only lookup answered--stack-region us-east-1with the OTHER record, which the pre-fold===got right. So the fold is the RECOVERY for a case mismatch, never an override of a spelling that exists verbatim, and a canonical-equal collision is announced naming the record actually read. That rule was PRODUCTION-UNREACHABLE as first shipped, and the warning it printed was false — the second review round's finding. Every path into the compare folds--stack-regionfirst (each handler's entry fold, and the--from-statefactory), so the candidate was always canonical:exactcould only ever match an already-canonical record,--stack-region US-EAST-1read theus-east-1one, and the warning still called thatthe exact spelling. The user's RAW spelling is therefore captured at each handler entry BEFORE the fold (options.rawStackRegion), carried through the factory andS3LocalStateProvider, and consulted by exactly two things: this record-match compare andloadBootstrapContainerRepo's raw marker-key probe (which had the same reachability hole — the one flag that names the region explicitly could not reach the key an upper-casedcdkd bootstrapwrote). It never reaches an SDK client or an endpoint. It DOES reach one S3 key no state record spells — that marker probe'scdkd-bootstrap/{RAW}.jsonsecond attempt, deliberately, since the WRITE side does not fold — which the third round's own wording denied and the fourth round corrected; every STATE key is still a record's own spelling. The folded value still goes everywhere it went before, since it is what cdk-local's CFn client region needs. The warning now states which RULE decided the read (matches --stack-region 'X' exactlyvsno record spells --stack-region 'X' exactly, so this is a case-insensitive recovery) from the same binding the choice was made from, so it cannot claim a match the compare did not make. An ENGINE command has no handler capture point, so the factory derives the raw spelling from the still-raw flag it receives — which is how those four get the rule too. The same both-sides fold reaches one comparison further down the file:buildCrossStackResolver's same-region filter for theFn::ImportValueindex-miss scan, whoseconsumerRegionis the RECORD's spelling — which the fold above makes reachable upper-cased, so a raw!==would have skipped EVERY ref and dropped each cross-stack env var with only a per-key warning. The ref's own spelling is still what keysgetState, exactly astargetRegionis.local-run-task.tswas handing that helper a FOLDED chain value whilelocal-invoke.ts/local-invoke-agentcore.tspassedloaded.region(the second review round's finding, and the reverse of what the code comment beside the call claimed): the two commands keyed ONE index differently.consumerRegionbecomes the exports-index KEYcdkd/_index/{region}/exports.jsonAND the rawref.region === this.regionfilterExportIndexStore's index-miss REBUILD uses, so a spelling no state record carries yields ZERO refs and PUTs an EMPTY index — after which everyFn::ImportValuedegrades to the O(N) scan permanently, from a helper whose own header called itself read-only. Keyed by a record's spelling the filter always matches, so the worst case is a key miss plus a CORRECT rebuild. run-task now threads the loaded record's region out ofbuildEcsImageResolutionContextand prefers it, falling back to the folded env chain only when no record was loaded at all; that resolution moved into an exportedresolveEcsConsumerRegionso it has a BEHAVIORAL test rather than only the source-level co-occurrence pin (which acond ? canonicalizeRegion(a) : process.env['AWS_REGION']would also have satisfied).src/state/export-index-store.tsis deliberately NOT touched: deploy derives its own write key from--region/AWS_REGIONverbatim (deploy.ts), which already differs from the stack's region whenever a stack declaresenv.region, so a key miss is a case cdkd has to survive rather than one it can spell its way out of — and converging the write side is issue #1820's lane. The helper's header, which claimedread-only against state, now says what is true: no lock and nostate.jsonwrite, but anFn::ImportValuelookup CAN persist that one derived index key. The file's two region CHAINS fold as well, and BOTH of their first descriptions were wrong in a way the review caught.loadStateForStack's chain does NOT pick the S3 client's region — it is handed toresolveStateBucketWithDefault, which names the legacy default bucketcdkd-state-{acct}-{region}(a name S3 could never have accepted upper-cased, so folding can only ever resolve MORE buckets) and probes it through a client hardcoded tous-east-1; the CLIENT's region isopts.region, which now folds SEPARATELY at all three construction sites in the file (both loaders plusbuildCrossStackResolver), an ABSENT value staying absent so the SDK's own chain still resolves the profile's region — and a BLANK--region ''counting as absent too, which it did not: it passed the!== undefinedgate and arrived asregion: ''while the comment beside it claimed otherwise (AwsClientsdropped it one layer down on a truthiness test, so this makes the boundary say what the client already did; the--from-statefactory's two spreads take the same treatment). The unit assertions now observe theAwsClientsCONSTRUCTOR, not theS3StateBackendone — the second review round found the fold unfenced becauseS3StateBackendreads onlyprofile/credentialsout of the bag those tests asserted, so reverting the onenew AwsClients({ region })line left the whole suite green; all three sites now assert it, andloadBootstrapContainerRepo's client had no coverage at all. AndloadBootstrapContainerRepo's chain becomes thecdkd-bootstrap/{region}.jsonmarker KEY, whichcdkd bootstrapdoes NOT write from a canonical region: it derivesoptions.region || AWS_REGION || 'us-east-1'verbatim, soAWS_REGION=US-EAST-1 cdkd bootstrapreally wrote the upper-cased key and a read that folded and stopped would have MISSED a marker the pre-fold read HIT — a regression this change would have introduced. The read therefore probes the canonical key FIRST (what the write side should converge on) and falls back to the raw spelling, at no extra round trip for an already-canonical region. Aligning the write side belongs to issue #1820, whose lane ownsbootstrap.ts. Every assertion is paired with a commercial / already-canonical counter-case asserting byte-identical behavior, the record-spelling and STS-client cases additionally assert the FEARED shape ({ region: 'CN-NORTH-1' }, agetStatekeyed off the folded flag) rather than only the fixed one, a no-match case pins that the fold is not a wildcard, the exact-match rule is fenced END TO END from the--from-statefactory down to thegetStatekey by a dedicated suite (tests/unit/cli/local-from-state-stack-region.test.ts, which stubs only the AWS boundary) because a helper-level test is exactly what let an unreachable rule look covered — the helper-level rows now pass the option PAIR a real handler produces rather than a bare raw candidate no CLI invocation can emit — two wording-keyed negatives becameexpect(warnSpy).not.toHaveBeenCalled()(they would have gone vacuous on this round's own message rewrite), and the--stack-regionhandler-entry pin is DERIVED from the flag DECLARATION rather than from a read ofoptions.stackRegion—local-invoke.ts/local-start-api.tsnever name the field (they hand the whole options bag tocreateLocalStateProvider), so a read-based sweep would have checked two of four commands and reported green. That pin gains a sibling requiring the RAW capture to PRECEDE the fold: a capture placed after it captures the FOLDED value, which makes the exact-match rule inert again while every existing assertion still passes. A FOURTH chain of the item-(1) shape sat inlocal-run-task.tsand is fixed here too:consumerRegion, which becomes the exports-index keycdkd/_index/{region}/exports.jsonand the same-region filter of the index-miss scan, so a raw upper-cased env region read an object nothing writes and then matched no state record. Its pin is derived from the ENV-VAR READS in that ONE file (everyprocess.env['AWS_REGION']chain must sit inside acanonicalizeRegion(...)call, with a floor so a broken parse cannot pass vacuously) rather than tree-wide, becauselocal-start-api.ts's three chains are a deliberate open residual and a tree-wide sweep would fail on another lane's work. Every source change was binding-proved by reverting each in isolation (sed-swap, nevergit checkout) and watching exactly the corresponding rows fail — seven probes for the first review round (exact-match-first, the--regionfactory fold, the marker raw-spelling fallback,forwardAwsEnv's fold,consumerRegion's fold, the cross-stack same-region filter, and the threeclientRegionsites) and TWELVE for the second: the loader dropping the raw spelling, the factory dropping it, the handler capturing AFTER the fold instead of before, run-task dropping the record-spelling preference, the exports-index key getting folded, each of the threeAwsClientsregion sites individually, the agentcoreforwardAwsEnvfold, and the three blank-region guards — plus SEVEN for the fourth: the cross-stack bucket-name fold, theresolveGetStackOutputboth-sides recovery, that arm's exact-key-first guard, the two false doc claims (run-task's and the loader's), the factory's blankstackRegiongate, andresolveEcsConsumerRegion's||flag link. The two Docker / real-AWS fixtures gained the arms that actually discriminate the change, since neither did before (local-invokenever passed a non-canonical region, andlocal-invoke-from-statepinnedAWS_REGIONcanonical):local-invokeinvokes the echo handler with an upper-casedAWS_REGIONand asserts the ECHOED containerAWS_REGIONarrives canonical, paired with the canonical counter-case asserting byte-identical output — the handler echoesprocess.env.AWS_REGIONfor exactly this — andlocal-invoke-from-statere-reads the already-deployed stack with an upper-cased--stack-region, assertingBUCKET_NAMEstill resolves to the deployed bucket (pre-fix it silently came throughunset) at no extra deploy. Thelocal-invokearm deliberately stays Docker-only with NO--assume-rolearm — that would need a real assumable IAM role created outside any stack — and the fixture says so in a comment so the coverage limit is explicit rather than implied; the STS chain is covered by the unit feared-shape assertions on theSTSClient's own constructor region. Both fixtures carry the newlocal-region-case-foldscenario tag. Left open, filed separately:local-start-api.tscarries three chains of the same item-(1) shape whose env-var fall-throughs are still raw (the per-LambdastsRegion, the server'sdefaultRegion, and the WebSocket management-API containerAWS_REGION) — issue #1843, whose scope was widened with a FOURTH site the review-fix round surfaced: that file's ownforwardAwsEnvcopy, the exact twin of the one fixed here, which #1843 did not enumerate. A FOURTH review round then found thatconsumerRegionhas FOUR consumers, not the two the third round's comment named, and that two of them were still wrong. (a)buildCrossStackResolverhanded the RAW record spelling toresolveStateBucketWithDefault, unlike both sibling loaders which fold before their own bucket resolution — so on an account with no--state-bucket/CDKD_STATE_BUCKET/ cdk.json bucket, a record atcdkd/MyStack/US-EAST-1/state.jsonnamed the legacy defaultcdkd-state-<acct>-US-EAST-1, an upper-cased bucket name is not virtual-hostable so the request went path-style, S3 answered 400InvalidBucketName,probeBucketrethrew, and the resolver warn-and-dropped EVERYFn::ImportValue/Fn::GetStackOutputenv entry whileloadStateForStackread that same record fine — an unconditional failure for legacy-bucket accounts. The bucket resolution now folds while the index KEY and the scan filter keep the raw spelling, which is the whole reason the value stays raw at the boundary. (b)resolveGetStackOutputhad no case recovery at all, although it is reached with aproducerRegioncdkd does not control: cdk-local defaults it toSubstitutionContext.consumerRegionfor an intrinsic carrying no explicitRegion, so anUS-EAST-1consumer record referencing aus-east-1producer builtcdkd/Producer/US-EAST-1/state.json, 404'd, and the var was dropped. It now mirrorsresolveImport: EXACT key first (a record that exists at that key IS the producer, so a missing output there stays a genuine miss), then alistStackswalk folding BOTH sides, reading the found ref's own spelling. Two smaller items rode along:resolveEcsConsumerRegion's flag link became||so--region ''can no longer beatAWS_REGIONand win the chain as an empty index key / bucket region (the blank-is-absent rule this change adopted at the four client boundaries), and the--from-statefactory'sstackRegionspread is now blank-gated too, so the comment claiming parity with the raw half beside it is finally true (behavior-identical downstream, where the gate is a truthiness test — the claim was the defect). Three comments the code did not support were corrected in the same round, and because rounds 2 and 3 each shipped a false one about this exact field the corrections are PINNED: therawStackRegiondoc is now DERIVED against the code — the command that feedsloadBootstrapContainerRepomay not claim the record match is the only consumer, the three that do not call it must, and the loader's own copy must admit the raw marker key — asserted against the round-3 sentence verbatim so a revert of the wording fails the suite. - ✅
AWS::RDS::DBSubnetGroup.DBSubnetGroupArnandAWS::SSM::Parameter.Arnare cached, so an outputFn::GetAtton either resolves instead of hard-failing (issue #1824) —src/provisioning/providers/{rds,ssm-parameter}-provider.ts,scripts/gen-sdk-attr-coverage.ts,docs/_generated/sdk-attr-coverage.{json,md},tests/unit/provisioning/uncached-arn-attributes-issue-1824.test.ts(new),tests/unit/scripts/gen-sdk-attr-coverage.test.ts. The #1800 fixture re-capture refreshed everytests/fixtures/cfn-schemas/*.jsonfrom a 2026-05-16 capture and surfaced two read-only ARN attributes AWS had added since, neither of which its provider recorded under its CFn name. This is the #1179 GetAtt-key class: an output / cross-resourceFn::GetAttreads the CACHEDresource.attributes[<CFnName>]inIntrinsicFunctionResolver.constructAttribute, which never calls a provider'sgetAttribute, and for an*Arnname the resolver's shape guard HARD-FAILS rather than degrading — both physicalIds here are NAMES (a subnet-group name, a parameter name), sonew cdk.CfnOutput(this, 'ParamArn', { value: param.attrArn })failed the deploy. The two differ in source and were fixed accordingly.DBSubnetGroupArnis read straight off theCreateDBSubnetGroupresponse (no extra call), and re-reported fromupdateoff theDescribeDBSubnetGroupsthe tag diff already issues — necessary because an update result'sattributesREPLACE the state record's rather than merging, so returning onlyDBSubnetGroupNamewould have WIPED the create-time ARN.AWS::SSM::Parameter.Arnhas no create-response source (PutParameterreports onlyVersion/Tier), and the recorded decision is a CONSTRUCTED ARN rather than a follow-upGetParameter: the read would be a brand-new round trip on every parameter create AND every update (parameters are among the most numerous resources in real stacks) for a value that is a pure function of data cdkd already holds, so it removes a round trip. It does NOT buy immunity from a transient failure on the update path, and the in-code note says so rather than claiming otherwise: an update result'sattributesREPLACE the state record's, so a degradation duringupdatedrops the create-time ARN whichever source is used — construction only swaps WHICH dependency can fail (STS plus the client's region resolver, instead of SSM). That degradation is a DECIDED trade-off with the reasoning recorded inbuildParameterArn's JSDoc and pinned by a test: the degraded arm returns the PARTIAL map (droppingArn, so the resolver's*Arnguard fails LOUDLY and the next real UPDATE re-records it) rather than returning NO attributes, which would make the engine carry the previous map forward and keep the ARN at the cost of also keeping a SUPERSEDEDType/Value— a silently wrong answer toFn::GetAtt [Param, Value]and acdkd driftbaseline AWS does not hold. A loud missing value beats a quiet wrong one, the same call thefabricated-account arm makes. Construction reuses the machinery this repo already has for "the create response carries no ARN" — the samegetAccountInfo+derivePartitionAndUrlSuffix+fabricated-refusal trio asAppSyncProvider.buildAppSyncArn: the partition is DERIVED, never hardcodedarn:aws:(the active #1794 / #1815 bug class), and afabricatedaccount (#1730 / #1746) REFUSES to record anything, since an ARN built from the placeholder123456789012carries no wildcard and is therefore invisible toisPlaceholderArn— degrading to the loud pre-fix guard failure rather than to a plausible-looking wrong value. Theimport()path records the ARN too, read off the existence-verificationGetParameterit already issues, so an ADOPTED parameter is not left with the same hard-fail. One further guard rides on the constructed form. The ARN's own REGION segment is canonicalized throughcanonicalizeRegion(the #1795 / #1814 class):derivePartitionAndUrlSuffixfolds case INTERNALLY, so the partition was already right for an upper-cased region while the segment was not, andcdkd deploy --region US-EAST-1is REACHABLE — DNS is case-insensitive, so the deploy SUCCEEDS and then recordsarn:aws:ssm:US-EAST-1:..., a value that matches no IAM policy, is rejected by every SDK call taking the ARN, and is persisted intostate.jsonwhere it outlives the deploy.cdkd importnow REFUSES a physical id SSM's write APIs cannot accept, and that replaces a round-2 guard that was production-unreachable.cdkd import --resource Param=arn:aws:ssm:...:parameter/fooverifies throughGetParameter, which ACCEPTS an ARN, soresolveExplicitPhysicalIdrecords the ARN AS the physicalId — and the damage lands later and elsewhere: the nextcdkd deployfails atPutParameter({Name: physicalId})with aValidationExceptionafter aValuechange, andcdkd destroyfails identically atDeleteParameter. Round 2 addressed the symptom instead, returning an ARN-shaped id verbatim from the ARN BUILDER — which can only ever run against a mock, becauseupdate()sendsPutParameteratssm-parameter-provider.tswell BEFORE it builds the ARN andcreate()likewise, andPutParameterRequest.Nameforbids an ARN outright ("You can't enter the Amazon Resource Name (ARN) for a parameter, only the parameter name itself"), as doesDeleteParameterRequest.Name. Worse, the test pinning it primedPutParameterto ACCEPT an ARN, i.e. endorsed a wire shape AWS rejects. That guard and that test are DELETED, and the refusal sits where the bad value ENTERS (--resource/Properties.Name), before any AWS call. The predicate is a COLON, which comes straight from the documented name charset (a-zA-Z0-9_.-plus/as the hierarchy separator) and therefore covers both shapesGetParameteradditionally accepts while every write API rejects: an ARN and a version / label selector ("Name": "name:version"). REFUSED rather than NORMALIZED to the name, because the ARN -> name mapping is genuinely ambiguous: the leading/is not recoverable, sincearn:...:parameter/foois the ARN of BOTH the simple namefooand the one-level hierarchical name/foo(aws-cdk-lib'sarnForParameterNamerenders the two to the identical string, which is exactly why CDK needs an explicitsimpleNameflag when the name is a token), and a parameter SHARED from another account has no name form at all ("For parameters shared with you from another account, you must use the full ARN") — so a derived name could silently address a DIFFERENT parameter, whereas a refusal is loud and fixable. The message names the remedy for the entry route actually used (the--resourceform, orProperties.Name) plus theaws ssm get-parameter --query Parameter.Namecommand that prints the name to pass; both remedy polarities are pinned, so a template-borne ARN cannot be answered with a flag the user never passed.AppSyncProvider.buildAppSyncArncarries the same latent region-verbatim shape and is filed as issue #1850 (its file is owned by another lane). BothSDK_ATTR_ALLOW_LISTKNOWN GAP entries are DELETED (the list is now empty), which is what verifies the fix:classifyTypetestscachedKeysbefore the allow-list, so a surviving entry would have gone silently inert while the critic still reported the types as debt —docs/_generated/sdk-attr-coverage.{json,md}moves both attributesallow-listed->cachedandknownGap2 -> 0. The new unit suite drives each provider's real create / update result into the resolver rather than asserting the attribute map, so it pins the end-to-end behavior; with the caching reverted, everyresolves Fn::GetAttcase fails with the resolver's ownIntrinsicResolutionRefusalError(... is not an ARN (arn:...)) — the actual pre-fix deploy failure — and the critic's new positivecachedfence fails alongside it. What that fence binds is the provider FILE, not a code path, and the entry says so because the obvious reading is wrong:collectStoredAttributeKeyspools object-literal keys per file, so any ONE of the create / update / import literals keeps the type classifiedcached— measured, neutralizing BOTH the create and update spreads leaves the critic green off theimport()occurrences alone, and only removing all three flips it togap. Per-path binding comes from the provider suite, which drives each path's real create / update / import result into the resolver, and it now covers the "omit the key, never writeundefined" arm on every path (probed: making the write unconditional at the RDS update / import and the SSM import sites left every pre-existing case green). The constructed ARN's PARTITION is fenced the #1745 / #1794 / #1815 way — acn-north-1and aus-gov-west-1case, each PAIRED with a byte-identical commercial counter-case — because replacing the derivation with a literal'aws'left all 15 first-cut tests green when every one of them usedus-east-1. REAL-AWS COVERAGE, because the SSM ARN is CONSTRUCTED and a green mocked suite would agree with a wrong wire assumption (construction and any unit assertion share one formula).tests/integration/getatt-fallback-guardgains a POSITIVEGUARD_PHASE=arn-resolvesphase whose Consumer value isFn::GetAtt [Probe, Arn]: the deploy must SUCCEED and the consumer parameter's live value must equalGetParameter'sParameter.ARNBYTE FOR BYTE, for a FLAT name AND a HIERARCHICAL (leading-/) one — the leading-slash fold into theparameter/separator is exactly where a constructed ARN goes wrong, and every pre-existing phase used a flat name only. A follow-onarn-resolves-updatephase rotates the probes' VALUES so each takes the UPDATE path, then assertsattributes.Arnin the REAL persistedstate.jsonis unchanged across it. The non-vacuity check that the update actually fired runs PER PROBE: keyed on the flat probe alone, the HIERARCHICAL before/after equality could pass withHierProbenever taking the UPDATE path at all — and the leading-slash arm is the one the phase exists for.assert_arn_matches's probe captures also emit aFAIL:line naming the compared values instead ofreturn 1, which under the fixture'sset -euo pipefailaborted the run with no diagnosis at all (the helper is called as a statement, so a non-zero return kills the script), leaving a throttled probe indistinguishable from a real ARN mismatch.tests/integration/rds-full-stackroutesFn::GetAtt(<DbSubnetGroup>, DBSubnetGroupArn)into a second SSM parameter — mirroring the pattern it already uses for the computed DB endpoint — and asserts it equals the livedescribe-db-subnet-groupsARN, which is what validates thatCreateDBSubnetGroupreally returnsDBSubnetGroup.DBSubnetGroupArnrather than an assumed mock. KNOWN LIMITATION — this fixes NEW deploys, not existing state records. A resource deployed by a pre-fix binary keeps its ARN-less attribute map and the upgrade does not heal it: the deploy engine skips a resource whose resolved properties equal state with NO provider call, so the scenario the issue opens with (upgrade, then addnew cdk.CfnOutput(this, 'ParamArn', { value: param.attrArn })and change nothing else) STILL hard-fails — and the resolver'sattributes are not enriched for this resource type ... file an issuemessage is now FALSE for these two types. The heal plus that wording are issue #1852, deferred because both files aresrc/deployment/**, owned by another lane and in theinteg-broadgate scope; until then the remedy is a real property change on the resource (or a re-import). - ✅
AWS::DynamoDB::GlobalTabledestroy +WarmThroughputhardening (issues #1830 / #1857):delete()retries the transientCannot delete table while indexes are being created, updated, or deletedrefusal (message classifier + awaitForIndexesActivere-arm between attempts, both provider-local at the time and since LIFTED into the sharedsrc/provisioning/dynamodb-index-busy-delete.tsby issue #1931, which gave the siblingAWS::DynamoDB::Tabletype the same retry) instead of surfacing a hardPartialFailureError. The re-arm poll is BOUNDED at 60s per attempt rather than reusing the #1521 gate's 15-minute one: that cap is sized for a wait that runs ONCE, while this one runs per retry, so at the then-8 retries the budget was ~2h inside a calldestroy-runner.tscaps at 30 min — a genuinely stuck index therefore produced a 30-minute wait ending in a genericResourceTimeoutErrorthat never mentions indexes, instead of AWS's own actionable sentence in seconds. The first index-busy retry also emits ONElogger.warnnaming the cause, sincewithRetryannounces retries only at debug level and a delete spending minutes re-polling otherwise printed nothing. And a THROTTLEDDescribeTableinside that poll now keeps waiting rather than reading as "settled" (which degraded the re-arm to no wait at all); any OTHER describe failure still gives up the WAIT — never the operation, since a delete path must tolerate a stale read — but says so at warn level instead of debug. On theWarmThroughputhalf, the block is now numerically coerced per member intoSdkGlobalSecondaryIndexes(the one funnel all four send sites read), refused-and-warned when no member resolves, and dropped with a warning at eachUpdateTablesite when it would LOWER the live value (AWS rejects a decrease). The twoWarmThroughputRULES — the per-member numeric coercion and the decrease guard — live in a new sharedsrc/provisioning/dynamodb-warm-throughput.tsthat BOTH DynamoDB providers read, rather than in a second GlobalTable-local copy: PR #1808 had just landed the same two rules for the siblingAWS::DynamoDB::Table. Only that Table spelling ever SHIPPED — the GlobalTable side never existed outside this change — so what landed here is the Table rule LIFTED rather than two shipped rules reconciled, and no deployed behaviour changed for either type. Comparing the shipped rule against the GlobalTable DRAFT input by input (a quoted numeric string, a partially usable block, a mixed decrease/increase, an absent live value, a zero, a negative, a non-numeric string, an explicitnull, an empty block, a scalar, an array) showed the decrease guard agreeing on EVERY probe and the coercion on every probe but one — a whitespace-only string, where a bareNumber(' ')is 0 rather than NaN, and the shared rule takes the REFUSING answer. The GlobalTable provider's owntoFiniteNumber(capacity units, on-demand ceilings) moved into the shared module in the same change, alongsideWARM_THROUGHPUT_MEMBERS: it was one rule written by hand three times (here, the Table provider's byte-identicalcapacityNumber, and the shared module's own member parser), and a file REFUSING a whitespaceWarmThroughputwhile reading a whitespaceMaxReadRequestUnitsas ZERO is the very divergence the extraction exists to prevent, one property over.dynamodb-table-provider.tskeeps its owncoerceWarmThroughputsignature as a four-line adapter over the shared coercion, so its behaviour, its call sites and its five warm-throughput test suites are unchanged. Also corrects a claim the GlobalTable code carried while #1808 was still unmerged: that provider'sisSendableWarmThroughputwas issue #1760'sBoolean(value)truthiness gate at the time, and #1808 redefined it ascoerceWarmThroughput(value) !== undefined— i.e. the same rule as the coercion'sspecpresence, not a competing one, so the same-name/different-meaning trap no longer exists. - ✅ The ECR registry-host FORM TABLE is spelled ONCE, and the host is matched case-insensitively (issues #1792 / #1793) —
src/utils/ecr-uri.ts,src/utils/regexp.ts,src/cli/commands/gc.ts,src/assets/asset-redirect.ts,tests/unit/utils/ecr-uri.test.ts,tests/unit/cli/gc.test.ts,tests/unit/local/ecs-task-resolver.test.ts,tests/integration/gc-custom-asset-names/. The forms were spelled TWICE and the two already disagreed:gc.ts'sECR_REGISTRY_HOSTcarried the-fipsandon.awsforms whileecr-uri.ts'sECR_URI_HOST_REGEXmatched only the plaindkr.ecrone, so a genuine FIPS or dual-stack registry was not recognized there AT ALL andcdkd local invoke/start-api/run-taskclassified it as a public image — anonymousdocker pull, nodocker login, opaque auth error.ecr-uri.tsnow owns the FORM TABLE (ECR_REGISTRY_HOST_FORMS+ theecrRegistryHostPattern(...)builder) andgc.tsbuilds its alternation from it, each side keeping its own suffix-ACCEPTANCE rule on top: gc still matches the suffix against the union over every partition (over-matching only ever KEEPS an asset), whileparseEcrRegistryHoststill pairs the captured suffix WITH the region. The four rows are read off the AWS-publishedecrendpoint list —dkr.ecr(every partition),dkr.ecr-fips(the six US / GovCloud FIPS regions), and the dual-stackdkr-ecr/dkr-ecr-fips, whose suffix is the FIXEDon.aws— so a form spelled with the other's suffix is refused, and the fixed literal is the tightest available check for those two (on.awsis AWS-owned, so unlike a captured suffix it cannot be substituted by a look-alike). Unifying the copies surfaceddkr-ecr-fips.<region>.on.aws, which BOTH had been missing: on gc's side that is the IRREVERSIBLE direction, since a missed reference reads as unreferenced and the live image is DELETED. Separately, thedkr.ecrLABELS were case-SENSITIVE literals — the last piece #1786 deliberately left alone — so<acct>.DKR.ECR.<region>.<suffix>named the same host but matched no shape at all, making BOTH entry points go quiet (parseEcrRegistryHostundefinedANDlooksLikeEcrHostWithForeignSuffixfalse, i.e. not even the #1764 diagnostic fired) and, on gc's side, deleting a live image. Both matchers now fold case. Widening rather than refusing was confirmed against docker's own semantics before shipping:distribution/referencespellsdomain-componentas([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])while the repository PATH isalpha-numeric := [a-z0-9]+, andParseNormalizedNamedraisesrepository name must be lowercasefor the REMOTE NAME only (splitDockerDomainperforms no case folding on the domain) — so docker accepts an upper-cased host, cdkd is not made todocker loginfor a URI docker would then reject, and the repository path is never rewritten. What docker does NOT fold is its credential STORE, keyed on the hostname verbatim (measured in #1801), so the fix here is the SHAPE match only and the login / pull spelling stays reconciled byecr-puller.ts'scanonicalizeImageUriHost; #1817 tracks moving that fold intoparseEcrRegistryHost. Tests: the #1786 2x2 extended to the labels (upper / mixed x genuine / look-alike, each measured against the lower-cased verdict, plus explicit ACCEPTED / REJECTED guards so no arm can pass vacuously), a negative block asserting the shapes a WIDENING regression would admit (an unescapeddkr.ecrmatchingdkr-ecr/dkrxecr, a label run merely CONTAINING a served spelling, a U+212A fold), per-form acceptance for all four forms, all four form/suffix MISPAIRINGS refused,on.awsas an exact literal rather than a prefix, and a cross-matcher pin driven off the exported table so a form added to it must work in gc AND in the strict matcher or the suite reds. Four things review corrected, each worth stating because the first cut read as safer than it was. (1) The unification is deliberately PARTIAL and the docs now say so: gc is the ONLY consumer ofecrRegistryHostPattern, whileECR_URI_HOST_REGEXre-spells the alternation off the table's LABELS column, because the strict matcher needs the suffix CAPTURED at a FIXED group index and the builder inlines afixedUrlSuffixuncaptured. So the shared unit is the TABLE — which is what had drifted — not the whole host shape; a THIRD, looser spelling (isCdkAssetImageUri'shost.includes('.dkr.ecr.')) still lives insrc/local/ecs-task-resolver.tsunder issue #1846. (2) gc's DIGEST match looked protective and was INERT. Theiflag widenssha256:[0-9a-f]{64}to upper-case hex, so...@SHA256:AAAA...started being COLLECTED — but a digest is compared for EXACT equality against ECR's always-lower-caseimageDigest, so it could never match and the live image was still DELETED. The digest is now lower-cased on insert (an OCI digest is defined as lower-case hex, so the fold cannot merge two distinct digests); the TAG stays verbatim, because ECR tags ARE case-sensitive and folding one would create the mirror-image inert collection. The old comment claiming the flag's reach past the host "can only over-PROTECT" was false and is corrected in place. Fenced end-to-end (arunGcarm assertingBatchDeleteImage's payload, not merely whatcollect()gathered) plus a real-AWS arm ingc-custom-asset-names. (3) RECOGNITION IS NOT A WORKING PULL for the three newly matched forms:ecrLoginauthenticates against the PLAIN host (proxyEndpoint) while the pull targets the host the template names, and docker's credential store is keyed on the hostname verbatim — so a FIPS / dual-stack pull now fails withno basic auth credentialswherelocal invokeused to REFUSE it with a clear message.docs/local-emulation.mdwas narrowed accordingly and the endpoint threading is filed as issue #1855. The case fold is unaffected (login and pull both land on the plain lower-case host). (4) The label fold moved from an ASCII-only map to plaintoLowerCase(): the ASCII rationale was INVERTED — the labels only select a row from a closed literal set and derive nothing, so full folding is safe, whereas under a futureuflag the ASCII map would REJECT a host UTS-46 maps onto the plain form, re-introducing the #1792 failure rather than preventing a substitution. Also:escapeRegExpwas a THIRD copy (ecr-uri.ts/gc.ts/assets/asset-redirect.ts) and is now the sharedsrc/utils/regexp.ts, with gc-side negative arms proving the escaping (unescaped, gc collectsdkrxecr/onXawslook-alikes); the complete classification table of every verdict these two issues changed — including the five DIAGNOSTIC-ONLY flips, where the parse result is unchanged butlooksLikeEcrHostWithForeignSuffixgoesfalse->true— is recorded inECR_URI_HOST_REGEX's doc and pinned row-by-row by a test; and the consumer-level effect finally has coverage (ecs-task-resolver.test.tsarms proving a FIPS / dual-stack / UPPER-cased host now classifieskind: 'ecr', and that a mispairing stayspublicwhile reaching the #1764 diagnostic). - ✅ The
AWS::ECS::ServiceDeploymentConfigurationnested blocks classified against real AWS — three parity rows and one DIVERGENCE, no behavior change (issue #1806) —src/provisioning/providers/ecs-provider.ts(comment only),docs/provider-development.md,tests/unit/provisioning/ecs-deployment-configuration-subfield.test.ts. The residual #1805 left open: its ROLLING probe could not reachLinearConfiguration/CanaryConfiguration/LifecycleHooks[], and those carry no (or a partial)requiredlist, so unlike the #1802 depth-2 row a kept-but-partial block there IS reachable from a template CloudFormation ACCEPTS. Measured us-east-1 2026-08-13, SDK and CloudFormation A/B per block on a real traffic-shifting service: the nested struct IS replaced, but the absent member is filled with an AWS-side DEFAULT (StepBakeTimeInMinutes-> 6,CanaryBakeTimeInMinutes-> 10, a hook'sTimeoutConfiguration.Action->ROLLBACK; the defaults are fixed, not derived — an emptyLinearConfiguration: {}from a live{33, 9}came back{10, 6}), and CloudFormation handed the SAME partial template reached the IDENTICAL end state in all three cases. So cdkd's verbatim pass-through is parity and a "fill the missing member from the previous side" normalization would be the DIVERGENCE. One PREMISE of the issue did not survive contact, and one FINDING it does not discuss is recorded beside it: those configs are NOTBLUE_GREEN-reachable (each is gated on its OWN strategy — AWS answersLinear configuration can only be present with LINEAR deployment strategy— so the fixture the issue prescribed could not have reached them at all;LifecycleHooksis not strategy-gated that way, measured underCANARY), and the default-fill does not become phantom drift, because the deploy engine capturesobservedPropertiesfromreadCurrentStateand the AWS-filled value lands in the drift baseline. The sweep of the REST of the tree splits TWO ways (plus the array shape), so neither "the other blocks are refused" nor "the rest is parity" is the takeaway (DeploymentAlarmskeeps its permissive-divergence row). AWS itself REFUSES — the ASGInstanceMaintenancePolicydisposition (#1227), cdkd fails loudly, parity with no nested required-ness check involved — for a hook element missingLifecycleStages; a hook element missingTargetType(absent DEFAULTS toAWS_LAMBDA, which then demands a target ARN and role a PAUSE hook does not carry, so dropping one member arms a requirement for two others); andDeploymentCircuitBreaker.ThresholdConfigurationmissingValue. That last one carriesrequired: [Type, Value]in the live registry, so CloudFormation refuses it too and the parity comes from BOTH engines failing — what decides a verdict is whether AWS ACCEPTS, not whether CFn refuses (DeploymentCircuitBreakermissingRollbackis CFn-refused and AWS-ACCEPTED, which is exactly why that row is the permissive divergence and this one is not). It was probed through@aws-sdk/client-ecsrather than the AWS CLI on purpose: the CLI refuses it CLIENT-side off botocore's required trait while the JS SDK cdkd uses serializes the partial, so cdkd's refusal comes from the SERVICE — the models agree, only where validation runs differs. And one genuine DIVERGENCE, opposite in polarity to #1802 and filed as #1861:DeploymentCircuitBreaker's OPTIONAL children (ResetOnHealthyTask, and the wholeThresholdConfigurationblock) dropped from an otherwise complete parent are RETAINED by cdkd and RESET to their AWS defaults by CloudFormation (measured from the identical baseline withRollbackflipped in the same call so the update demonstrably applied: cdkd leftfalse/{COUNT, 7}intact, CFn producedtrue/{BOUNDED_PERCENT, 50}). So cdkd fails to apply a removal CFn applies — too STICKY where #1802 is too PERMISSIVE. What CFn is doing is applying a REMOVAL rather than materializing defaults, which is what a fix must key on: a member the template NEVER declared (set out of band) SURVIVED a CFn update that changed onlyMinimumHealthyPercent, while the same member declared-then-dropped was reset — the previous-present / current-absent semanticclearOnUpdateRemovalimplements one level up. Removing the WHOLE property still resets nothing under either engine, so only a member dropped from a STILL-DECLARED struct is a removal; theLinearConfigurationfamily is parity because the API itself default-fills there. Service identity was captured on both sides (same ARN /createdAt, stack events showing UPDATE) so a replacement cannot be mistaken for a reset. This PR ships the pass-through unchanged and records the divergence; the reset is a behavior change #1861 owns. TheLifecycleHooksARRAY is replaced WHOLESALE (a 2-element list re-sent with one element left exactly that one), making a per-element drop a non-issue. Pass-through pinned by a new describe block whose assertions are written against the shape the REGRESSION would emit. Three mutation probes back that up rather than a blanket claim: an arm-specific default fill fails the Linear, Canary, hook and missing-stages pins; declaringDeploymentConfigurationingetDriftUnknownPathsfails the drift fence; and a readback that still WRITES the key but stops re-casing it (raw SDK camelCase) fails thereadCurrentStateround-trip fence while leaving every send-side pin green — which is why that fence is a round-trip rather than the source grep it replaced. - ✅
ResourceUpdateNotSupportedErroris terminal by construction, so a resource named*DependencyViolation*no longer burns ~47s of backoff (issue #1838) —src/utils/error-handler.ts,src/deployment/retryable-errors.ts(comment only),tests/unit/deployment/retryable-errors.test.ts. Found by the review of PR #1827, which added themarkNonRetryable/isMarkedNonRetryablemarker mechanism (issue #1778) and recorded this as a KNOWN LIVE INSTANCE it could not cover, becausesrc/utils/error-handler.tswas owned by the concurrent #1710 lane. The retry classifiers inretryable-errors.tsmatch by SUBSTRING against the error MESSAGE, andResourceUpdateNotSupportedErrorinterpolates the resource's LOGICAL ID into its message.DependencyViolationis the only whitespace-free entry inRETRYABLE_ERROR_MESSAGE_PATTERNS, so an ordinary composite CDK logical id was enough:MyDependencyViolationSubmadeisRetryableTransientErrorreturn true for a deterministic, cdkd-authored "this type does not support UPDATE" refusal, which ~20 providers raise from inside theupdate()calldeploy-engine.tswraps inwithRetry(anddrift.tswraps for--revert). The refusal was therefore retried through the full generic schedule — 8 retries, ~47s of pure sleep — before the deploy reached the--replaceDELETE+CREATE fallback the error exists to trigger. It now callsmarkNonRetryable(this)in its CONSTRUCTOR rather than at eachthrow: the class is a refusal in every instance, so marking centrally means no provider throw site has to remember, and one forgotten call cannot re-open the hole for a single provider. No import cycle is introduced —retryable-errors.tshas zero imports of its own, andsrc/utils-> other-layer imports already have precedent (src/utils/live-renderer.ts->src/provisioning/resource-name.js). Class identity,exitCode = 2, and theresourceType/logicalId/suggestionpayload are untouched, so the deploy engine'sinstanceoffallback selection andhandleError's exit code are unaffected. The issue's sibling audit of the other refusal classes in the same file marked nothing else:ProvisioningError(and theCdkdErrorbase) must stay unmarked because they are the generic wrapper for RELAYED AWS failures — marking either would make every transient AWS error terminal;ResourceTimeoutErroris raised BYwithResourceDeadline, which wraps the retry loop from OUTSIDE at both sites (deploy-engine.ts:2375,destroy-runner.ts:983), so the classifier never sees it;IntrinsicResolutionRefusalErrorinterpolates user text but intrinsic resolution runs OUTSIDE thewithRetryclosure (which wraps onlyprovider.create/provider.update), and one of its four arms — the fabricated-account guard — is genuinely time-dependent, sincegetAccountInfocaches a fabricated answer for only 10s so a later attempt can heal, which is a positive reason NOT to mark it; and every remaining class (StackTerminationProtectionError,NestedStackChildDirectDestroyError,StackHasActiveImportsError,PartialFailureError,DeployCancelledError,AssetError,MacroExpansionError,LocalMigrateError,MissingCdkCliError, theLocal*/ state / lock / synthesis / config classes) is thrown from destroy pre-flight, command-level aggregation, asset publishing, synthesis or local emulation — none inside a retried closure. Tests pin the marker rather than the wording:isRetryableTransientErroris false for aResourceUpdateNotSupportedErrorwhose logical id containsDependencyViolation, a CONTROL asserts an UNMARKED error carrying the SAME message is still classified retryable, a premise test asserts the message really does carry the pattern (so the block cannot pass vacuously), and further cases cover all four constructor arg shapes, survival through aProvisioningError.causewrap, precedence over a co-occurring throttle signal, payload / class-identity preservation, and the four audited-and-left-unmarked siblings. A second block intests/unit/deployment/retry.test.tspins the user-visible SYMPTOM rather than the classifier verdict — the real class driven through the realwithRetryrejects after exactly ONE attempt with ZERO sleeps, against a CONTROL where the same message unmarked still drives 9 attempts and over 40s of requested backoff. Mutation-probed: disabling the constructor'smarkNonRetryable(this)call turns exactly the 5 marker-dependent tests RED (154 still pass, so both files were found) while both CONTROLs stay green. Code review additionally found the SAME defect shape innestedStackChildFailureMessage(src/provisioning/nested-stack-messages.ts), which IS thrown from inside the destroy runner's retried closure; it is filed as issue #1849 rather than fixed here, becausesrc/provisioning/**was owned by a concurrent lane and — unlike the type-level immutability fact this change marks — a whole-child re-destroy could plausibly heal, so it needs its own per-arm audit plus a real-AWS destroy integ. - ✅ The AWS-computed per-index
WarmThroughputstops drifting a GlobalTable forever (issue #1742 defect 2) —src/provisioning/providers/dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-drift-phantoms.test.ts. The half PR #1773 shipped without, now that the mechanism it needed exists on main.DescribeTablereports aWarmThroughputfor EVERY index whether or not the template asked for one — a default{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000, Status: 'ACTIVE'}, measured us-east-1 2026-08-13 on therollback-replay-effective-propsfixture — so on aproperties-only baseline (what a reverse-replacement rollback leaves behind, sincerollback-executor.tsstripsobservedProperties) the member was a PERMANENT one-sided difference:cdkd driftreported an untouched table forever and--revertre-issued calls for it. Both of the issue's own candidate answers are unusable and each is now pinned by a test. Declaring the path ingetDriftUnknownPaths— the #1760 answer for the siblingAWS::DynamoDB::Table's TOP-LEVEL member — cannot express a member of an ARRAY ELEMENT:calculateResourceDriftcompares arrays wholesale, soisIgnoredPathis never asked about a path crossing one and the only expressible suppression is the WHOLEGlobalSecondaryIndexessubtree, i.e. never detecting an index add / remove / capacity change again (the #1420 stopgap already refused). Gating the READBACK emission on the desired side removes this population's phantom and CREATES one for the far largerobservedPropertiespopulation — every bag already in S3 was written by a binary that emitted the computed member, so the firstcdkd driftafter upgrading compares a baseline that HAS it against a readback that does not and reports the whole array; it does not self-heal, since the observed capture only runs on CREATE / UPDATE. That attempt was implemented, reviewed and REVERTED under #1773, and the in-code comment now says so, so it is not re-proposed. What ships instead is thecanonicalizeDriftPropertiesseam (issue #1784): the provider stripsGlobalSecondaryIndexes[].WarmThroughputfrom BOTH comparison sides, which converges the two populations at once — a stale observed record and a fresh readback both lose the member, and a template-only baseline that never had it now faces an AWS side without it. Scope is a CLOSED module-level path table rather than a walk for any key namedWarmThroughput(Replicas[].GlobalSecondaryIndexes[]entries carry only the read-half throughput blocks, andLocalSecondaryIndexesis a wider raw-SDK divergence than one member), theresourceTypeis CHECKED as a cheap shape guard — the arm is UNREACHABLE today (drift.tsresolves the provider by(resourceType, provisionedBy)and passes that same type back, and this provider is registered for one type), and an earlier draft citing the #1784 CC-API-fallback caveat as the reason was wrong: that caveat is about the bag SHAPE for a type with noreadCurrentStateand never produces a foreign type, and the function is pure / non-mutating / identity-returning when inapplicable so an unaffected table pays nothing. The readback half ships with it:readCurrentStatenow reverse-maps the per-indexWarmThroughputto the CFn shape, dropping the AWS-managedStatusmember the CFnWarmThroughputTypehas no concept of — a table that DECLARES warm throughput drifted permanently against its own declaration on that member alone, incdkd driftand in--revert'supdate()previous side (NOT incdkd diff, which comparesstate.propertiesand never consults the readback). Dropping a key from a readback would ordinarily strand every already-writtenobservedPropertiesrecord (the #1760 lesson); it does not here precisely because the canonicalizer removes the WHOLE member from both sides, which is why the two halves must ship together and the code says so. ACCEPTED COST, recorded in-code rather than filed: a template that DECLARES a per-indexWarmThroughputno longer has changes to it REPORTED bycdkd drift. The value is still SENT (toSdkGlobalSecondaryIndexesforwards it), so this is a detection gap and not a delivery one, and it is bounded by what cdkd could do about the difference anyway — warm throughput only ever GROWS on the AWS side and cannot be lowered, so--revertwould issue a decrease AWS rejects (the residual issue #1768 records for the sibling type's declared arm). A per-index declared-gate is not expressible in any case: the hook sees ONE bag, with nosideargument and no reference to the desired side, which is the symmetry #1784 requires. Twelve new unit cases — ten on the canonicalizer, two pinning BOTH polarities of the readback shape (a reportedWarmThroughputloses itsStatus; an index AWS reports none for gets no key at all, since an always-emit{}placeholder would be a fresh one-sided difference) — each paired with a discrimination case that must NOT change: the stale-baseline transition and the template-only baseline are both asserted through the REALcalculateResourceDrift(not by eyeballing the two bags) with a negative control proving the clean result comes from the hook; a real index deletion stays visible; identity is asserted by reference for an unaffected bag, a bag with no index list and another resource type; a non-array index list and a non-object element are shape-guarded; non-mutation is asserted on the caller's bag; andgetDriftUnknownPathsis pinned as NOT declaring the subtree. Both source halves were mutation-probed in isolation (neutralizing the canonicalizer fails 4 cases, restoring the pre-fix readback line fails 1). Still open on #1742: the live test the issue calls load-bearing — a real-AWS run seeded with a hand-patched STALEobservedPropertiesbaseline, which a fresh-deploy fixture structurally cannot provide because both comparison sides come from the same readback — plus restoring therollback-replay-effective-propscapacity table's GSI, which that fixture drops today to work around this defect. - ✅ The
onUnusablewarning is a terminated SENTENCE and describes the READ, so it no longer runs together with — or contradicts — the caller's appended clause (issue #1735) —src/provisioning/config-shape.ts,tests/unit/provisioning/config-shape.test.ts. Deploying anAWS::DynamoDB::Tablewhose template carriesBillingMode: ''against a livePAY_PER_REQUESTtable logged, verbatim:... check for an unresolved intrinsic or a mis-nested template value. Ignoring it and using the default (PROVISIONED) here; the same value is REFUSED on a template-path create The mode this update compared against (PAY_PER_REQUEST) is kept .... Two defects, both in the SEAM between the shared helper's message and the caller's appended clause, so neither belonged to one provider. (1) No sentence separator: everyonUnusablemessage ended bare while every caller composes as${message} <their sentence>, so the two ran together at exactly...template-path create The mode.... All fouronUnusablemessages (readConfigString's container arm,requireConfigString,requireConfigArray,requireConfigObject) now end with a period; thethrowarms deliberately do NOT, since nothing composes onto those. (2) The two halves contradicted each other: the helper said "Ignoring it and using the default (PROVISIONED)" while the caller's clause said the compared mode (PAY_PER_REQUEST) "is kept" — both true of DIFFERENT things (what the HELPER returned vs what the provider SENDS), but read as one sentence the user could not tell which value reached AWS, which is the only question the warning exists to answer.requireConfigStringnow describes its own READ ("This read falls back to the default (PROVISIONED)") and leaves the outcome entirely to the caller;readConfigString's container arm takes the same re-scoping. For a caller that appends NOTHING the read IS the outcome, so those sites lose nothing.requireConfigArray/requireConfigObjecttake (1) but NOT (2): they returnundefinedand every caller SKIPS, so "Leaving this configuration unapplied" is what actually happens on all of them and no caller's clause contradicts it. Fixed in the HELPER, not at the callers, which is what makes it durable AND what made it shippable:.claude/rules/providers.mdprescribes exactly such a per-site clause ("keep the PREVIOUS value" / "SKIP the block" / "SUPPRESS the diff"), so the appending set GROWS, and a future appender now inherits the fix instead of repeating the bug — while the two contended appending files (dynamodb-table-provider.ts,dynamodb-globaltable-provider.ts, both owned by in-flight lanes) needed no edit at all. Why no test caught it: every assertion in this family usesstringContaining, so none of them ever rendered the COMPOSED sentence — a live real-AWS run was what surfaced it. The suite now carries a composition block that asserts the joined string directly, table-driven over all four guards (terminal punctuation, nocreate [A-Z]run-together signature, and the no-contradiction case reproducing the reported shape verbatim), plus a pin that the throw arm stays unterminated so a future "consistency" sweep does not punctuate messages that have no composition problem. Mutation-probed against the reverted wording: 10 assertions fail across all four guards and both defects. Full suite green (12431 tests), confirming no other suite depended on the changed wording. - ✅ Route 53 HostedZone
NameServerskeeps its CloudFormation list shape through Output resolution —src/provisioning/providers/route53-provider.ts,src/deployment/intrinsic-function-resolver.ts,tests/unit/{provisioning/route53-provider,deployment/intrinsic-functions}.test.ts,tests/integration/route53/**. Before: all three SDK-provider paths that exposeAWS::Route53::HostedZone.NameServers— create, update, and on-demandgetAttribute— joined the delegation set into one comma-delimited string before recording it in state. CloudFormation defines this attribute as a LIST, so the common CDK patternFn::Join(',', Fn::GetAtt(..., 'NameServers'))received a string, failed Output resolution with “Fn::Join's second argument must be a list”, and silently left that Output absent even though the hosted zone itself deployed successfully. Now: the provider preserves the AWS SDK array in every path, soFn::GetAttsupplies the list shape thatFn::Joinrequires. Existing stacks do not need recreation or a manual state edit: the intrinsic resolver recognizes the old Route 53-only comma-delimited state shape at its read boundary and converts it back to a list, including the empty-string edge, without a schema-version bump. Unit tests cover create, update, on-demand enrichment, the current array state, and the legacy string state. The Route 53 live fixture joins the Output and compares its sorted values with the realGetHostedZonedelegation set; the 2026-08-13 run passed with 8 resources created, 8 deleted, 0 errors, and 0 orphans. A separate 19-resource broad deploy/destroy also passed because the compatibility read sits in the shared intrinsic resolver. - ✅
AWS::Glue::Databaseresource links and Lake Formation default permissions reach AWS (issue #1807) —src/provisioning/providers/glue-provider.ts,scripts/gen-nested-key-coverage.ts,docs/_generated/nested-key-coverage.{json,md},.claude/rules/providers.md,tests/unit/provisioning/glue-database-input-members.test.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,tests/integration/glue-update-hardening/. The bug:GlueProvider.buildDatabaseInputis a FRESH-OBJECT builder (const result: DatabaseInput = { Name }) that named onlyDescription/LocationUri/Parameters, while both the CFn schema and@aws-sdk/client-glue'sDatabaseInputalso declareTargetDatabase,FederatedDatabaseandCreateTableDefaultPermissions— andgrep -rnfor those three spellings undersrc/returned ZERO hits. So a database declared as a RESOURCE LINK to another catalog, a FEDERATED database, or one carrying Lake Formation default table permissions deployed "successfully" as a plain empty database: no error, no warning, and nocdkd driftfinding either, becausereadCurrentStatedid not surface them. Eleven nested paths were affected. Why nothing caught it: the CFn and SDK spellings AGREE, so the nested-key critic's key pass classified every pathsame-spellingand stayed silent — this is the #1432 "membership does not guarantee delivery" class, visible only to the WRITE-EVIDENCE pass. That pass was deliberately left OFF for this type when the Glue targets were registered (#1393 item 3), with the 11 paths pinned BY NAME inGLUE_DATABASE_DROPPED_PATHSplus a calibration test rather than silenced by 11 allow-list entries — an allow entry would have muted a CI-blocking bucket for a LIVE drop, which that critic's design refuses. The pin was written so that fixing the provider FAILS it, forcing the opt-in into the same change; this PR is that change, so the list and its test are gone andAWS::Glue::Databasenow carriesfreshObjectMapper: true(15 audited paths, allsame-spelling, 0 blocking findings). The fix names every member INDIVIDUALLY rather than casting the three blocks through verbatim, which the siblingTableInput.TargetTable/ViewDefinitionforwards do. A cast would typecheck and deliver correctly, but it leaves the 8 CHILD paths reportingno-write-evidence— on a fresh-object target only a per-member write is delivery proof — so the opt-in would have been impossible, and the next member AWS adds would vanish silently instead of failing the critic.readDatabasereverse-maps the same three blocks EMIT-WHEN-PRESENT (unlike the always-emittedDescription/Parametersplaceholders): AWS reports a Lake Formation DEFAULTCreateTableDefaultPermissionsofIAM_ALLOWED_PRINCIPALS/ALLfor a database that declared none, so a placeholder would have to invent one of two shapes and be wrong on the other population — and an AWS-only key is ignored by the drift comparator, which descends only into keys present in the baseline. Wire behavior was PROBED, not assumed (us-east-1, 2026-08-13): a same-account resource link needs no Lake Formation onboarding; AWS REFUSESDescriptionandTargetDatabasetogether (Description and resource link cannot exist together in a database!), which is why the integ arm puts them on separate databases; an undeclared database reads backCreateTableDefaultPermissions: [{IAM_ALLOWED_PRINCIPALS, [ALL]}], which is what makes a declared[SELECT]distinguishable from a silent drop; andUpdateDatabasere-sendingTargetDatabaseround-trips unchanged. The three blocks are SHAPE-GUARDED, which the first cut of this change was not (found by all three PR reviewers): a malformedTargetDatabasepassed every member read asundefinedand cdkd SENT an empty{}block, a non-arrayCreateTableDefaultPermissionsthrew a rawTypeErrorout of.map, and a plain-object-but-unreadable block (an unresolved intrinsic, a bare{}) shipped empty too. The blocks now take the repo's standard split — REFUSE on a template-path create (downgraded throughreplayWarnon a reverse-replacement replay, which neededCreateContextthreading intocreateDatabase), WARN plus RETAIN THE PREVIOUS block on update, becauseUpdateDatabasereplacesDatabaseInputwholesale so omitting it would ERASE a live resource link (the #1612 UPDATE row reached through a whole-blob API), and DROP the key when both sides are unusable.CreateTableDefaultPermissionsis validated ALL-OR-NOTHING rather than per entry: it is one wholesale call, so dropping unreadable entries would silently NARROW a Lake Formation grant list. The readback ALSO needed drift scoping, which the same review caught: on theobservedPropertiesbaseline the comparator walks the key UNION, so surfacing AWS's Lake Formation default would have reported drift on EVERY already-deployed Glue database until an unrelated update ordrift --accept—getDriftUnknownPathsnow scopesDatabaseInput.CreateTableDefaultPermissionsout PER RESOURCE for a database whose template declares none (the ELBv2Targetsshape; an explicit empty list IS a declaration and stays compared). A related reviewer concern did NOT reproduce and is recorded in-code: AWS refuses a database carrying both a description and aTargetDatabase, but the refusal is on a NON-EMPTY description — the always-emittedDescription: ''placeholder is accepted alongside a link on both create and update (measured us-east-1, 2026-08-13), so thedrift --revertround-trip through the readback is safe. A SECOND review round then found the guard itself incomplete, in the direction that mutates: the readable-but-empty case (a bare{}/ an unresolved{Ref}) is a plain OBJECT, so the shape guard accepted it and a?? previousfallback never fired — the block built empty andUpdateDatabasedropped a link the template still declared, which is exactly what the retain arm exists to prevent. The block's SOURCE is now picked before it is built (pickBlockSource): desired-when-it-names-a-sendable-member, else previous, else nothing — one ladder for the wrong-shape and readable-but-empty cases alike. "Sendable" means a STRING or a NUMBER, which also closes the leaf hole the same round found (an intrinsic-valuedDatabaseNamerode an unchecked cast onto the wire) and makes an unquoted-YAML numericCatalogIdreach AWS as the string the SDK wants. APrincipalnaming no sendable identifier is the same defect one level in and invalidates its entry. The drop-both-unusable warning now says what a drop MEANS on a wholesale-replace API (a RESET to the account default), andgetDriftUnknownPathstreats an UNREADABLE properties bag as "declares no block" for this one key — the usual default is to compare, and this key inverts it because AWS always materializes the default, so an older import carrying only the top-levelDatabaseNamewould otherwise report drift on every run with no way to clear it. A THIRD round then closed a silent NARROWING the second had introduced: the sendable-member test usedsome, so ONE literal member licensed the whole block and an intrinsic-valued sibling was dropped with no message —TargetDatabase: {DatabaseName, CatalogId: {Ref}}shipped withoutCatalogId, silently retargeting a cross-account resource link at the caller's own catalog, and the guard's strictness depended on siblings (the sameCatalogIdalone WAS refused). A block is now usable only when EVERY declared member present is sendable. The same round alignedisSendableLeafwithcatalogIdForApion non-finite numbers, coerced a numericDataLakePrincipalIdentifierlike every other leaf, and gave the refusal message an action clause naming what happens next on a wholesale-replace update. Tests: 51 unit cases (each a WHOLE-PAYLOADtoEqual/toStrictEqualon theDatabaseInputthe SDK command carries, because the regression is a member silently MISSING — atoMatchObjector per-key probe passes against exactly the shape the bug produced), covering create / update / readCurrentState, partial blocks, omission-when-absent, template-side removal, a readback-to-update()round-trip, every malformed shape on both paths, the replay downgrade, the retain-previous and drop-both-unusable arms, an explicit empty permission list, and the per-resource drift scoping. The critic's own fence is INVERTED from the old pin (empty finding set + the 11 paths named), and the fix was mutation-probed through the shipped--check's--providers-dir=seam: with the three write blocks stripped from a scratch copy of the real provider it exits 1 naming exactly those 11 paths, and 0 with them present. Live coverage:glue-update-hardeninggains a resource-link database (TargetDatabasepointing at the fixture's existing table database) and a second database carryingCreateTableDefaultPermissions, asserted on BOTH the create and the UPDATE phase — the update phase flips the granted permission set, sinceUpdateDatabaseREPLACESDatabaseInputwholesale and a regression would ERASE the block from a live database rather than merely never send it — plus post-destroy gone-probes for both. - ✅
cdkd deployconsumes the{ outcome: 'skipped' }delete outcome its five call sites used to discard (issues #1762 / #1803) — newsrc/deployment/delete-outcome.ts,src/deployment/{deploy-engine,rollback-executor}.ts,src/cli/commands/{deploy,destroy-runner}.ts,src/provisioning/composite-id.ts,src/provisioning/providers/{iam-policy,iam-user-group,custom-resource,lambda-layer,lambda-permission}-provider.ts,tests/unit/deployment/{deploy-engine-delete-skipped,rollback-executor-delete-skipped}.test.ts,docs/{cli-reference,deployment-events,provider-development}.md,.claude/rules/providers.md. Issue #1752 gaveResourceProvider.deletea'skipped'outcome meaning "this row was NOT destroyed and may still be alive" and taughtcdkd destroyto report it; every other call site discarded the value, so the same skip printed✓ … deleted, counted as deleted and — in the deploy engine's template-DELETE branch — DROPPED the state record, leaving the user with neither the AWS resource gone nor an id pointing at it. Each of the five sites now handles it the way its own situation allows, which is the whole design decision: the template-removal DELETE warns, prints⚠ <id> (<type>) skipped (<reason>), KEEPS the record, counts a newDeployResult.deleteSkipped(rendered asSkipped (not deleted)only when non-zero) and emitsRESOURCE_SKIPPEDinstead ofRESOURCE_SUCCEEDED— but still exits 0, because the kept record means the resource is still a pending DELETE and the next deploy re-attempts it, wherecdkd destroyhas no next run and therefore exits 2; the three replacement deletes (--replacedelete-first,--recreate-via-{cc-api,sdk-provider}, the UPDATE-not-supported fallback) FAIL the resource, since their create would otherwise run beside a live old one or collide with its name; the create-first cleanup delete warns, matching the policy that site already had for a delete FAILURE (the new resource is created and recorded, so the old one is untracked either way, and failing would roll back a replacement that worked); and all five rollback-executor arms throw into their existing per-op accounting — at four of them that means afailurescount withROLLBACK_RESOURCE_FAILEDand a KEPT journal segment rather than a silent successful revert, while the fifth (deleting the NEW resource AFTER the old one was re-created) lands in that site's own pre-existing catch as awarningscount, because the revert itself succeeded and only the new resource leaks — the same outcome that arm already gives a FAILED delete, so the skip inherits its policy rather than inventing a stricter one. Two consequences beyond the reporting: a skipped DELETE is no longer pushed tocompletedOperations, because journaling a delete that never happened would makecdkd rollbackre-CREATE a live resource; and the skip is checked OUTSIDE each site'scatch, so a providerreasoncan never be read by the already-deleted message classifiers (not found/does not exist/ …) those blocks run — reading a skip as "already gone" is the exact mis-accounting being removed.ProvisionCounts.deleteSkippedis deliberately separate from the existingskipped, which counts UPDATEs that resolved to no change and feedsunchanged. Thirteen unit cases across the two new files, mutation-probed by neuteringdeleteSkipReason(9 of 13 went red; the 4void/{ outcome: 'deleted' }controls correctly stayed green). Shipped with #1803's one-line correction in the same PR since both land in the skip-accounting family:destroy-runner.ts'sskippedCountJSDoc said "Two producers today" and had gone stale three times (once per lane that added a producer), so it now points atResourceDeleteResult's own enumeration as the single source of truth instead of restating it. The same staleness was fixed in the five providers'DEPLOY_SKIP_CAVEATstrings and incompositeIdFormatMessage, all of which warned users that the deploy side "DROPs the record and reports success" — true when they were written, false as of this change. Superseded 2026-08-19 by issue #1960: the exit-0 half of this entry no longer holds — such a run now exits 2 (the state record is still kept, which was the other half and is unchanged). See the 2026-08-19 entry. - ✅ The FOURTH region -> URL-suffix derivation — cdk-local's — is now behind a canonicalizing boundary (issue #1814) —
src/local/intrinsic-image.ts,tests/unit/local/intrinsic-image.test.ts. Residual of the #1795 region-case fix, found by that PR's code review. #1795 folds the region insidederivePartitionAndUrlSuffix(src/utils/aws-partition.ts) so every cdkd-OWNED caller inherits ONE normalization point, butderivePseudoParametersFromRegionis not cdkd's — it arrives through thesrc/local/intrinsic-image.tsshim as a bare re-export fromcdk-local/internal, and cdk-local carries its own partition table whose prefix tests are case-sensitive in the same direction. So theFn::JoinCode.ImageUripath (issue #637) still synthesized<acct>.dkr.ecr.CN-NORTH-1.amazonaws.com/...— acn-region carrying the commercial suffix, a host that does not exist — on the five call sites, across three files, that reach the symbol:src/cli/commands/local-start-api.ts,src/cli/commands/local-invoke-agentcore.ts(x3) andsrc/local/lambda-resolver.ts. The shim's re-export of that ONE symbol becomes a thin BOUNDARY WRAPPER (the shapedocker-image-builder.tsalready uses for its error-class boundary) that runs the region throughcanonicalizeRegionbefore delegating;substituteImagePlaceholders/tryResolveImageFnJoinstay bare re-exports. This is the FALLBACK direction the issue names — the preferred fix is upstream, and the wrapper stays correct if cdk-local later canonicalizes too, because the fold is idempotent. The INPUT is canonicalized rather than the result post-processed, because the returnedregionis substituted as${AWS::Region}into every ARN the resolver builds, so a raw value would misspell those too and not just the derived suffix;undefinedstill passes through so upstream keeps owning the missing-region verdict. Unit tests pin each non-commercial partition cdk-local knows (cn-/us-gov-/us-iso-/us-isob-) as case-invariant across the upper-cased AND mixed-case spellings a--regionflag can carry, with every commercial answer asserted byte-identical to today's so the change is safe without a non-commercial account, plus a BINDING PROOF that each of the five call sites reaches the symbol through the shim rather than importing it straight fromcdk-local/internal— a site doing the latter would silently keep the raw-region behavior while every other assertion still passed. That predicate is itself proven against a synthetic import block, so it cannot pass vacuously. Probing the two tables against each other while writing those tests surfaced a SEPARATE divergence, filed as issue #1821 and deliberately not fixed here: cdk-local's table predates the three rows cdkd's #1764 added, sous-isof-/eu-isoe-/eusc-regions resolve COMMERCIAL even when spelled canonically. That is a table-COVERAGE gap, orthogonal to case and unfixable by canonicalization; it is pinned by its ownit.eachcase so it fails loudly the day upstream gains the rows instead of being re-discovered. The PR's own code review then found the sibling half in the same file:cdkd local invoke-agentcorewas the one region-takingcdkd localcommand the #1795 sweep skipped, so while this change fixed its pseudo-parameter path, its RAW--regionstill reached three consumers that are case-SENSITIVE in the same direction the partition table is — the SigV4 signing SCOPE (where--regionhas the highest precedence over the credential chain's region), theSTSClientendpoint resolution, andapplyRoleArnIfSet. It now folds at the handler entry like its three siblings, andtests/unit/cli/local-region-case.test.ts's source-level pin (the fold is not reachable from a unit test) gains it as a fourth row — verified binding by reverting the statement and watching exactly that row fail. - ✅ Seven provider ARN builders derive their partition instead of hardcoding
arn:aws:(issue #1815, PARTIAL) —src/provisioning/providers/{cloudwatch-alarm,servicediscovery,sqs-queue,logs-loggroup,s3-directory-bucket,apigateway,budgets-budget}-provider.tsplus their unit tests. Same class as issues #1730 / #1745 / #1794 and quiet for the same reason: an ARN built with the wrong partition is structurally VALID, so nothing downstream rejects it — it is simply recorded into state and served as the resource'sFn::GetAttanswer. Each builder now routes throughderivePartitionAndUrlSuffix(region).partition(src/utils/aws-partition.ts), the same closed mapping${AWS::Partition}uses. Two of the seven are load-bearing beyond the recorded string: the API Gateway stage ARN is passed straight toTagResource/UntagResourceasresourceArn, and the S3 Express directory-bucket ARN is BOTH an attribute and the S3 Control tagResourceArn— so outside the commercial partition those tag mutations named a resource that does not exist. Where no region is in hand the answer is derived from the best source available rather than guessed.budgets-budget-provider.tshas no region SEGMENT but still carries a partition, sobudgetArnbecame async and readsgetClient().config.region()— the RESOLVED region of the very client that receives the ARN, the same source itsdelete()path already consults forassertRegionMatch. Deriving fromproviderRegion(process.env['AWS_REGION']) instead was wrong on the PRIMARY path, not a fallback: when that env var is unsetgetClient()buildsnew BudgetsClient({})and the SDK resolves the region from its OWN chain (AWS_DEFAULT_REGION, the~/.aws/configprofile), so a profile-configuredcn-north-1/us-gov-*caller derived the COMMERCIAL partition from an empty string while the client talked to a non-commercial endpoint. The three placeholder arms (logs/sqsunknown:unknown, the cloudwatch wildcard-region*:*) readAWS_REGIONas a BEST-EFFORT hint — those arms are dominated by an STS failure, so the env var is neither the client's authoritative region nor the first source in the try arm's own chain — and it derives to the commercialawswhen unset or unrecognized, so commercial output is byte-identical everywhere the old output was right. The cloudwatch wildcard arm is additionally near-DEAD in production (a real SDK region provider resolves FROMAWS_REGION, soconfig.region()throwing while the env var is set is a state the runtime does not normally produce); it is noted as such in-code so the pinning test is not mistaken for evidence of a live non-commercial path. Per the issue #1745 / #1794 convention each site gets acn-north-1AND aus-gov-west-1case asserting thearn:aws-cn:/arn:aws-us-gov:output, PAIRED with a commercial case asserting the result is byte-identical to the pre-fix literal — that pairing is what makes the change provably non-breaking without a non-commercial account. Known bound recorded in-code and pre-existing rather than introduced here:s3-directory-bucket-provider.tsderives the ARN's region (and now partition) froms3Clientwhile the tag calls consuming it go throughs3ControlClient, built fromproviderRegion— the two can disagree, and the region segment already carried that exposure. The issue stays OPEN: this is the file-disjoint subset one lane could claim. Still residual —glue-provider.ts:2141/:2709,lambda-eventsource-provider.ts:68-74(partition-aware but hand-enumerated, so it works in China and not in GovCloud), and both partition-sensitivestartsWithpredicates, which change BEHAVIOR rather than a recorded string and are the half the issue flags as the user-visible one:custom-resource-provider.ts:732misroutes an SNS-backed custom resource as Lambda-backed, andiam-managed-policy-provider.ts:501reads an AWS-managed policy as customer-managed. - ✅ The
DenyExternalAccessbucket policy names the caller's partition (issue #1794, partial) —src/utils/deny-external-access-policy.ts(new),src/cli/commands/bootstrap.ts,src/cli/commands/state-migrate.ts,src/assets/asset-storage.ts,.claude/rules/code-layout.md,tests/unit/utils/deny-external-access-policy.test.ts+ per-site cases in the three call sites' suites. All three buckets cdkd owns — the state bucket (cdkd bootstrap), thecdkd state migratedestination bucket, and the asset bucket (ensureAssetStorage) — are hardened with aDenyExternalAccessstatement denyings3:*to any principal whoseaws:PrincipalAccountis not the owner. ItsResourceARNs hardcoded theawspartition, so onaws-cn/aws-us-govthe statement named a resource that DOES NOT EXIST there: it matched nothing and the deny protected nothing. Nothing downstream could catch it — the document stays structurally valid,PutBucketPolicysucceeds, and each command still prints✓ Set bucket policy (deny external access)over a bucket with no effective deny at all. The three documents were byte-identical copies (which is how the literal drifted in all three at once), so the fix centralizes them in ONE helper that derives the partition viaderivePartitionAndUrlSuffix(region).partition— a fourth copy cannot re-introduce the class. Every call site derives the partition from the CLIENT that writes the policy (await client.config.region()), never from a localregionvariable — one rule, no per-site judgement about which value is authoritative. The first cut of this fix passedbootstrap'sregionvariable on the argument that AWS credentials are partition-scoped so it must agree; the 3-axis PR review (spec + code reviewers independently) showed that argument is sound about credentials-vs-bucket but does not describe the code:regionisoptions.region || AWS_REGION || 'us-east-1', a HARDCODED commercial fallback, whileAwsClientsomitsregionentirely when--regionis absent and lets the SDK chain read the profile's region andAWS_DEFAULT_REGION. So a GovCloud / China user with a non-commercial profile region, noAWS_REGIONand no--regionreproduced #1794's exact failure mode ON THE FIX'S OWN PATH. That regression is pinned by a dedicated unit case (mutation-probed: it fails against the reverted derivation). The underlying variable divergence ALSO mis-names the asset bucket / ECR repo / bootstrap-marker key, which is a rename-and-migration question rather than a bug fix and is filed separately as issue #1820. Commercial output is unchanged BYTE-FOR-BYTE, pinned by a literal-document test rather than by rebuilding the expectation from the same helper, since every already-deployed cdkd bucket carries exactly that policy. Every partition assertion was mutation-probed against the pre-fix hardcoded literal (9 failures across the helper and all three call sites; every commercial counter-case still passed). Remediation for an EXISTING non-commercial bucket:bootstrapskips the policy PUT for a bucket that already exists unless--forceis passed, so anaws-cn/aws-us-govstate bucket bootstrapped by an earlier cdkd keeps its inert deny untilcdkd bootstrap --forceis re-run (it rewrites versioning / encryption / policy and does not touch existing state). Partial by design: the issue's fourth site,CloudControlProvider'sAWS::S3::BucketArnenrichment, is NOT in this change —src/provisioning/cloud-control-provider.tsis owned by the in-flight #1778 lane and the repo's one-lane-per-file rule applies — so #1794 stays open with that site as its remaining scope. The wider residual class the audit surfaced (~10 provider ARN builders plus two partition-sensitivestartsWithpredicates) is issue #1815. - ✅ Region / host CASE no longer routes
cdkd local *at a host that does not exist (issues #1795 / #1801) —src/utils/aws-partition.ts,src/local/ecr-puller.ts,src/cli/commands/local-{run-task,invoke,start-api}.ts,tests/unit/utils/aws-partition.test.ts,tests/unit/local/ecr-puller.test.ts,tests/unit/cli/local-region-case.test.ts. Two halves of ONE case seam, found by the code review of the #1786 PR and MEASURED rather than reasoned about. (a) region CASE is canonicalized, in both of the two places it has to be. The sharedderivePartitionAndUrlSuffix(src/utils/aws-partition.ts) folds its region through the new exportedcanonicalizeRegionbefore thePARTITION_TABLEprefix walk, which is a case-SENSITIVEstartsWith— so--region CN-NORTH-1fell through to the COMMERCIAL partition and the threecdkd localcommands synthesized<acct>.dkr.ecr.CN-NORTH-1.amazonaws.com/<repo>:<tag>, acn-region carrying the commercial suffix. Doing that in the shared helper keeps ONE normalization point for the derived SUFFIX, so every present and future caller inherits it and the three call sites cannot drift apart again. But the suffix is only HALF the defect, and the review of this PR MEASURED the other half: the AWS SDK's own endpoint resolution is case-sensitive in exactly the same way — against this repo's vendored@aws-sdk/util-endpointspartition data,cn-north-1resolvesaws-cn/amazonaws.com.cnwhileCN-NORTH-1resolvesaws/amazonaws.com(both the exact-match table and theregionRegexfallback are case-sensitive) — so every SDK client built from the raw region talked to the WRONG partition's endpoint:sts.CN-NORTH-1.amazonaws.comfor the${AWS::AccountId}lookup (which then failed, dropping the account id with a warn and leaving the imageFn::Subunresolved), plus the ECR / SecretsManager / SSM clients further down thecdkd local run-taskpath; and${AWS::Region}itself was substituted verbatim into every ARN. So each of the three commands ALSO folds the region VALUE, at its pseudo-parameter resolution point (covering all four sources —--region, both env vars, and the synth- or state-derived region) AND once at the handler entry, sooptions.regionis canonical for every consumer those files hand it to. Double-folding is a no-op, which is what makes having it in both layers safe rather than redundant.parseEcrRegistryHost(src/utils/ecr-uri.ts, owned by a concurrent lane) is deliberately untouched: it does not fold case today, which is exactly whyparseEcrUricanonicalizes BEFORE calling it, and if that lane later adds its own fold this one becomes a no-op rather than a conflict — that boundary is where an untrusted, DNS-shaped string from a template or a state record enters cdkd, a different trust question from a CLI flag. (b)ecr-pullerlogs in to and pulls from the SAME host. It authenticated against the lower-cased endpoint (authData.proxyEndpoint, or the derived fallback) while runningdocker pullagainst the RAWimageUri, and docker's credential store is keyed on the hostname VERBATIM — measured with a temporaryDOCKER_CONFIGholding auth for<acct>.dkr.ecr.us-east-1.amazonaws.com, the lower-cased reference sent credentials (denied: Your Authorization Token is invalid) while theUS-EAST-1spelling sent NONE (no basic auth credentials), anddocker pullwas independently confirmed to preserve host casing verbatim in the request URL.canonicalizeImageUriHostnow folds ONLY the registry HOST andparseEcrUrireturns it ascanonicalUri, whichdocker pull/docker image inspect/ the returned run-reference all use. The repository path and tag are deliberately NOT folded — only the domain is case-insensitive, and rewriting the path would change WHICH image is pulled. "Everything before the first/" is deliberately NOT the rule either: docker treats component 1 as a registry only when it contains a.or a:or equalslocalhost, soMyOrg/MyRepo:tagis a Docker Hub repository PATH and folding it would name a DIFFERENT image — the component is tested rather than assumed. An AWS-reportedproxyEndpointstill WINS over the derived login endpoint (it can legitimately be a VPC-endpoint host, and AWS reports it lower-cased), so the invariant is that login and pull agree on the DERIVED fallback; both arms are covered. The caller region is folded here too, which closes the same class one level down: the two module-level STS caches are KEYED on it, soUS-EAST-1andus-east-1were separate entries paying a duplicateGetCallerIdentity/AssumeRole— exactly the cost those caches exist to avoid — and the STS / AssumeRole clients themselves resolved the commercial endpoint. The same change compares canonical forms on both sides of the cross-region check, so--region US-EAST-1against a lower-case host stops logging a spuriousCross-region ECR pullline. Not a regression in either half: before #1786 a mixed-case host was rejected outright and the image was pulled anonymously, so the user-visible outcome (the private pull fails) was the same and only the error differed — what these fix is that #1786's benefit was INCOMPLETE. Unit tests pin (a) per CALL SITE three ways — the suffix, the region VALUE, and the RESOLVED imageFn::Subthose two compose into (the assertion that would have caught a fix covering only the suffix), plus a source-level pin on the handler-entry fold whose SDK consumers a unit test cannot reach — and (b) that the login endpoint and the pull reference name the same host for a mixed-case input, on the derived-fallback arm AND the production-dominantproxyEndpointarm, with the VPC-endpoint precedence pinned alongside so the invariant cannot be read as "the login host is always the pull host". Every assertion is paired with an all-lower-case / commercial counter-case asserting byte-identical behavior, and each was verified to FAIL with its own source change reverted in isolation. - ✅ A suppressed GlobalTable billing flip stops recording capacity AWS never received, and an ABSENT recorded mode now consults AWS (issues #1738 / #1733) —
src/provisioning/providers/dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-suppressed-flip-capacity.test.ts,tests/unit/provisioning/dynamodb-billing-mode-junk-previous.test.ts. #1738: when an unusable desiredBillingModesuppresses the flip (the issue #1683 arm 3 warn-and-KEEP path), the capacity call belonging to the OTHER mode never fires either — every one of those emissions is billing-mode-gated — while the effective bag still recorded whatever capacity blocks the template declared.retainUnsendableCapacityMembersnow decides the split per member against the KEPT mode, at all four container levels (top-level, per-GSI, per-replica, per-replica-index): with the kept modePROVISIONEDthe on-demand half is unsendable and is replaced by the PREVIOUS record's value while the provisioned half stays as declared (step 4b / 6b's auto-scaling reconcile and step 6's GSI diff really do deliver it); withPAY_PER_REQUESTthe mirror. That is the.claude/rules/providers.mdUPDATE row — retain the PREVIOUS value rather than the replay-CREATE DROP #1726 takes on the create side, because the table already exists and dropping the key would leave a later template that REMOVES the block deriving no removal. The previous side is VALIDATED throughasRecord(the same predicate every wire read of these blocks applies) before it is retained, per the #1653 review rule, and the key is DROPPED when the previous side is unusable OR absent; entries are matched byIndexName/Region, never positionally. NocanonicalizeDesiredPropertiestwin — this is a SKIP, so folding the desired side would derive a REMOVAL of a live capacity block from a template whose only fault isBillingMode. #1733: an ABSENT recordedBillingModeresolved to the create-path defaultPAY_PER_REQUESTWITHOUT consulting AWS (#1552 scoped its live-read fallback to the present-but-unusable shape), so a record with no mode — left bycdkd importof a PROVISIONED table, or by this arm's own DROP — made a correctedPAY_PER_REQUESTtemplate compare EQUAL, issue noUpdateTable, and silently lose the flip, withcdkd driftunable to report it either. The live-read fallback is widened to "absent OR unusable", under one deliberate narrowing: the read is consulted only when the DESIRED side DECLARESBillingMode, so a template that legitimately OMITS the property never consults AWS and cannot flip an imported PROVISIONED table to on-demand. That gate is not new —AWS::DynamoDB::Tablehas resolved an absent previous exactly that way all along, so GlobalTable CONVERGED onto its sibling rather than diverging from it. ABillingModeSummaryAWS does not report resolves to PROVISIONED, the same readingliveBillingModeand both providers'readCurrentStatealready take: DynamoDB omits the summary for a table created without an explicit mode, and such a table IS provisioned. The first cut resolved it to this provider's create-path default instead, and review caught that this left the fix INERT on its own headline population, while ALSO re-opening the #1552 same-modeUpdateTablefrom the other side (a record with no mode against aPROVISIONEDtemplate compared PAY_PER_REQUEST vs PROVISIONED and flipped a table that was already provisioned). With the alignment the seed cannot re-introduce that rejection at all: the flip is gated onoldBilling !== newBillingand a baseline resolved fromDescribeTableIS the mode AWS holds. The seeded branch also REMOVES a spurious call — aPROVISIONEDtemplate on an already-PROVISIONED table whose record carried no mode used to read as a real flip. Both halves are live-covered by newdynamodb-globaltablefixture steps: 13g asserts the kept-PROVISIONED retention (a declared on-demand ceiling raised on the suppressed-flip deploy must record the PREVIOUS value, with the live table carrying no ceiling at all), and a newBillingSeedTabledrives 13j (state record patched to removeBillingMode, corrected template must APPLY the flip) and 13k (kept-PAY_PER_REQUEST must retain the previous replica read capacity). The run also MEASURED whatBillingModeSummaryreports for an explicitly-PROVISIONED create, which answers issue #1733's open question (a): AWS reports none even when the mode was declared explicitly. So the no-summary population is every provisioned table rather than only an imported one, and the PROVISIONED inference is what carries the common case — logged rather than asserted, since the code is correct under either answer. - ✅ The CFn schema fixtures now capture each definition's
requiredlist, so a required-ness-dependent parity verdict can be fenced (issue #1800) —scripts/refresh-cfn-schemas.mjs(+ its.d.mts), all 134tests/fixtures/cfn-schemas/*.json,tests/unit/provisioning/ecs-deployment-configuration-subfield.test.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,.claude/rules/code-layout.md,docs/provider-development.md. The fixtures capturedproperties/readOnlyProperties/createOnlyProperties/primaryIdentifier/nestedProperties/nestedPropertyPaths/definitionShapes— but notrequired, which the live registry schema does carry. Why that mattered: the #1225 classification ofAWS::ECS::Service.DeploymentConfigurationconcluded that cdkd's verbatim pass-through is CloudFormation parity at every depth, and the depth-2 arm of that verdict rests ENTIRELY on required-ness — a keptDeploymentCircuitBreakermissingRollbackhas itsrollbackREPLACED true -> false byUpdateService(measured live), which would be a real silent drop except that the shape is unreachable from a valid template because CFn refuses it (Model validation failed (... required key [Rollback] not found)). So the day AWS relaxes that list the depth-2 replace becomes reachable and the parity verdict silently becomes wrong, with no test failing. The PR closing #1225 item 1 tried to fence exactly this and could not: with norequiredin the fixture the best available assertion was that the member paths are still MODELLED, which stays true under a relaxed list — so that test stated its own limitation in-code and carried aguard the guardassertion designed to RED the day the fixture started carryingrequired. What shipped:extractDefinitionRequiredcaptures the per-definition list (top-level under the same reserved#topkeydefinitionShapesuses; a definition with no list, or an empty one, gets NO entry, since the absence IS the fact and emitting[]for the ~90% in that state would bloat every fixture for no signal — consumers read a missing key as "nothing required here" and the SECTION's presence as the capture marker), emitted with the same omit-when-empty rule as its two siblings so a type requiring nothing anywhere keeps its prior byte shape. All 134 fixtures were re-captured live (us-east-1, 2026-08-13). The guard fired as designed and the ECS test now asserts the ACTUAL lists —DeploymentCircuitBreaker[Enable, Rollback],DeploymentAlarms[AlarmNames, Enable, Rollback],DeploymentLifecycleHook[LifecycleStages] — with exacttoEqual, since a member DROPPED from a required list is precisely the relaxation being fenced andtoContaincannot see a removal. It also asserts the ABSENCE of a list onLinearConfiguration/CanaryConfiguration, which is what SCOPES the parity verdict rather than over-claiming it and keeps issue #1806 visible: those two are fully optional, so a kept-but-partial block there IS reachable from a template CFn accepts — strictly worse than #1802, which at least has a CFn-side refusal to point at. A non-vacuity guard pins that the section exists and is populated, so a capture regression fails there rather than silently downgrading the test to the shape it replaced. The re-capture also defused a fence exactly as that fence's own comment predicted: thedefinitionShapesloud-failure probe picked its stand-in DYNAMICALLY from fixtures predating the shape capture, and once every fixture had one it had nothing to select and failed with its own "no pre-shape-capture fixture left to probe with" message. Its comment prescribed adding a fixture-dir seam; one already existed (loadReport's fourth parameter, added by #1464 for the siblingnestedPropertyPathsfence), so the probe now builds its own partially captured tree and is immune to the next re-capture — the property the dynamic pick was reaching for and could not actually hold. Three unit tests cover the extractor (sorted lists,#top, the omit-when-empty branch, non-string members, a non-arrayrequired). One side effect of the re-capture was measured rather than assumed, because it is theproperty backfill flips CC routeclass in reverse: a fixture that has been stale since 2026-05-16 gains whatever AWS has added since, and a NEW top-level property with no provider wiring becomes asilentDropentry — which does not reject the deploy but AUTO-ROUTES the resource through Cloud Control (the #614 rule). Nine such entries appeared (AWS::CloudWatch::Alarm.EvaluationWindow,AWS::Cognito::UserPool.{IssuerConfiguration,KeyConfiguration},AWS::EC2::Route.OdbNetworkArn,AWS::EC2::VPC.VpcEncryptionControl,AWS::ElasticLoadBalancingV2::Listener.Tags,AWS::Neptune::DBCluster.{GlobalClusterIdentifier,NetworkType},AWS::WAFv2::WebACL.MonetizationConfig), andListener.Tagslooked like a live route flip for thealb/alb-advancedfixtures, both of which apply stack-levelcdk.Tags.of(this). It is not: every one of the nine is absent from itsaws-cdk-lib2.244.0 L1 props interface (checked per entry, not reasoned about), so they are CFn-schema-only properties ahead of CDK support and no CDK app can emit one — the same disposition the pre-existing hand-writtenEvaluationIntervalentry records. A hand-authored L1 usingaddPropertyOverridecan still reach them, and the CC auto-route is the correct answer there. The FULL suite — not the targeted runs, which were green — then surfaced three more consequences of the re-capture, all of them the coverage critics working as designed. (a) Those nine properties are unaccounted, so each gets an entry intests/fixtures/cfn-schemas/_todo-backfill.json, the ledger built for exactly this case, regenerated via the documentedCDKD_GENERATE_BACKFILL=true(umbrella campaign: #609). (b) That regeneration ALSO cleaned a pile of STALE entries —AWS::ApiGateway::Stageentirely, plusLaunchConfigurationName/NotificationConfiguration/AvailabilityZone/ElasticGpuSpecifications/ElasticInferenceAccelerators/EvaluationCriteria/EvaluationIntervaland others. Those are NOT schema removals (checked per property — every one is still in the re-captured fixture): they are properties the providers already account for viahandledProperties/unhandledByDesign, which the ledger kept listing because retiring an entry is the manual third step its own comment describes, and nobody took it. (c) TWO REAL latent bugs: AWS added an ARN read-only attribute toAWS::RDS::DBSubnetGroup(DBSubnetGroupArn) andAWS::SSM::Parameter(Arn), neither provider caches it, and since the resolver reads the cachedattributes[<CFnName>]rather than callinggetAttribute, an outputFn::GetAtton either HARD-FAILS the*Arnshape guard — the #1179 class. Both are recorded asKNOWN GAPentries inSDK_ATTR_ALLOW_LISTpointing at issue #1824 (the same dispositionAWS::Lambda::EventSourceMapping.EventSourceMappingArnhad under #1190), so the critic is green while the gaps stay VISIBLE and deleting each entry is what verifies the eventual fix; fixing them here would mean editingsrc/provisioning/providers/**, which activates theinteg-destroygate and drags a real-AWS integ run behind a no-behavior-change capture PR. Review then surfaced two more, both of which the re-capture itself caused. (d) TheAWS::SNS::Subscriptionentry inSDK_ATTR_ALLOW_LISTis now provably INERT and is RETIRED: its fixture predated the #1694primaryIdentifiercapture, soArnused to reach the allow-list, and once the re-capture gave the fixtureprimaryIdentifier: ['Arn']the classifier filters that attribute out BEFORE consulting the list — which is exactly the "auto-classify those as not-a-gap instead of requiring a hand-written entry per type" behaviorextractPrimaryIdentifierexists for. It was found by a fence added in this PR, not by reading:classifyTypetestscachedKeysbefore the allow-list, so a fixed or superseded entry silently goes inert and nothing flags it (there is nofindStaleAllowListEntrieshere the way there is ingen-nested-key-coverage.ts). The new test asserts every allow-listed attribute still classifiesallow-listed, which is what makes the "DELETE this entry when it is fixed" note on the two #1824 entries enforceable rather than aspirational — an in-code claim this PR made and had to be corrected on, since the first draft asserted the staleness detection already existed. (e) The summary saidgap: 0for two types whoseFn::GetAtthard-fails, because a NOT-A-BUG entry and a tracked real gap shared one bucket;AllowListEntrygained aknownGapflag and the summary reports those separately, so the headline stays honest while CI stays green. Two contract claims this PR wrote were also wrong and are corrected:definitionRequired's absence does NOT mark a fixture as un-captured (seven types legitimately require nothing anywhere and carry no section —generatedAtis the discriminator), and the capture sees only a definition's OWN top-levelrequiredarray, so required-ness expressed through aoneOfcombinator (AWS::S3::Bucket'sTargetObjectKeyFormat) or on an inline nested object (AWS::WAFv2::WebACL'sFieldToMatch.SingleHeader) is absent — for those a missing entry means UNKNOWN, not permissive, which is the exact false verdict the capture exists to prevent. Both bounds are now pinned by tests. Otherwise no behavior change: no runtime code reads the newdefinitionRequiredsection, and no CLI flag, dependency, or state-schema change. - ✅
cdkd exportstops aborting on a dual-stack VPC, and an array-valued identifier property no longer reaches CloudFormation as-is (issues #1788 and #1787) —src/cli/commands/export.ts,.claude/rules/code-layout.md,tests/unit/cli/export-identifier-overlay.test.ts. Both halves were split out of #1771, which registered the splitters it named but deliberately left the shared overlay rule alone. #1788:AWS::EC2::VPCCidrBlockhas a COMPOSITEprimaryIdentifierand had noCOMPOSITE_ID_SPLITTERSentry, andcdkd exportis all-or-nothing, so its mere presence aborted the WHOLE command withadd an entry to COMPOSITE_ID_SPLITTERS— while any VPC carrying a secondary IPv4 CIDR or an Amazon-provided IPv6 CIDR declares one, including every CDKVpcconfigured withipProtocol: IpProtocol.DUAL_STACK. Every schema fact behind the entry is a liveDescribeTypemeasurement (us-east-1, 2026-08-13), not the issue's table: identifier[Id, VpcId], withIdread-only, so the overlay narrows toVpcId— the same narrowing, for the same reason, as theAWS::EC2::VPCGatewayAttachment/AWS::EC2::Routesiblings. No SDK provider registers the type, so it is always Cloud-Control-routed and the physicalId is the primaryIdentifier joined in schema order (<Id>|<VpcId>). Both segments start withvpc-, but the association id carries the longer, distinctvpc-cidr-assoc-prefix, so the two ARE discriminable and the splitter shape-binds BOTH (the way theAWS::EC2::EIPentry bindseipalloc-; validating one side only is not a discriminator). It also accepts the BARE<Id>form, recoveringVpcIdfrom recorded properties, because CloudFormation reports this type'sPhysicalResourceIdas the association id alone — the shape a--migrate-from-cloudformationstack carries. #1787:overlayResourceIdentifierOnPropertiesonly overwrote a field the template carried as a literal STRING. That is right for the two cases it was designed for — an absent key, and aRef/Fn::GetAttintrinsic that must be preserved (#319) — and wrong for a third, reachable case: a field the template carries as a non-string LITERAL. The measured instance isAWS::S3Tables::Namespace, whose registry type is{"type": "string"}and whose CDK L1 emits a string, butaddPropertyOverride('Namespace', ['analytics'])synthesizes"Namespace": ["analytics"]— which cdkd deploys, becauses3-tables-provider.tsaccepts both wire shapes. The array was not a literal string, so the overlay left it alone and it reachedCreateChangeSet, where CFn answered with an opaque rejection. The rule is now keyed on a four-way classification (classifyOverlayCurrentValue): absent and intrinsic are unchanged, any LITERAL — string, number, boolean, or an array of those — is overwritten with the scalar identifier, and a list carrying an intrinsic / a nested list / an empty list is REFUSED with a message naming the resource, the property and the scalar to declare instead. Overwriting is not a guess: the overlay value comes from the cdkd-recorded physicalId, which is the authority on what the resource IS, while the template side is only what CDK happened to synthesize — the same reasoning the pre-existing prefixed-name literal-mismatch case already rests on. Refusing is the one honest answer left, and the message distinguishes WHY per shape rather than asserting a single reason for all three: preserving any of them reproduces the opaque rejection this change removes, but only a list actually carrying an object element has an intrinsic to discard — an empty list has no value to reconcile against the identifier, and a nested one simply is not representable as the declared scalar. 12 unit tests drive BOTH overlay call sites (filterTemplateForImportandapplyImportOverlayForPhase2— a refusal on only one of the two would let the same template through on the other path) and assert phase-1 / phase-2 agreement, since a divergence there makes CFn see a property change between the IMPORT'd state and the UPDATE template and silently REPLACE. Binding-proved: restoring the pre-#1787 preserve-the-array arm reds 5 of the 12. No CLI flag, dependency, or state-schema change. - ✅ The state-bucket region probe reaches the caller's partition (issue #1763) —
src/utils/aws-region-resolver.ts,.claude/rules/code-layout.md,tests/unit/utils/aws-region-resolver.test.ts.resolveBucketRegionbuilt itsGetBucketLocationprobe client with a HARDCODEDregion: 'us-east-1'and swallowed every failure into a commercial default, so outside the commercial partition the probe could not reach the bucket at all and every consumer of the shared state-bucket resolver — the S3 state backend, the lock manager, the exports index store, the custom-resource response path, andupload-cfn-template'sTemplateURL(which is why the #1758 URL-suffix fix was inert rather than wrong there) — proceeded against the wrong region. The probe endpoint is now taken from, in order: a newopts.region(the caller's own region),opts.fallbackRegion, the AWS SDK's own region chain, and only thenus-east-1. The cross-region question was MEASURED, not assumed (2026-08-13, real eu-west-1 bucket): SDK v3GetBucketLocationresolves a bucket in ANY region from us-east-1 / us-west-2 / ap-northeast-1 / eu-west-1 clients alike, so the probe never needs to know the answer to ask the question and moving off the global endpoint costs nothing — it only has to reach the right PARTITION. No call-site threading was needed, and the issue's premise that "no caller passesfallbackRegion" was wrong:rebuildClientForBucketRegionalready passes it, sourced from the client's ownconfig.region(), which covers the whole state-bucket family; anddeploy/destroy/export/import/orphaneach assign--regionintoprocess.env.AWS_REGIONbefore any work, so the SDK chain coversuploadCfnTemplate's five call sites (two of which live inexport.ts, held by a parallel lane). A caller with no region configured anywhere still probesus-east-1, so commercial behavior is preserved rather than merely equivalent. Second half of the fix: only a SUCCESSFUL lookup is now cached, mirroringwrite-only-properties.tsand #1746'sgetAccountInfo— the failure answer is a GUESS, and caching it pinned every later caller in the process to one transient error's wrong region with no way to heal, while concurrent callers still collapse onto a single probe. Unit tests pair every non-commercial assertion with a commercial counter-case, and do NOT mock the resolver (the #1758 tests did, which is exactly why they could not see this). - ✅ Two drift DECLARATION seams a path could not express (issues #1783 / #1784) —
src/analyzer/drift-normalize.ts,src/types/resource.ts,src/cli/commands/drift.ts,docs/provider-development.md,tests/unit/analyzer/drift-normalize.test.ts,tests/unit/cli/drift.test.ts. Both were surfaced by #1767 as residuals it could not ship, and both are MECHANISM only — no provider declares either yet;AWS::DynamoDB::Table(#1767) andAWS::DynamoDB::GlobalTable(#1742) are the intended first consumers and are held by other lanes. (1) A LEAF-ONLY form for the provider path lists. Every entry was a SUBTREE declaration, and the unordered walk DESCENDS INTO ARRAY ELEMENTS giving each its parent's path — so declaring an unordered OBJECT list whose elements contain an order-SIGNIFICANT array of their own was impossible:'GlobalSecondaryIndexes'sorts the index list AND every per-indexKeySchema, where HASH must precede RANGE, so it would HIDE a real key change rather than remove a phantom one. That is why #1767 shipped the reverse-map half and left the ordering half undeclared, carrying phantom drift for theproperties-baseline population a reverse-replacement rollback leaves behind. AppendingLEAF_ONLY_PATH_SUFFIX([]) now claims the path ALONE:'GlobalSecondaryIndexes[]'sorts the list and stops. The marker is honored by BOTHgetDriftUnknownPathsandgetDriftUnorderedPathsrather than only by the list that needed it — they share ONE matcher precisely so their spellings cannot diverge — and a bare'[]'matches NOTHING, since it would otherwise name the root bag. (2)ResourceProvider.canonicalizeDriftProperties(resourceType, properties), applied bydrift.tsto BOTH comparison sides as the last normalization pass. This is the seam for a difference no PATH can name — a member of an ARRAY ELEMENT:calculateResourceDriftcompares arrays wholesale viadeepEqualand never descends, soisIgnoredPathis never asked about a path crossing one and the only expressible suppression is the WHOLE array. Using it for #1767's residual would have meant never detecting an out-of-band index add / remove / capacity change again, permanently, to clear a one-time report — a trade that was refused. Stripping the AWS-managed member from BOTH bags converges an already-writtenobservedPropertiesrecord with a post-fix readback instead, with no ignore-path and no lost detection. It takes NOsideargument on purpose (one-sided normalization is the mistakedrift-normalize.ts's header already records — it manufactures drift on theproperties-fallback baseline, whose baseline is the user's raw template), must be pure / synchronous / non-mutating / identity-returning when inapplicable, and is applied to the COMPARISON copies only, so theawsbag--acceptwrites to state and--revertdiffs against is untouched. Both seams are pinned by command-path tests that driverunDriftForStack(not the comparator directly) plus negative controls proving the clean result comes from the declaration being threaded through: the SUBTREE spelling is shown HIDING theKeySchemareorder the leaf-only form keeps visible, and a real index deletion is shown surviving the canonicalizer. - ✅
cdkd gc's URL-suffix list is now DERIVED from the partition table, so a new partition arm cannot silently outrun it (issue #1785) —src/cli/commands/gc.ts,tests/unit/cli/gc.test.ts.AWS_URL_SUFFIXESwas a hand-written literal maintained as a SUPERSET ofderivePartitionAndUrlSuffix's arms by convention only, and the test meant to fence it iterated a hand-written region list — so a new arm inPARTITION_TABLEredded nothing and gc would go on treating that partition's assets as UNREFERENCED, the irreversible direction. The list is now built fromPARTITION_TABLE's suffixes plus a declaredGC_EXTRA_URL_SUFFIXESseam (empty today), so an addition propagates in the same commit. The seam is kept rather than dropped because coupling makes gc FOLLOW the table, and gc must be able to LEAD it: a partition cdkd has state for but no derive-table row yet, and — the direction coupling introduces — a suffix EDITED or removed from the table, which would otherwise silently stop gc matching state already written with the old spelling. The unit test now probes one synthesized region per EXPORTED table row (so a new arm arrives on its own, with a count floor so an empty table cannot pass vacuously) and separately pins a hand-written FLOOR of the seven suffixes cdkd has already recorded, which is what makes a removal red. Verified by severing the coupling on a scratch copy: both new tests fail namingeusc-probe-1 (amazonaws.eu). Not in scope, and split out rather than assumed: the ECR host-form widening this issue also asked for had already shipped in the #1781 PR, and sharing those host forms withsrc/utils/ecr-uri.ts(which matches only the plaindkr.ecrform, so the two already disagree) needs that module's opposite strict-vs-loose acceptance rule preserved — tracked as issue #1793, which the in-code note now points at. - ✅ The four
AWS::S3::Buckethost attributes derive their URL suffix, and the resolver and provider halves can no longer disagree (issue #1745) — newsrc/utils/s3-endpoints.ts,src/deployment/intrinsic-function-resolver.ts,src/provisioning/providers/s3-bucket-provider.ts,tests/unit/utils/s3-endpoints.test.ts,tests/unit/deployment/s3-bucket-attribute-url-suffix.test.ts.DomainName/RegionalDomainName/DualStackDomainName/WebsiteURLwere templated in TWO places — the provider records them into state, the resolver answers a cross-resourceFn::GetAttstate has no cached value for — and both hardcoded the commercialamazonaws.com, so outside that partition every one emitted a host that does not resolve (structurally valid, so nothing downstream could catch it). Both sides now call one shared module, which is why this had to move together: fixing one half alone makes theFn::GetAttanswer disagree with whatreadCurrentStatereports, the phantom-drift shape.claude/rules/providers.mdwarns about. The provider's ownArnmoved toarn:${partition}:s3:::in the same object (the #1730 class, one line over).WebsiteURLwas NOT a mechanical suffix swap, which the issue asked to verify rather than splice: AWS serves the legacys3-website-<region>spelling for exactly NINE regions ands3-website.<region>for every other, so the separator is per REGION and cannot be derived from the partition —us-gov-west-1takes the hyphen while itsus-gov-east-1sibling takes the dot. The set was read out ofaws-cdk-lib'sregion-infoS3_STATIC_WEBSITE_ENDPOINTfacts (the AWS-authored table CDK itself resolves a website endpoint from) rather than from prose, and an unknown region falls back to the dot form, matching CDK. That corrects the answer for non-legacy COMMERCIAL regions too — aneu-central-1bucket'sWebsiteURLused to name a host AWS does not serve. The resolver additionally gained aDualStackDomainNamecase: it had none, so the unknown-attribute fallback served the bucket NAME where a hostname was requested, disagreeing with the provider's own record. Fenced against the same AWS-authored table (every known region's endpoint and suffix must equal what the helper builds, with a count floor), plus commercial counter-cases asserting byte-identical output — which is what makes the change safe to ship without a non-commercial account. Residuals, filed not assumed.arn:aws:is still hardcoded in the CC-provider's S3Arnenrichment (so the two ROUTES disagree for the same bucket) and in three self-authored IAM policies — issue #1794. AndDomainNameis the one attribute a suffix swap cannot fix: it is S3's partition-GLOBAL host, and that endpoint exists only in the commercial partition (s3.amazonaws.com.cnis NXDOMAIN; GovCloud's…s3.amazonaws.comresolves to COMMERCIAL S3). cdkd emits the spellingaws-cdk-lib's ownBucket.fromBucketAttributesuses rather than inventing the regional form, because a guess would diverge from whatever CloudFormation returns there — recorded in-code, pinned by a test that says so, and tracked as issue #1809. Note also that these values live instate.attributesand refresh only on the bucket's next create/update, so an existing non-legacy-region stack keeps its oldWebsiteURLuntil then. - ✅ ECS
DeploymentConfigurationsub-field + whole-block removal measured: top-level parity, depth-2 a permissive divergence — no behavior change (issue #1225 item 1, closing the last actionable row) —src/provisioning/providers/ecs-provider.ts(comment only),tests/unit/provisioning/ecs-deployment-configuration-subfield.test.ts,docs/provider-development.md§2a. Why it was open: this was the one row #1227's first pass classified as a REAL silent drop —UpdateServicemerges at the sub-field level, so a template keepingDeploymentConfigurationwhile droppingMinimumHealthyPercentretains the live value — and the issue prescribed implementing BOTH the kept-partial normalization and the #1160 whole-block reset together, blocked on deployment-type-dependent defaults (REPLICA 200/100, DAEMON min 0) and the circuit-breaker / alarms clear shapes. The measurement inverted that direction (live A/B, us-east-1, 2026-08-13, SDK and CloudFormation against the same service, every probe torn down): the merge is real — a kept block carrying onlyMaximumPercent: 150leftMinimumHealthyPercentat 50 and the circuit breaker enabled — but CloudFormation renders the same partial block and reached the IDENTICAL end state on a realUPDATE_IN_PROGRESS->UPDATE_COMPLETE, so the retained value is CFn's behavior and normalizing it would DIVERGE. The whole-block removal answered the same way: CFn issued a genuine resource UPDATE and did not reset the configuration, so cdkd'sundefinedis correct and the #1160 reset this field was holding open must NOT be added. One level DOWN the answer flips, and that row is NOT parity — the review of this PR corrected an earlier draft that said it was. A keptDeploymentCircuitBreakermissingRollbackhas the nested struct REPLACED rather than merged, and the SDK ACCEPTS it (a liverollbackwent true -> false), while CloudFormation refuses the same template up front (Model validation failed (#/DeploymentConfiguration/DeploymentCircuitBreaker: required key [Rollback] not found),UPDATE_ROLLBACK_COMPLETEwith the live config intact) because the registry schema marks theDeploymentCircuitBreakerdefinition required [Enable, Rollback] andDeploymentAlarms(theAlarmsproperty) required [AlarmNames, Rollback, Enable]. cdkd enforces nested required-ness NOWHERE —property-coverage.tsis top-level-only andmutually-exclusive-properties.tshandles combinations, neither reads a definition'srequiredlist — so cdkd deploys a template CFn rejects and silently flips the live setting: an accepted divergence in the PERMISSIVE direction, filed as #1802. It is deliberately not papered over by re-fillingRollbackfrom the previous side, which would invent a value the template never declared and would fix one property rather than the class. The ASGInstanceMaintenancePolicyanalogy does NOT apply (there AWS itself rejected the partial, so cdkd failed loudly too). What shipped: no behavior change — the deferral comment is replaced by the measured three-shape classification, and 9 unit tests pin the pass-through against the shape each REGRESSION would emit (a synthesized sub-field, a reset payload, a re-filledrollback, a fill-only-missing on the CREATE path at both levels, and theAlarmstwin). The accompanying schema check deliberately does NOT claim to fence a relaxedrequiredlist — the fixtures capture norequiredat all, so it asserts only that the members the verdict names are still modelled, states that limit in-code, and carries a guard that reds the day the fixture does start carryingrequired(#1800). Both pins are mutation-probed against the real provider: adding aclearOnUpdateRemovalreset reds the removal test, merging the previous block into the desired one reds two more. Remaining #1225 scope is now only item 2, ASGCapacityReservationSpecification, which needs a billed Capacity Reservation to probe. No CLI flag, dependency, or state-schema change. - ✅ Six delete-result discarders outside the deploy engine: the ASG delegation now propagates, and the four REPLACE paths stop swallowing a skipped delete (issue #1778) —
src/provisioning/cloud-control-provider.ts,src/provisioning/providers/{acm-certificate,iam-managed-policy,iam-role,sns-subscription}-provider.ts,docs/provider-development.md,.claude/rules/providers.md,tests/unit/provisioning/{replace-path-delete-skip-outcome,cloud-control-delete-result-propagation}.test.ts. These are LATENT-today hardenings — none of the five providers involved currently has a skip arm, so no shipped behavior changes until a skip arm reaches one of these five providers; the point is that the arms land into call sites that already handle the outcome, instead of into ones that drop it. Two classes. Delegation:CloudControlProvider.deletehands a protectedAWS::AutoScaling::AutoScalingGrouptoASGProvider.deleteunder--remove-protectionand used toawait+ barereturn, so a skip inside the delegate would have reached the destroy runner as a plain successful delete — the #1777 nested-stack hole one layer down. The contract chosen is PROPAGATE (matchingNestedStackProvider.delete'sskippedCount/interruptedforwarding) rather than ASSERT-it-cannot-skip, because an assertion needs re-verifying every time the delegate grows an arm and fails loudly on a case the delegate considers merely unaddressable; the delegate is typed asResourceProviderso the forwarding survivesASGProviderwidening its own return type. The provider'sdeletereturn type widened toPromise<void | ResourceDeleteResult>accordingly. The sibling site —cleanupFailedCreateRemnant's SELF-delete of a failed-create remnant — was confirmed uninteresting on the ACCOUNTING axis (a CREATE path, so nothing it returns reaches the destroy counters) and NOT on the MESSAGE axis: theRemoved failed-create remnantdebug line asserted the opposite of what a skip means, and a skip is exactly the outcome under which the name is still taken, so it now takes the same "a retry may fail with AlreadyExists" warning a failed cleanup takes. REPLACE paths: the fourupdate()implementations that paircreate()+delete()discarded the delete result, and because a skip does not throw it slipped past the verycatchwhose warning names the orphan risk. The ORDERING was re-verified per provider rather than trusted, and decides the remedy — create-then-delete (acm-certificate/iam-managed-policy/iam-role) cannot abort (the new resource exists;ResourceUpdateResulthas no skip channel), so each WARNS with the skip'sreasonin the same orphan wording its failure arm already used; delete-then-create (sns-subscription) ABORTS with aProvisioningErrorbefore creating the replacement, since continuing would leave two subscriptions on the topic delivering every message twice and that duplicate is exactly what the CREATE would add. The premise is stated as "the old resource was not destroyed" rather than "no AWS call was issued" —ResourceDeleteResult's contract explicitly warns against the second reading (NestedStackProviderreportsskippedafter a recursion that may already have deleted the child's other resources), and the abort holds under the weaker premise anyway. The abort was checked against everyupdate()caller (deploy-engine,drift --revert, the rollback executor's revert arms) — none is better served by a duplicate subscription — and is deliberately NOT symmetric with a THROWN delete, which still warns-and-continues, because a throw may mean the unsubscribe partially landed. Two mechanical consequences of the rollback caller, both covered by tests: the SNS check (only that one — the three create-then-delete providers keep their warn-only branch inside the existingtry) lives OUTSIDE thetry, sodelete()'s ownProvisioningErrorfailure path keeps its warn-and-continue semantics; and the thrown message interpolates NOTHING but the template logical id, because the rollback arms wrapupdate()inwithRetryandretryable-errors.tsclassifies by SUBSTRING — a provider-suppliedreasonor a state-bornephysicalIdcarryingdoes not exist/Rate exceeded/DependencyViolationwould have burned the whole backoff schedule before a certain failure, and the physicalId is the worse offender (cdkd import --resource <id>=<anything>writes it verbatim, and the only skip family reaching this path is literally "malformed physicalId in state"). Both are still reported — on the warn line, and the physicalId on theProvisioningError's structured field. Keeping values out of the message narrows that surface but cannot close it, since the match is a SUBSTRING rather than an equality — an ordinary composite logical id likeMyDependencyViolationSubcarries a retryable pattern (measured), and a message naming nothing is not diagnosable. Sosrc/deployment/retryable-errors.tsgained amarkNonRetryable/isMarkedNonRetryablepair (a non-enumerableSymbol.formarker, walked down the.causechain likeisThrottlingError) thatisRetryableTransientErrorconsults BEFORE any name or message heuristic, and the abort is marked: a cdkd-authored refusal is now terminal by DECLARATION. The fence is at the RETRY LOOP, not only in that one classifier —withRetryrethrows a marked error ABOVE itsopts.isRetryable ? ... : ...branch (the custom-classifier branch bypassed the marker entirely, and all four call sites that pass one —isRecreateRetryableError/isNameCooldownError, wired by the deploy engine's--replacedelete-first fallback and the rollback executor's reverse-replacement — are MESSAGE-only, so they could not see it even in principle), anddestroy-runner.ts's delete-retry loop gates its ownToo Many Requestsmessage test the same way (the marker gates BOTH arms rather than replacing either, so a genuine throttle still retries). Two one-line changes fence every caller in the tree with no classifier signature widened.markNonRetryableis narrowed toE extends Error(the reader is a prototype-chain lookup, so marking a shared prototype would mark every instance) and returns a non-extensible error UNMARKED rather than throwing — callers use it inline asthrow markNonRetryable(...), where aTypeErrorwould REPLACE the refusal it decorates. The remediation names the STATE record as well as AWS, since neither skip family shipping today (the state-borne composite-id arms,NestedStackProvider's propagation) is repaired by deleting the AWS resource alone, and it points at "the warning naming<logicalId>" rather than "the preceding warning", which is false at--concurrency > 1.logPendingConfirmationSkip's two CFn-parity delete-SUCCESS arms gained an in-code note NOT to convert them to skips — doing so would abort every deploy of aPendingConfirmation-adopted subscription with no flag to force it — plus a positive test pinning that UPDATE consequence. All six sites stay LATENT even after #1770 lands (that issue's eight arms are inlambda-layer/lambda-permission/custom-resource/iam-policy/iam-user-group, none of whichCloudControlProviderdelegates to or any of the four REPLACEupdate()s calls;AWS::IAM::Policyis a different type and file fromAWS::IAM::ManagedPolicy), so the reason to fix them is that the mechanism lands BEFORE any skip arm reaches these five providers. 30 unit tests: per site, a forced{ outcome: 'skipped' }asserting the new behavior, plus an INVERTED CONTROL where the delete succeeds and the replacement completes normally, plus a rollback-executor caller test driving the realreplayRollbackrevert arm and proving the abort surfaces after ONE attempt (benign and hostile reasons) and hostile-reason/ hostile-physicalIdregression rows proving the thrown message is not classified retryable; every site and every guard mutation-probed RED. - ✅ The eight non-composite-id warn-and-SKIP delete arms stop being counted as
deleted(issue #1770, residual of #1752) —src/provisioning/providers/{lambda-layer,lambda-permission,custom-resource,iam-policy,iam-user-group}-provider.ts,docs/provider-development.md,.claude/rules/providers.md,tests/unit/provisioning/provider-delete-skip-outcome.test.ts. #1752 gaveResourceProvider.deleteitsResourceDeleteResultreturn and converted the five malformed-composite-physicalId arms, but its own scope statement was wider — every warn-and-continue arm that issues NO AWS call has the same defect, and eight of them outside that family still returned barevoid, socdkd destroyprinted✓ <id> (<type>) deleted, counted them towardN deleted, DROPPED the state record and exited 0 over resources that may still be alive. All eight now return{ outcome: 'skipped', reason }, so the runner prints⚠ <id> (<type>) skipped (<reason>), counts a separateskipped, KEEPS the record and exits 2 — no runner change was needed. What survives each skip is named per site rather than genericized: a Lambda layer version stays published (both malformed-LayerVersionArnarms — a too-short ARN and an unparsable trailing version, kept as two reasons because they point at different halves of the id); aRemovePermissionstatement stays on the function's resource policy, i.e. an invoke grant outliving the stack; a Custom Resource's handler never sees aDeleterequest, so whatever it manages in a third-party API / another account is untouched (both the no-properties and the no-ServiceTokenarms); an inline IAM policy stays ATTACHED to its roles / groups / users, i.e. live permissions outliving the stack; and anAWS::IAM::UserToGroupAddition's memberships survive, so the users keep every permission the group grants. Eachreasonis an exported named constant beside its provider (deliberately NOT one shared literal likeCOMPOSITE_ID_SKIP_REASON, because the status line has to say WHICH half of the record is broken), pinned by a test asserting all eight are distinct, ≤ 64 chars, namestate/physicalId, and say what did NOT happen. The twoAWS::IAM::UserToGroupAdditionarms were the issue's explicit judgment call and are converted with the reasoning recorded in-code: they logged at DEBUG, which reads as "routine, nothing to do", butGroupNameandUsersare BOTH required by the CloudFormation schema, so a record missing either is CORRUPT rather than empty, and the create path issued realAddUserToGroupcalls — the level is now WARN to match, since a skip preserves state and exits non-zero and a normal-verbosity run has to say why. Their genuinely-routine neighbour is pinned by its own test: an EMPTYUsers: []is a VALID membership list, falls through the!usersguard (an array is truthy), does nothing and correctly reportsdeleted— collapsing the two would turn a legitimate no-op destroy into a non-zero exit. The*NotFoundidempotent arms in the same files are untouched, as isCustomResourceProvider's backing-Lambda-is-gone pre-check (issue #804) — those mean the resource IS gone, and the Custom Resource inverted control deliberately routes THROUGH that arm, so it doubles as a fence that it still reportsdeleted. Every arm carries a skip test (outcome + reason + no AWS call issued) and an inverted control with a well-formed input proving the normal delete path still runs; all 8 were mutation-probed by reverting the arm (all RED), and the 5 guards were separately probed as firing UNCONDITIONALLY to prove the inverted controls have teeth (all RED) — two of those probe anchors turned out to occur 2x / 3x in their file and were re-anchored multiline rather than patched blind. The 3-axis review then found two arms that should not have been skipping at all, and a skip is expensive precisely because it is not inert — it preserves the record, warns, and exits 2 on EVERY re-run, so the destroy can never go green.AWS::Lambda::Permissionread onlyproperties['FunctionName'], although the physicalId's documented<functionArn>|<statementId>shape — the very shape the code below it already splits for the statementId — carries the function ARN, andRemovePermissionaccepts a full ARN asFunctionName;AWS::IAM::Policyderived the name only from the physicalId, althoughPolicyNameis inhandledPropertiesandcreate()uses it VERBATIM as the real AWS name. Both now exhaust the second source first, ordered by what was DEPLOYED rather than by convenience — the physicalId wins over the property, because a template edit renaming the policy without a replacement having landed would otherwise send aDeleteRolePolicyfor a name AWS never had — and the Lambda fallback is gated onarn:+:function:so a non-Lambda ARN, or a statementId that merely contains|, is never sent as a function name.POLICY_NAME_SKIP_REASONchanged tono policy name in stateaccordingly, since the arm is now only reached once both sources are exhausted. Three wording defects from the same review: every warning promised "repair state.json and re-run", which is true only on DESTROY — the deploy / rollback callers DROP the record (#1762), so each message now carries the same caveatcompositeIdFormatMessagedoes; "LEFT IN PLACE" was asserted unconditionally and is FALSE for four arms when the parent is in the same stack (deleteGroup->removeAllUsersFromGroupanddeleteUser->removeUserFromAllGroupsremove exactly those memberships, a deleted function drops its whole resource policy, a deleted role drops its inline policies), so AWS ended clean while cdkd claimed an orphan and exited 2 forever — those four now qualify the claim and namecdkd state orphan <stack>, while the layer / Custom Resource arms deliberately do NOT, a false reassurance being worse than the warning it softens; and theUserToGroupAdditioncomment cited the wrong producer forUsers: []—createUserToGroupAdditionTHROWS on an empty list, so the reachable producer isupdateUserToGroupAddition, which removes every user and records[]. Theoutcome: 'skipped'member JSDoc insrc/types/resource.tsalso still read "no AWS call was issued", contradicting the block comment 20 lines above it that warns against exactly that reading (NestedStackProviderreportsskippedafter deleting the child's other resources). Review round tests: the fallback pair per arm (the fallback really deletes; the precedence is deployed-first), a negative control that a non-Lambda ARN is still refused, non-stringPolicyNameshapes (a number / an intrinsic / an array — the case an emptiness test cannot see, since''is falsy and the||chain rejects it anyway while a truthy non-string would reach the SDK verbatim), the#1762caveat on all eight arms, and the in-stack-parent qualifier on the four that carry it plus a control that the other four do not.warnContainswas also made arm-SPECIFIC, since seven arms shared the literalLEFT IN PLACEand a copy-pasted wrong remediation would have passed. Two review-round probes came back GREEN and were DIAGNOSED rather than accepted: an!== ''check in the IAM guard was genuinely redundant (the||chain already rejects an empty string) and was deleted, with the load-bearingtypeofhalf probed instead once the non-string test cases existed; and the in-stack-parent qualifier was unasserted, which is why the control tests above were added. A delta re-review then found the round-2 fallbacks could report a silentdeletedover a LIVE resource - worse than the skip they replaced - in three ways, all fixed. (1) Thetypeofhardening had been applied to the IAM guard but NOT to its Lambda sibling, so a truthy NON-stringFunctionName({ Ref: 'MyFn' },['my-fn'], a number) beat a perfectly good ARN: the SDK URI-encodes it,[object Object]comes backResourceNotFoundException, and the IDEMPOTENT arm then reports the statement DELETED - while an array coerces to a bare name nothing validated and the call can SUCCEED against the wrong function. An emptiness test cannot see either shape, since''is falsy and the||chain rejects it anyway. (2) The ARN fallback was adopted without checking its REGION: this provider holds ONE client, at the stack's region, so a cross-region ARN sentRemovePermissionto the wrong region, came backResourceNotFoundException, and was likewise reported DELETED while the real statement stayed live - cdkd has no client that could reach it, so the honest answer is the skip. (3) Thearn:requirement REFUSED a genuine second source: the CFn primary identifier is[FunctionName, Id]andFunctionNameis often a BARE name (src/cli/commands/export.tsdocuments exactly this), so the gate declined a real physicalId shape and put the arm back in the class #1770 exists to remove. Accepting it is safe because AWS'sStatementIdpattern is[a-zA-Z0-9-_.]+, which FORBIDS|- so a|anywhere in the physicalId can only ever be the composite separator, which also means the gate's original in-code justification ("a statementId that merely happens to contain a pipe") described an impossible input. A fourth arm was added on the way: a composite physicalId with an EMPTY trailing segment (<arn>|) used to reachRemovePermissionwith an empty HTTP label and HARD-ERROR where it used to skip, so it is now its own skip with its own reason (the function may be perfectly well named - pointing the user atFunctionNamewould be the wrong half of the record). The IAMPolicyNamefallback separately converted an honestskippedinto a silentdeleted: with the name now resolvable the guard passed, but an inline policy exists only as an ATTACHMENT, and a record naming noRoles/Groups/Usersand no legacy role segment reaches a body where every branch is skipped - ZERO AWS calls,return undefined, i.e. DELETED. The zero-call hole pre-dated the fallback (physicalId: 'MyPolicy'with empty properties already reached it), but the fallback ROUTED formerly-skipped records into it, so it is closed here rather than inherited: a newPOLICY_NO_TARGET_SKIP_REASONarm reports it. The guard deliberately uses the SAME truthiness spelling the legacy branch and the removal loops use rather than=== undefined- a null-valuedRoles, which a hand-edited or pre-v7 state file carries, would otherwise fall through into the very hole being closed - while a PRESENT-but-emptyRoles: []stays an honestdeleted(nothing is attached, so there is nothing to remove; the same judgment asUsers: []). Also fixed in the same round: the four in-stack-parent qualifier tests asserted only generic substrings (UNLESS,part of this stack), so a qualifier copy-pasted from the WRONG arm still passed - the exact weakness removed fromwarnContainsone round earlier; each now pins an arm-specific phrase PLUS its own message head. Two more probes came back GREEN and were diagnosed rather than accepted: the redundant!== ''check (deleted; the load-bearingtypeofhalf was probed instead once non-string cases existed) and the=== undefinedvs!rolesdistinction (which theRoles: nulltest now discriminates). Still out of scope and unchanged: the deploy-engine / rollback-executor callers discard the return value entirely (issue #1762). - ✅ The #1758 strict ECR host check stops being case-sensitive, so an upper-cased region can no longer bypass it (issue #1786) —
src/utils/ecr-uri.ts,tests/unit/utils/ecr-uri.test.ts.parseEcrRegistryHostcompares a registry host's captured suffix againstderivePartitionAndUrlSuffix(region).urlSuffixso a look-alike host cannot attract adocker loginby CASE, but both the region capture and the suffix comparison were case-SENSITIVE while DNS is not. (Stated precisely, and the precision took three drafts: the first overclaimed, the second described a partition table that no longer exists. #1764 shipped in PR #1790 while this branch was open, so on today'smainthe ISO-E / ISO-F suffixes ARE in the table and...us-isof-south-1.csp.hci.ic.gov/...resolves correctly. What this change guarantees is therefore exactly one thing -- the check is no longer bypassable by CASE -- and it remains true that any region whose partition the table does not yet carry is misclassified in BOTH directions, the look-alike accepted and the genuine host rejected. That is a partition-table gap, not a case gap.) Reproduced by execution before the fix, matching the issue's two probes exactly:123456789012.dkr.ecr.us-iso-east-1.amazonaws.com/r:twas REJECTED (correct — a commercial suffix on an ISO region is a look-alike) while123456789012.dkr.ecr.US-ISO-EAST-1.amazonaws.com/r:twas ACCEPTED, because upper-casing the region fails everystartsWithprefix test in the partition helper, falls through to the commercial partition, and that partition'samazonaws.comis exactly what the host carries. The measurement also found the INVERSE half the issue does not name, in the same run and the same fail-open/fail-closed pair: an upper-cased region or suffix on a GENUINE host stopped matching its own partition, soUS-ISO-EAST-1.c2s.ic.gov,us-east-1.AMAZONAWS.COMandCN-NORTH-1.amazonaws.com.cnwere all REJECTED — a real ECR registry silently classified as a user-managed image with nodocker login, i.e. the #1758 regression one layer up. Both halves now normalize at ONE boundary, a new module-privatematchEcrRegistryHostthat lower-cases the captured region, the captured suffix AND the derived expected suffix and returns all of them, so the two exported entry points share the comparison rather than each spelling it:looksLikeEcrHostWithForeignSuffixis documented as "exactly the case the parse rejects" and carried the identical defect, consistently — under an upper-cased region the parse accepted a look-alike and the predicate returnedfalse, soecs-task-resolver.tsdid not even log the #1764 foreign-suffix diagnostic. The normalization lives here rather than inderivePartitionAndUrlSuffix(src/utils/aws-partition.ts, deliberately untouched) because THIS is where an untrusted DNS-shaped string enters cdkd, and keeping it in ONE function is what stops the two entry points drifting apart. That is deliberately NOT the stronger claim that every other caller of the partition helper is already canonical -- the review measured that it is not:local-run-task.ts/local-invoke.ts/local-start-api.tsderive${AWS::URLSuffix}from the RAW user--region, so--region CN-NORTH-1synthesizes acn-host carrying the commercial suffix, which this module then correctly rejects as a look-alike. Both spellings are broken (pre-fix it was accepted and pointed adocker loginat a host that does not exist), and the repair belongs where the CLI accepts the flag -- filed as issue #1795 and SHIPPED, alongside #1801, by PR #1818 while this branch was open. That does not make these guards redundant, and the distinction is the load-bearing one: #1795 addedcanonicalizeRegioninsidederivePartitionAndUrlSuffixso the partition LOOKUP folds its own input, whereas these guards decide whether the captured segments are a region id and a URL suffix at all. Folding cannot answer that -- folding is exactly what turnsus-eKst-1into the plausibleus-ekst-1-- so the two compose rather than overlap, which a re-probe against the merged helper confirmed: removing the region guard now reds NINE cases rather than six. The derived suffix is lower-cased too — a no-op against today's all-lower-case table, kept so this stays the single answer if that table ever gains a mixed-case entry. The returnedregionis now the canonical lower-case spelling, which matters beyond the comparison:ecr-puller.tsuses it as an SDK client region andecs-task-resolver.tsas theregionof akind: 'ecr'image. Tests: 40 cases pinning the 2x2 the issue asks for — {genuine, look-alike} x {UPPER, mixed} region — plus the mixed-case SUFFIX arm (AMAZONAWS.COM,AmAzOnAwS.CoM, both halves upper at once), each asserted EQUAL to the lower-cased verdict rather than to a hand-written expectation, so a future partition-table change cannot make the arms agree on a wrong answer; one guard asserts the look-alike arms are genuinely REJECTED so the equality pairs cannot pass vacuously, the mirror guard asserts the GENUINE arms are genuinely ACCEPTED (their baseline lives in anotherdescribe, so a regression rejecting BOTH sides would otherwise satisfy them), and an upper-cased FOREIGN suffix stays rejected so case-folding cannot make an unrelated suffix pass. The review also found that case was only ONE way to defeat thestartsWithclassification -- any leading junk defeats it identically, and the captured segment does not stay inert (it becomes anECRClient({region})and is interpolated into the fallback login endpoint), measured as" us-iso-east-1"and"us-i̇so-east-1"parsing successfully -- so the boundary now also refuses a region segment that is not[A-Za-z0-9-](the class admits UPPER case -- that IS the fix -- and is tested against the RAW capture), with an inverted control pinning commercial / China / GovCloud / 2-digit-suffixed region ids (each against its OWN partition's suffix, since pairing them all withamazonaws.comwould rejectcn-north-1for an unrelated reason) and a third mutation probe reddening 6 arms. The 3-axis round then found the guard covered only HALF the problem: the SUFFIX is folded before comparison too, and #1790'saws-isoesuffixcloud.adc-e.ukCONTAINS ak, so...eu-isoe-west-1.cloud.adc-e.u<U+212A>/...folded onto it and was ACCEPTED -- unreachable at the branch tip, live the moment this branch rebased onto main. Both captures are now held to the same raw-capture rule, with its own probe and an inverted control asserting the real suffix still parses in any casing. Mutation-probed in FOUR cuts against verified-unique anchors -- reverting the region lower-case reds 8 of 39, the suffix lower-case reds 4, deleting the region shape guard reds 6, and moving that guard back AFTER the fold reds exactly the Kelvin-sign arm -- with the restore done by undoing the probe edit specifically, nevergit checkout. One finding audited and deliberately NOT ridden along: thedkr.ecrLABELS inECR_URI_HOST_REGEXare still matched case-sensitively, so<acct>.DKR.ECR.<region>.<suffix>/does not match at all (measured; falls through as a public image, and returnsfalsefrom the foreign-suffix predicate so not even the diagnostic fires). Its failure direction is the safe one -- NO credentials are sent on any of the four call sites, traced end to end:local run-taskandlocal invoke-agentcorefall through to an anonymousdocker pullthat fails, whilelocal invokeandlocal start-apiREFUSE before any pull -- and widening the shape MATCHER is a behavior expansion wanting its own decision rather than part of closing a bypass, so it is recorded in-code and filed as issue #1792. A second review round (3-axis, against the final head) found one real defect in the guard itself and two wrong statements. The guard was applied AFTERtoLowerCase(), andString.prototype.toLowerCaseperforms full Unicode case folding, so U+212A KELVIN SIGN folds to ASCIIkand...dkr.ecr.us-e\u212Ast-1.amazonaws.com/...was ACCEPTED as the regionus-ekst-1-- a region the host does not name, i.e. precisely the substitution the guard exists to refuse. It now tests the RAW capture against[A-Za-z0-9-]before folding, which keeps case-insensitivity while admitting only characters that fold to themselves. The header's claim thataws-partition.tswas unavailable because a concurrent lane owned it was STALE by the time it was written (PR #1790 merged 30 minutes earlier); the shape is unchanged and still correct, but the reason is now stated as what it actually is -- a CLI flag and a host string read out of a template or a state record are different trust questions, so this boundary keeps its own normalization regardless of what #1795 does. The review also measured, against a real docker daemon with a temporaryDOCKER_CONFIG, that docker's credential store is keyed on the hostname VERBATIM, soecr-puller.tslogging in to the lower-cased host while pulling the raw-cased URI means a mixed-case genuine host still cannot authenticate -- the classification is fixed, the pull is not. Not a regression (the pre-fix path rejected such a host outright and pulled anonymously, failing too) but an incomplete benefit, filed as issue #1801 because the repair lands insrc/local/**and carries its owninteg-localobligation. One deliberate WITHDRAWAL is now stated in-code and pinned: refusing a malformed region also withdraws the #1764 foreign-suffix diagnostic for such a host, which is correct rather than a loss -- that diagnostic's subject is "this suffix does not belong to its region's PARTITION", and a segment that is not a region id has no partition to belong to. No CLI flag, dependency, or state-schema change. - ✅
cdkd gcstops deleting referenced container images outside the commercial partition (issue #1781) —src/cli/commands/gc.ts,docs/cli-reference.md,tests/unit/cli/gc.test.ts. All three asset-reference matchers (virtual-hosted S3, path-style S3, ECR image URI) hardcoded the commercialamazonaws.comURL suffix, so a state file recordingamazonaws.com.cn/c2s.ic.gov/sc2s.sgov.govhosts matched NOTHING and its assets read as UNREFERENCED — the same hardcoded-suffix class as #1758 / #1745 but with the opposite blast radius (those emit a host that does not resolve; this one silently deletes). What it actually deleted was MEASURED, not assumed, and is narrower than the issue claims.CONTENT_HASH_KEY_REcollects<sha256>.<ext>tokens out of any string regardless of host, and cdkd's file assets are content-addressed with an extension, so S3 was already rescued by accident — a real--dry-runagainst a seededcn-north-1state file protected 71 of 72 objects including the referenced one with the matchers still broken. ECR is where the bug bites: cdkd's image tags are bare 64-hex with no.<ext>tail and digests aresha256:<hex>, so the content-hash pass cannot see either, and a referenced non-commercial image was listed for deletion. The three matchers now match the suffix against a closedAWS_URL_SUFFIXESSET — the union over EVERY partition, notderivePartitionAndUrlSuffix(region).urlSuffixfor the caller's region, because the scan reads every state file in the bucket written by any binary for any region, so a single derived literal would still delete acn-north-1stack's images during aus-east-1gc run. The list is deliberately a SUPERSET of the arms that helper knows (issue #1764): a suffix missing here deletes live assets, the irreversible direction, so it must LEAD that table rather than follow it —cloud.adc-e.uk/csp.hci.ic.gov/amazonaws.euare covered here today and the helper still lacks them. It stays a closed SET rather than a[^/\s]+wildcard so a look-alike host (https://<assetBucket>.s3.<region>.example.com/<key>, or one merely EMBEDDING a real suffix without ending in one) is not read as a cdkd asset reference — that direction only over-protects, but it would let any string naming the bucket pin an object forever and quietly turn gc into a no-op. Note this is a WEAKER check thansrc/utils/ecr-uri.ts, which captures the suffix and validates it against the region the host names; that pairing is right for one host with a caller-known region and wrong for a scan, where it would re-inherit #1764's missing arms on the irreversible side. The same change widens the ECR HOST forms toecr(?:-fips)?plus the short-form<acct>.dkr-ecr.<region>.on.awsalias, kept in a separateECR_REGISTRY_HOSTconstant precisely soon.awscannot leak into the two S3 matchers (pinned by its own test). Tests: 10 cases, every non-commercial assertion paired with a commercial counter-case asserting the unchanged collection (the #1758 convention), and the assertion that actually protects the user — an end-to-endcdkd gc --region cn-north-1run over a state file recordingamazonaws.com.cnhosts deletes ONLY the genuinely unreferenced object / image. Reverting the suffix set reds 8 of the 10 (the two negative-assertion cases correctly stay green — over-matching was never the pre-existing bug); narrowing the ECR host back to\.dkr\.ecr\.reds the FIPS case, and dropping the short-form arm reds the short-form case. One scope note recorded because the obvious stronger claim is NOT true: the test that derives a suffix per partition uses a HAND-WRITTEN region list, so it fences the suffix each listed region derives but NOT the arrival of a new arm in the derive table; coupling gc's list to that table needssrc/utils/aws-partition.tsto export the mapping and is tracked as (#1785). No CLI flag, dependency, or state-schema change. - ✅ A nested stack whose child FAILED to destroy now fails the parent's resource instead of reporting it deleted (issue #1777) —
src/provisioning/providers/nested-stack-provider.ts,src/provisioning/nested-stack-messages.ts(new),src/cli/commands/destroy-runner.ts,.claude/rules/providers.md,docs/{cli-reference,provider-development}.md,tests/unit/provisioning/nested-stack-provider.test.ts,tests/unit/cli/destroy-runner-nested-child-failure.test.ts,tests/unit/deployment/deploy-engine-nested-stack-delete-failure.test.ts,tests/integration/nested-stack/{verify.sh,README.md,lib/nested-stack-example.ts}.NestedStackProvider.deleteruns the child stack's destroy through a nestedDestroyRunner, andDestroyRunnerResultcarries THREE fields that say "this child stack is NOT gone". (#1752) / PR (#1774) propagated two of them —skippedCountandinterrupted— as{ outcome: 'skipped', reason }; the third,errorCount, was deliberately deferred because its correct answer is a THROW and that is a behavior change with its own blast radius. This is that change. Pre-fix, a child resource that genuinely FAILED to delete was swallowed: the parent printed✓ <Child> (AWS::CloudFormation::Stack) deleted, DROPPED the child's row, and — with the parent's ownerrorCountstill 0, sopreserveStateevaluated to FALSE — deleted the parent'sstate.jsonAND the exports index outright, exiting 0. The end state was worse than a dangling pointer: the child's ownstate.jsonsat there preserved describing live, billing resources, and nothing left named it, so recovery required knowing the<parent>~<child>key layout by hand. Nowdeletethrows, naming the child stack, the failure count, andcdkd state show <parent>~<child>as the file to open; the parent's nested-stack row FAILS like any other type's failed delete, so the row survives,state.jsonand the exports index survive, and the run exits 2 (PartialFailureError) — the same "state preserved, stack not destroyed" contracterrorCount > 0already carries everywhere else. This is a deliberate BEHAVIOR CHANGE and it reaches BOTH callers ofdelete:cdkd destroy/cdkd state destroyfail the row (exit 2), ANDcdkd deploy— removing a nested stack from the template routes the row through the deploy engine's DELETE path, so a child that fails to destroy now fails the DEPLOY and triggers its rollback where it previously recorded the row as deleted and carried on. That asymmetry is the reviewable part: unlike a{ outcome: 'skipped' }RETURN value, which the deploy-side sites still discard ((#1762)), a throw cannot be ignored by any caller — sodeploy-engine.tsgains this behavior with no edit to it. The review found a self-defeating gap and it is fixed here: the destroy runner'serrorCount > 0summary namedcdkd state orphan <parentStack>as the last resort, and following that advice DROPS theChildrow — the exact pointer the throw preserves. This PR is what first makes a nested-stack row reach that arm, so as first written it preserved the pointer and then told the user to destroy it.destroy-runner.tsnow collectsfailedStateTargetsalongside #1752'sskippedStateTargets, both through one sharedstateTargetFor(logicalId, resourceType)helper so the two cannot drift, and the error summary names the CHILD's state file (<parent>~<child>) for a nested-stack row and<stack>for an ordinary one, listing both when a run has each; astackNamefallback keeps the hint non-empty if a future path ever incrementserrorCountwithout recording a target. The re-review then found the mirror-image gap in the same line: a run carrying BOTH an error and a skip lands in the error arm (the skip-only arm is unreachable onceerrorCount > 0), so naming only the failed target printed, N skippedin the counters and silently dropped #1752's guidance for the skipped rows — whose remedy is different IN KIND, since a skip is not retryable until its state record is repaired. The arm now appends that guidance whenskippedStateTargetsis non-empty, and both arms build their hints through onehintForhelper. Two more review items shipped with it: the message builder moved OUT of the provider into a new dependency-free LEAF modulesrc/provisioning/nested-stack-messages.ts, because importing it from the provider draggeddestroy-runner→register-providers→ every provider into two unrelated unit tests and closed an import cycle (nested-stack-provider→destroy-runner→register-providers→nested-stack-provider) that is safe today only because nothing on the ring does module-scope work; and the fixture'sverify.shgained anassert_existsmirroringgone_probe's THREE-way outcome, since a wrappedif ! aws ...reports "state.json was DELETED" on ANY probe failure — a throttle or expired credentials would ACCUSE the fix of the very data loss it prevents. Three more decisions are deliberate. TheerrorCountcheck runs BEFORE the skip arm and wins over a concurrent skip / interrupt, naming both as context — an error means something was ATTEMPTED and FAILED, while a skip asserts no AWS call was issued, so reporting the run as a mere skip would be a lie in the other direction. The throw's wording avoids every already-deleted-shaped phrase (not found/does not exist/No policy found/NoSuchEntity/NotFoundException, plus the deploy engine'swas not found/ResourceNotFoundException), because both callers' catch blocks read those as idempotent success and DROP the state row — the exact outcome the throw exists to prevent; the union of both sets is pinned. And an ABSENTerrorCountfalls OPEN rather than throwing: the field is a required number the runner always initializes, so an absent one can only come from a partial / stubbed result, and keying a refusal on ABSENCE would turn every such input into a hard failure — the residual risk (a future refactor making the field optional restores the silent success) is pinned by a test rather than left implicit. Tests: the provider file gains the throw, the message content + the both-catch-sets wording fence, a FOUR-row table over the (skip, interrupt) combinations (with only the both-set and neither-set rows, SWAPPING the two clause bodies passed) each asserting the message contains no empty()(the only assertion that fences the conditional suffix), the absent-errorCountrow, the inverted control that a clean-but-skipped child still returns{ outcome: 'skipped' }, and a 3-LEVEL grandchild -> child -> parent chain (plus its clean control) driven by making the mocked runner RECURSE into the same provider and convert the throw intoerrorCountexactly as the real runner does — nesting depth was covered nowhere before, since the provider test mocks the runner, the runner test mocks the provider and the fixture is 2 levels. Two caller-side files pin what the throw BUYS: the destroy runner keeps theChildrow,state.jsonand the exports-index entries and names the child in its orphan hint (with an inverted control that an ordinary failure still names the stack, and that a CLEAN nested delete still drops the row and deletes both), and the deploy engine propagates the throw as its ordinaryFailed to delete resource <id>failure while keeping the record (with controls that an already-gone child IS still swallowed idempotently, and that a clean delete still drops the row). Both of those files are PROBE-INERT for the fix by construction — they drive a FAKE provider, so they pass against pre-#1777 code too — and their headers now say so instead of claiming to be regression tests for it; each builds its expected message from the newly exportednestedStackChildFailureMessagebuilder rather than a hand-copied literal, so wording drift into an already-deleted shape cannot leave a stale literal passing, and each fake provider setsdisableOuterRetry: truelike the real one so the harness exercises production's single-attempt path. Mutation-probed in SIX cuts against real source, each restored by hand: theerrorCountthrow neutered → 7 provider rows red;|| childResult.interruptedremoved → the interrupt row red;failedStateTargetsrecording the parent instead of the derived target → the two orphan-hint rows red; the throw's wording drifted into an already-deleted shape (appending(resource not found)) → 3 rows red across the provider AND the deploy-side file, which is what proves the two probe-inert caller files still fence something real; and the skipped-guidance clause made unconditional → its control row red, then dropped entirely → the both-kinds row red. Integ:tests/integration/nested-stack/gained averify.sh(it had none — the ledger records the test as modestandard, so "a clean child still reports deleted" was only ever an implicit consequence of that flow exiting 0; Phase C makes it an explicit assertion and the ledger's mode column flips toverify.sh). It provokes a genuinely failing child delete without touching the CDK app: the child's S3 bucket has noautoDeleteObjects, so cdkd's CloudFormation-parity data guard ((#1340)) refuses to delete it while it holds an object. Phase B PUTs one object and asserts the destroy exits 2, prints no✓ Child … deletedline, names the child stack, asserts the last-resort remedy names the CHILD state target (with a negative half asserting the PARENT form is absent — the orphan-hint fix was unit-fenced only until the re-review), and preserves BOTH state files plus the parent'sChildrow AND the child state's still-listed bucket row (the child file's mere existence is non-discriminating — the child runner preserved it pre-fix too); Phase C empties the bucket and re-destroys for the clean control. Scratch files go to a per-runmktemp -drather than fixed/tmppaths, so two concurrent runs cannot clobber each other's captured destroy output. The state assertions are what discriminate: an exit-code-only check would pass against the un-fixed binary, which exited 0. The load-bearing absence ofautoDeleteObjectsis documented at the EDIT SITE in the fixture's stack, not only in its README — stating accurately that adding it makes Phase B FAIL at itsrc -ne 2check (an earlier draft claimed the assertions would keep passing, and overstating a hazard is how the next reader concludes the fixture is lying about something else). A 3-level integ arm was considered and deliberately NOT added — the only lever available makes teardown materially riskier (a mid-tree failure leaves two child state files plus a non-empty bucket whose name the cleanup can only learn from a state file the failing run may have moved past), and the chain is covered by the recursive unit test instead; the cost is teardown risk on a real account, not wall-clock. Becausedestroy-runner.tsis in theinteg-broadgate scope, this change needs a broad integ in addition to the feature one. No CLI flag, dependency, or state-schema change. - ✅
cdkd exportno longer aborts on a Route / EIP / EventInvokeConfig, and theVPCGatewayAttachmentidentifier it shipped is corrected (issue #1771) —src/cli/commands/export.ts,src/provisioning/providers/s3-tables-provider.ts,tests/unit/cli/export.test.ts,tests/integration/export/{lib/export-stack.ts,verify.sh}.cdkd exportis all-or-nothing, so ONE composite-primaryIdentifiertype with noCOMPOSITE_ID_SPLITTERSentry blocks the whole command. Three types cdkd deploys routinely were unregistered —AWS::EC2::Route(the widest blast radius: essentially every public-subnet VPC declares one),AWS::EC2::EIP,AWS::Lambda::EventInvokeConfig— and every schema fact behind the three new entries is a liveDescribeTypemeasurement (us-east-1, 2026-08-13), not the issue's table.AWS::EC2::Route: identifier[RouteTableId, CidrBlock]withCidrBlockread-only, so the overlay narrows toRouteTableId. The subtlety is whatCidrBlockHOLDS: the schema gives it an empty description, so "it is the IPv4 CIDR" is the natural and wrong reading. Cloud ControlGetResourceagainst a scratch route table carrying both shapes says otherwise —rtb-…|::/0reads back{"CidrBlock":"::/0","DestinationIpv6CidrBlock":"::/0"}— i.e. it carries whichever ofDestinationCidrBlock/DestinationIpv6CidrBlock/DestinationPrefixListIdthe route declares, so the physicalId's destination segment maps onto it verbatim and no per-key branch is needed. All three destination shapes were read back live (0.0.0.0/0/::/0/pl-63a5400a), so the mapping is measured rather than inferred. The identifier is always returned in CANONICAL form, which is load-bearing rather than cosmetic:createRoutepacks the destination it SENT into the physicalId and EC2 clears host bits on the way in, so an SDK-written state entry can holdrtb-…|100.68.0.18/18while AWS holds100.68.0.0/18— and because the recorded properties hold that same non-canonical value, the agreement check passes and a verbatim identifier would have been shipped straight into the opaque CFn rejection this path exists to prevent. The recorded properties are then read as a cross-check whose OUTCOME depends on whether the benign explanation is decidable: AWS canonicalizes a non-canonical CIDR onCreateRoute(CFn documents rewriting100.68.0.18/18to100.68.0.0/18) and the Cloud Control path records what AWS returned, so a modelled host-bit difference passes through silently; an unexplained divergence is REFUSED whenever every declared destination is a shape whose AWS-side normalization cdkd models (an IPv4 CIDR, or a prefix-list id — measured to be stored verbatim on both sides, sopl-AAAvspl-BBBis conclusive), because a stale id can name a DIFFERENT route that still exists in the same table — IMPORT would adopt it and phase 2 would then REPLACE (delete) a route the user never targeted — while a divergence involving an IPv6 CIDR (the one shape whose canonicalization cdkd does not model) only WARNS, since refusing on an undecidable signal would block exports that are fine.AWS::EC2::EIP: identifier[PublicIp, AllocationId]with BOTH read-only, so it is the first entry whosepropertiesOverlayis an explicit empty map; a bareeipalloc-…/ bare-IP physicalId cannot produce both fields and is refused with a re-deploy hint rather than sent half-filled. The two segments are bound by SHAPE — BOTH halves validated, since an allocation-id-only guard passeseipalloc-a|eipalloc-band then shipsPublicIp: 'eipalloc-b'— rather than by position: every writer goes througheipPhysicalId, but that ordering is the one thing here no state record in the wild has verified, and a positional bind turns a reversed record into{PublicIp: 'eipalloc-…'}plus an opaque changeset-create failure.AWS::Lambda::EventInvokeConfig: identifier[FunctionName, Qualifier], NO read-only fields, so the default whole-map overlay is correct; a bare function name is read as qualifier$LATEST, mirroring the provider's ownparsePhysicalId, and the id splits on the FIRST|so a function ARN survives. Plus a correction to #1691'sAWS::EC2::VPCGatewayAttachmententry: it producedAttachmentTypevalues ofInternetGateway/VPN, which are plausible, satisfy every unit test written against them, and are REJECTED by CloudFormation —Invalid request provided: Invalid Attachment Type 'InternetGateway'at changeset-create. The real values areIGW/VGW(Cloud ControlListResourcesagainst a VPC carrying both attachment kinds, us-east-1, 2026-08-13); the registry schema types the field as a bare string and enumerates nothing, which is why only a live IMPORT settles it. Both old spellings are still accepted as INPUT — nothing ever WROTE them (they were produced by the table on the way out), so this is compatibility for a hand-written /--resource-supplied id, and the lookup usesObject.hasOwnso a first segment ofconstructorcannot resolve to an inherited function. Live-tested against real CloudFormation, not only unit mocks: a scratch VPC + IGW + attachment + route table + route + EIP, and a separate Lambda + EventInvokeConfig, were adopted through realChangeSetType=IMPORTchangesets — both reachedIMPORT_COMPLETE, and the same run is what produced theInvalid Attachment Typerejection. Tests: theexportinteg fixture now declares all of it (VPC / IGW / attachment / route table / default route / EIP / EventInvokeConfig),verify.shasserts the RESOLVED identifier value per type in the printed import plan — from a SHARED helper called by BOTH thedefaultanddry-runarms, because/run-integ exportruns the default variant only and assertions living in the dry-run arm alone would never execute under the skill meant to gate them — plus the PhysicalResourceId CloudFormation recorded per resource after the real import (each glob pinned to a measured value, since a CFn PhysicalResourceId is a THIRD string distinct from both the Cloud Control identifier and cdkd's own physicalId: for the attachment the two even disagree on order,vpc-…|IGWvsIGW|vpc-…, and CFn reports an EIP as the bare public IP) — assertions that fail both ways a splitter can be wrong (absent, because the command aborted; and present-but-wrong, e.g.AttachmentType=InternetGateway); 40 new unit cases (34 in a dedicated#1771 typesblock, 2 on the correctedVPCGatewayAttachmententry, and 4 prototype-pollution cases split across the two registry lookups) cover all three destination shapes, the empty/short/over-long id refusals, the explicit-empty vs default overlay distinction (asserted through BOTH template overlay sites, sincepropertiesOverlay: {}only protects the EIP if the?? resourceIdentifierfallbacks treat it as write-nothing), the$LATESTbare form, the shape-bound EIP segments, theObject.hasOwnalias lookup, the previously-unreachedvgw-last-resort arm, and each arm of the Route divergence policy. Eleven mutation probes in the final round (each anchor counted for uniqueness first) confirm every new branch fails RED when broken, including theevery-vs-somedecidability boundary, the/0mask special case, and both registry lookups. The IPv6 route arm is deliberately NOT in the fixture: it needs anAWS::EC2::VPCCidrBlock, itself an unregistered composite type that would abort the same export (#1788). Also corrects a stale comment ins3-tables-provider.tsclaiming the CFn schema typesNamespaceasList<String>(it is{"type": "string"}; the provider still accepts both wire shapes becauseaddPropertyOverridecan produce an array — the overlay's handling of that array is split out as #1787). No CLI flag, dependency, or state-schema change. - ✅
AWS::EC2::SecurityGroupIngressrecords thesgr-…rule id, socdkd exporthas a CloudFormation identifier to resolve (issue #1761) —src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/ec2-sg-ingress-rule-id.test.ts,tests/integration/sg-circular-dependency/**,docs/state-management.md,docs/import.md. cdkd's physical id for the type is the composite<groupId>|<ipProtocol>|<fromPort>|<toPort>— the tupleRevokeSecurityGroupIngressneeds, and correct for deploy / destroy / drift — but CloudFormation identifies a rule by the SINGLE fieldId, which is thesgr-…security-group rule id AWS mints (livedescribe-type, us-east-1, 2026-08-13:primaryIdentifierandreadOnlyPropertiesare both exactly["/properties/Id"], handlerscreate/delete/list/read/update).createSecurityGroupIngressthrew that value away (attributes: {}on both arms), which is why #1659 had to register the type as a pure REFUSAL inCOMPOSITE_PHYSICAL_ID_IDENTIFIERS. It is now recorded underId— the same name the three sibling composite types use, i.e. the CFnprimaryIdentifierfield, which is also the type's onlyFn::GetAttattribute. Where the value comes from is the load-bearing part: it is read offAuthorizeSecurityGroupIngress's OWN response (SecurityGroupRules[].SecurityGroupRuleId, verified against@aws-sdk/client-ec2'sAuthorizeSecurityGroupIngressResult), never from a follow-up describe — anawaitadded insidecreate()'s try AFTER the mutating call re-creates the #1710 orphan class, since a throw there leaves the rule live on AWS while the failed CREATE journals no physical id. A unit test pins the AWS call COUNT at 1 so a later "just describe it to be sure" refactor cannot reintroduce it. An AMBIGUOUS response (a template declaring bothCidrIpandCidrIpv6makes AWS mint two rules) records NOTHING rather than naming one of them. The idempotent "already exists" arm has no response to read, so it does a best-effortDescribeSecurityGroupRuleslookup matched on protocol + ports + the one declared source — deliberately NOT viasgRuleKey, whoseSourceSecurityGroupOwnerId/Descriptionmembers AWS fills in on the read side even when the template omits them — and swallows its own failures, because turning a deploy AWS already considers satisfied into a failure over an export-time convenience is the worse trade.import()gained the type too:--resource <logicalId>=sgr-…verifies the rule, declines an EGRESS id (that isAWS::EC2::SecurityGroupEgress, whose delete calls a different API), and records the composite plus the attribute. NohandledProperties/ readback companion is needed —Idis read-only, never templated, and lives inattributes, which drift does not compare. The export side is converted in the same PR.COMPOSITE_PHYSICAL_ID_IDENTIFIERS'AWS::EC2::SecurityGroupIngressentry was a pure REFUSAL that existed only because nothing recorded the id; it is now arecordedArnIdentifier-shaped resolution readingattributes.Id, with an EMPTYpropertiesOverlay(Idis the type's only identifier field AND isreadOnlyProperties, so CFn rejects writing it into the template'sProperties). The predicate is the anchored^sgr-[0-9a-f]+$and BOTH accepted sources go through it — the recorded attribute and a physicalId that is already the bare rule id — because validating one side is not a discriminator (the finding #1771 made about the EIP splitter's segment binding). Anchoring is what does the work: a barestartsWith('sgr-')acceptssgr-abc|tcp|443|443, i.e. the composite wearing the identifier's prefix. Measured live rather than derived (us-east-1, 2026-08-14), because #1771 established that the three strings naming one resource can disagree: a throwaway CloudFormation stack carrying a standalone ingress rule reportsPhysicalResourceIdsgr-02345615af6d2db0d, withRefandFn::GetAtt .Idreturning that same value — so for THIS type, unlikeAWS::EC2::EIP/::VPCGatewayAttachment, CFn's string agrees with the identifier.tests/unit/cli/export-composite-identifier.test.tshad a case PINNING the refusal ("refuses … even when anIdattribute happens to be present", justified by "nothing in cdkd writes anIdattribute for this type today"); that premise is now false, so the case is inverted rather than left to keep CI green over the wrong behavior. Known gap: a rule deployed by an older cdkd has no recordedIdandcdkd exportblocks it — with a message that does NOT say "re-deploy the stack once" like its three siblings, because the id ridesAuthorizeSecurityGroupIngress's own response and a no-op deploy issues no call; it names the real remedy (change any property, or destroy and re-deploy, accepting a momentary traffic interruption). Tests: 15 unit cases plus a live arm on thesg-circular-dependencyinteg — state must carry ansgr--shapedattributes.Id, andcdkd export --dry-runmust resolve that EXACT value into the import plan. The export arm runs against a second, deliberately minimal stack (CdkdSgIngressExportExample: an L1 VPC with no subnets plus the same SG-to-SG pair) becausecdkd exportaborts on the first unresolvable resource even under--dry-run, and the circular-ref fixture'sec2.Vpcemits anAWS::EC2::Route— a composite-identifier type with no splitter, i.e. exactly the class issue #1771 tracks. No CLI flag, dependency, or state-schema change (attributesis a free-form map). - ✅ DynamoDB: the Table GSI / LSI readback is reverse-mapped to its CFn shape, and a WarmThroughput DECREASE stops being sent (issues #1767 / #1768) —
src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-index-drift-phantoms.test.ts,tests/unit/provisioning/dynamodb-table-provider-warm-throughput-decrease.test.ts. Both were found by the code review of the #1760 PR and land in one file, hence one lane. #1767 — the index readback was forwarded VERBATIM.readCurrentStateemitted theDescribeTableindex descriptions unchanged, soIndexStatus/Backfilling/ItemCount/IndexSizeBytes/IndexArn, the on-demand{0, 0}ProvisionedThroughputplaceholder and the AWS-computed per-indexWarmThroughputall took part in drift detection — the method's docstring claimed "the comparator filters them" and it does not, because that filter iscalculateResourceDrift's state-keys-only TOP-LEVEL walk while these are members of an ARRAY the baseline DOES carry, compared positionally bydeepEqual. Two failures fell out: apropertiesbaseline could never equal the readback (permanent phantom), and — worse, on the ORDINARY path —ItemCount/IndexSizeBytesMOVE with write traffic, so any in-use table with an index drifted against its ownobservedPropertieson a schedule set by its own traffic, withdrift --revertre-sending the frozen blob throughupdate(). Each entry is now reverse-mapped by an ALLOW-LIST (IndexName/KeySchema/Projection, plus the three throughput blocks gated on the DESIRED bag's entry of the sameIndexNamedeclaring them — the #1760 shape one nesting level down, matched by NAME never by position). The allow-list is what caughtBackfilling, which the issue's quoted sample does not list. ThegetDriftUnknownPathscompanion ignores each index list when the template declares none, covering that population's upgrade transition and steady state; the residual for a table that DOES declare indexes (its pre-fixobservedPropertiesreports a one-sided difference until the next deploy orcdkd drift --accept) is carried deliberately: no PATH can express the middle ground, since the comparator is never asked about a path that crosses an array. A NON-path seam does now exist —canonicalizeDriftProperties, applied to both comparison sides, landed in #1799 and closed #1784 — and adopting it here is its own change with its own real-AWS verification, tracked as #1812. The index lists are NOT declared ingetDriftUnorderedPaths: a plain entry there is a SUBTREE declaration whose walk descends into array elements, so it would also sort the per-indexKeySchemaand reverse #1760's order-significance decision at the index level only. The leaf-only form that fixes exactly this —'GlobalSecondaryIndexes[]'— shipped in #1799 and closed #1783; declaring it for this type is part of the same follow-up, #1812. #1768 — a declaredWarmThroughputAWS has GROWN was unrevertable. Measured live (us-east-1, 2026-08-13): against a table AWS reports{12000, 4000}for, anUpdateTablelowering either member is REJECTED (decreasing WarmThroughput is not supported) while a re-assert of the equal value is accepted — socdkd drift --revertissued a call that could only fail and the resource reportedcould not revert(exit 2).update()now SKIPS a request in which every declared member is at-or-below the live value and at least one is strictly below, warning with both numbers; a MIXED request, an absent live value and any unusable value all fail OPEN (still sent). NoeffectivePropertiesis returned on purpose: recording AWS's value would silence the drift report that is the user's only signal to edit the template. The skip was checked against everyupdate()caller — deploy,drift --revert, and the rollback executor's two revert arms — none of which can make AWS lower the value. The PER-INDEX sibling is fixed in the same change:applyGsiUpdatessent onlyProvisionedThroughput/OnDemandThroughput, so a template-declared per-indexWarmThroughputwas silently dropped on every index add / change and--revertexited 0 claiming success — it now rides theCreateaction and gets its ownUpdateaction (measured: aWarmThroughput-only GSI update action is accepted, and a per-index decrease is rejected naming the index), under the same decrease guard. Live coverage for both issues ridestests/integration/dynamodb-gsi-update: the fixture now writes real items, asserts the deploy-writtenobservedPropertiesindex entry carries ONLY the CFn members, assertscdkd driftreports the table CLEAN (parsed from--json, so a drift-unknown outcome cannot read as clean) against both the observed and the stripped TEMPLATE baseline, and — for #1768 — deploys a template declaring a WarmThroughput BELOW the value AWS holds, which pre-fix failed the deploy outright and now must skip with the warning while AWS's value stays put. One more residual the review surfaced: a{0, 0}per-indexProvisionedThroughput(AWS's on-demand placeholder) arriving from a pre-fix state record via--revertis refused rather than sent, since AWS rejects capacity 0 in either billing mode. Review round 2 closed four more, all found by the 3-axis reviewers against the per-index send path this PR had just added: the per-indexWarmThroughputgained theliveCapacityAlreadyMatchestwin it was missing (warmThroughputAlreadyMatches), without which a--revertof a stale blob emitted one redundantUpdateTableplus a full index-ACTIVE wait PER GSI — which had also falsified the residual note beside it, so that note now names the three skips it depends on instead of claiming the revert is a no-op "by construction"; the shared send rule stopped being bare truthiness, soWarmThroughput: {}/'nonsense'no longer reach the wire (at least one member must resolve to a finite number, newly reachable once per index); the per-index decrease warning gained the deploy-path caveat AND a second remedy for the caller whose template declares no per-indexWarmThroughputat all; andNonKeyAttributesis copied rather than aliased into the SDK response, like itsKeySchemasibling. The suspectedWarmThroughput.Status/runGsiOpsrace was MEASURED rather than reasoned about (us-east-1, 2026-08-13): the wait predicate IS satisfied while a warm update settles —IndexStatus: ACTIVEalongsideWarmThroughput.Status: UPDATINGfor 90+ seconds — but all three next-op shapes (warmUpdateon another index, on the SAME index, and a GSICreate) were ACCEPTED, so the wait is deliberately NOT gated on warm status and the measurement is recorded where the predicate lives. Round 3 added the per-index EMIT arm the fixture was missing (the maintainer signed off on its recurring cost): phase 2 now declaresWarmThroughput: {12001, 4000}ongsi1, which rides theCreateaction the GSI add already issues — no extra AWS call — andverify.shasserts AWS reports 12001/4000 back. 12001 is the SMALLEST raise AWS accepts above the 12000/4000 floor (measured), and it is the discriminator: an index created with no declared WarmThroughput reports exactly 12000/4000, so the assertion cannot pass on the default. It reads the units WITHOUT waiting forWarmThroughput.Status, because the same measurement shows the units report the requested value from the first poll (whileIndexStatusis still CREATING) whereas the status stays UPDATING for minutes — the bounded poll is for read consistency, not for the warm update to finish. The phase-2b observed-baseline key set becomesIndexName,KeySchema,Projection,WarmThroughput, which now asserts BOTH halves of the reverse-map at once: the declared block survives, the AWS-managed members do not. Review round 3 closed seven more: tightening the send rule had made a refusedWarmThroughput(a member typo,'nonsense',{}, an unresolved intrinsic) vanish with no warning at all — pre-PR it reached AWS and was rejected BY NAME — so all four write sites now announce it while an ABSENT or falsy block stays silent;warmThroughputOpForand the capacity path share ONEUpdateaction per index (both members are optional onUpdateGlobalSecondaryIndexAction, so splitting them cost a secondUpdateTableplus a second full index-ACTIVE wait); the reverse-mapper NARROWS the GSI/LSI union instead of casting across it; the integ arm's poll no longer aborts the whole verify on a transient describe (set -ekills a bare assignment from a failing command substitution — measured both ways) and every scratch file is swept by the existing trap; and two comments were corrected against the code rather than reworded — the caller-blind{0,0}rationale had claimedupdate()takes no context whenUpdateContextexists ands3-bucket-provider.tsconsumes it (the behavior stands: its one field separatesdrift --revertonly, and the rollback replay is still indistinguishable from a template deploy), andindexDeclares's header had claimed its predicate is what the write path calls universally, which is false forcreate()'s verbatimCreateTableforward. Review round 4 fixed a regression the round-3 narrowing had introduced — oneif (!('ProvisionedThroughput' in live)) return out;guard ahead of all three throughput blocks gatedOnDemandThroughputandWarmThroughputon a THIRD, SDK-optional member, so the ordinary PAY_PER_REQUEST-with-caps readback (OnDemand + Warm, noProvisionedThroughputkey) dropped BOTH declared blocks and produced exactly the one-sided drift this change exists to remove; the narrowing is now per block. It also unblinded a CI critic: twoproperties!['…']non-null assertions are not followed byscripts/gen-handled-property-wiring.ts, which had silently dropped thegetDriftUnknownPaths/readCurrentState/delegatedevidence forWarmThroughputand both index lists from the checked-in matrix — an explicitproperties !== undefined &&restores it. Plus the adopted-index arm's{0, 0}refusal gained the test that reds when it is removed, and three more comments were corrected against the code rather than reworded. Review round 5 closed the last one: the tightened send rule ACCEPTED a numeric string while all four send sites forwarded the declared value verbatim, so{ReadUnitsPerSecond: '12000'}went on the wire as a string in a Long field — the one doomed shape the tightening exists to stop, andgetDriftUnknownPathsthen answered "declared" for it. The value is now COERCED per member at every send site ('12000'->12000, matching whatcreate()'s table-levelProvisionedThroughputalready did), an unusable member is dropped and named rather than reaching AWS asNaN, andisSendableWarmThroughputis DEFINED as the coercion's success so the drift gate and the write path cannot diverge for any input. The per-index refusal warning also moved below the unchanged-value gate, so both warn sites now mean "once per changed value" rather than re-warning on every deploy that touches another index. Review round 6 found the FIFTH send site the coercion had missed —create()forwards the declaredGlobalSecondaryIndexesarray toCreateTable, so a per-index{ReadUnitsPerSecond: '12000'}reached the wire as a string and{}as an empty block, both silently, which made one template fail on a fresh create and succeed on a later GSI add; each entry is now mapped through the same helper (entries are rebuilt, never mutated, since the engine records the same bag into state). It also scoped the identity claim to the BLOCK level, where it is exact, and states the per-MEMBER residual explicitly (a template whose second member is an unresolved intrinsic sends one member while the readback emits both — fail-open, announced on the deploy that introduces it and silent on repeats since every warn sits behind a change gate, with the drift report standing throughout, and closing it means areadCurrentStateshape change with its own baseline migration); moved the decrease / already-matches gates onto the COERCED spec so the analysis describes the request actually sent; and noted thatProjection's rebuilt key order is whatapplyGsiUpdates'sJSON.stringifycompare depends on. Review round 7 applied that same gate principle to the TABLE-level decrease check, which had been left reading the raw bag: for{ReadUnitsPerSecond: {Ref: 'X'}, WriteUnitsPerSecond: 2000}against a live{12000, 4000}it failed open on the unusable member and then transmitted the coerced{WriteUnitsPerSecond: 2000}— a decrease AWS refuses — so the DEPLOY FAILED while the per-index arm warned and continued on the identical input. Both paths now coerce ONCE, before the gate, which also means the dropped-member announcement survives a skip instead of being lost with the send. Three claims were corrected against measurement rather than reworded: the residual is announced on the deploy that introduces it and silent on repeats (every warn sits behind a change gate) rather than "on every deploy"; theProjectionkey-order flip comes from the TEMPLATE side, sinceaws-cdk-libemits{NonKeyAttributes, ProjectionType}while a readback is always the reverse; and the note claimingcreate()forwards a per-indexWarmThroughputverbatim was made false by round 6's own fix. Review round 8 was fences and precision only: the dropped-member announcement gained real coverage at BOTH levels (the assertion that looked like its fence matched a member name the DECREASE warning also prints, so it passed whether or not the announcement fired), the two pure warm-throughput gates now take aWarmThroughputrather thanunknownso the coerced-spec invariant is structural, and the announcement's tail no longer claims the value "was sent" — it fires ahead of the gates, so a later skip made that false. - ✅ The GlobalTable replay-CREATE omit stops orphaning
AttributeDefinitions, and one of two permanent drift phantoms goes away (issues #1741 / #1742) —src/provisioning/providers/dynamodb-globaltable-provider.ts,.claude/rules/providers.md,.claude/rules/code-layout.md,tests/unit/provisioning/dynamodb-globaltable-provider-replay-create-effective-props.test.ts,tests/unit/provisioning/dynamodb-globaltable-provider-drift-phantoms.test.ts. Both were found by the FIRST live exercise of theAWS::DynamoDB::GlobalTablereplay-CREATE arms (therollback-replay-effective-propsfixture added for #1724 / #1726, us-east-1, 2026-08-13), and neither is visible to a unit mock. Bundled because they land in one file, which cannot host two lanes. #1741 — the omit produced a call AWS rejects.create()takes the #1544replayWarndowngrade when a state record carries a malformedGlobalSecondaryIndexes: the translator warns and returns the well-defined EMPTY list, soCreateTablegoes out with no indexes — but with the record'sAttributeDefinitionsUNCHANGED. DynamoDB requires the definitions to be exactly the attributes named byKeySchemaand by the indexes actually being created, so for any table whose GSI was keyed on its own attribute — the ordinary case — the omit left that attribute defined and unused and AWS rejected the whole call (ValidationException: … Some AttributeDefinitions are not used. AttributeDefinitions: [pk, gsipk], KeySchema: [pk]). The downgrade exists so a reverse-replacement rollback can restore a table whose record an older binary wrote badly, so it was failing on exactly the population it was built for: the old table was not re-created, the rollback reported the resource as remaining, and the user was left with the new table plus a record describing the old one — worse than the refusal it replaced, which at least failed before touching AWS. Now the arm prunesAttributeDefinitionsto the attributes the call still keys on, and records the pruned list ineffectivePropertiesfor the same reason #1724 drops the index key (an unsent definition is a recordreadCurrentStatecan never match). LSI key attributes SURVIVE —LocalSecondaryIndexesis create-only and is not omitted by this arm, so a naive "keep only the tableKeySchema" would break a table with an LSI in the opposite direction — which is whycollectDesiredKeyAttributeNamesgained an explicit index-list scope rather than a hand-rolled twin;update()'s existing removal guard passes no scope and reads exactly as before. It fails OPEN on an unreadableKeySchema(an intrinsic-valued one resolves to no names, and pruning against an empty set would strip every definition and turn a template defect into a more confusing AWS error). The fixture's workaround — keying its index on the table's own partition key — is what made the arm pass before; that constraint can now be lifted, and the cross-region half of #1741 (the omit keepsReplicas[].GlobalSecondaryIndexesoverrides, whichaddReplicathen sends against a zero-index table) stays OPEN pending a cross-region fixture arm, so the issue is not auto-closed. #1742 — two phantom drifts on an UNTOUCHED table. Both reachable only when the drift baseline ispropertiesrather thanobservedProperties, i.e. after any reverse-replacement rollback, whichrollback-executor.tsstripsobservedPropertieson — which is why they had not been seen: on the ordinary path both comparison sides come from the same readback and already agree. (a)AttributeDefinitionsis an unordered SET compared positionally:DescribeTablereturns it in an order matching neither the request nor alphabetical (a table declaring[pk, gsipk]read back as[gsipk, pk]), so it drifted forever. The provider now declares it ingetDriftUnorderedPaths— the first such declaration on this type, so the method is new — and sorting cannot hide a real change because the list is keyed byAttributeName.KeySchemais deliberately NOT declared at either level, being order-SIGNIFICANT (HASH before RANGE). (b)GlobalSecondaryIndexes[].WarmThroughputis AWS-COMPUTED (a 12000/4000 default reported for every index) and is deliberately NOT fixed here, so #1742 stays open for that half. The obvious fix -- gating thereadCurrentStateemission on the desired side -- was implemented, reviewed, and then REVERTED, because it trades one population's phantom drift for another's: everyobservedPropertiesbag already in S3 was written by a binary that emitted the member, so the firstcdkd driftafter upgrading would report the wholeGlobalSecondaryIndexesarray on every untouched table (measured live on the siblingAWS::DynamoDB::Tabletype under #1760). The companion that closes that transition -- declaring the path ingetDriftUnknownPaths-- exists for a TOP-LEVEL key but cannot express a PER-INDEX one, since an ignore-path never crosses an array. It needs a both-sides normalizer in thedrift-protocol-normalize.tsmould plus a live test seeded with a STALE observed baseline, which a fresh-deploy fixture structurally cannot provide. Shipping the ordering half alone carries no such hazard: sorting is applied to BOTH comparison sides, so an existing baseline and the readback converge rather than diverge. Also measured and NOT bundled: the siblingAWS::DynamoDB::Tableprovider has the identical un-declaredAttributeDefinitionsreadback and an unconditional table-levelWarmThroughputemission — different file, different type, and the second half needs its own us-east-1 measurement — filed as issue #1760. #1739 was closed unfixed, having been measured as ALREADY fixed on main by PR #1722 (theAWS::DynamoDB::GlobalTable <logicalId>:prefix and the "compared against" wording are both present and fenced atdynamodb-globaltable-provider-billing-mode-shape.test.ts:319). Tests: 14 new cases across two files. Every fix is paired with discrimination cases that must NOT change — an omit orphaning nothing keeps the declared list verbatim, a valid GSI block prunes nothing, an unreadableKeySchemafalls open, a malformed definition entry is kept rather than silently pruned, a cross-region replica index block survives while the local one is dropped, and a retyped attribute still differs after canonicalization so the sort is not vacuous. The ordering case asserts through the SAMEcanonicalizeUnorderedArraysAtPathshelperdrift-calculator.tsapplies, using the two orders the live run produced, rather than asserting the declaration string alone. Mutation-probed against the real source in three cuts (the prune, the unordered declaration, the cross-region replica guard); each fails only its own rows and every discrimination case survives. No CLI flag, dependency, or state-schema change. - ✅ DynamoDB: the two
AWS::DynamoDB::Tablephantom drifts —AttributeDefinitionsorder and the AWS-computed table-levelWarmThroughput(issue #1760) —src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-drift-phantoms.test.ts. TheAWS::DynamoDB::Tabletwin of theAWS::DynamoDB::GlobalTablepair filed as (#1742), in a different file. The two halves reach the user by DIFFERENT routes, and the issue's single premise covered only one of them — the spec review of this PR caught the summary asserting one reachability for both.AttributeDefinitionsis the half the issue describes: reachable only when the drift baseline is the templatepropertiesrather thanobservedProperties(after a reverse-replacement rollback, which stripsobservedProperties, or on a resource deployed before observed-capture existed) — on the ordinary path both comparison sides come from the same readback and already agree, which is why routine runs never surfaced it.WarmThroughputis the OPPOSITE: the comparator's top-level walk iteratesObject.keys(stateProperties)(src/analyzer/drift-calculator.ts), so a key the template never declared cannot drift against apropertiesbaseline AT ALL. Its harm lands on theobservedPropertiesbaseline, where the AWS-computed value is frozen at capture time and a later AWS-side increase surfaces as drift on a property the user never declared, whichdrift --revertthen re-sends.AttributeDefinitions. The provider declared nogetDriftUnorderedPathsat all while emitting the readback verbatim, and the property is a genuine SET keyed byAttributeName: measured us-east-1 2026-08-13, aCreateTabledeclaring[{pk,S}, {gsipk,S}]read back as[{gsipk,S}, {pk,S}]on the create response AND on every laterDescribeTable, so the comparator's positional array compare reported permanent drift on a table nobody touched. It is now declared unordered;KeySchemais deliberately NOT, at either level, because it is order-SIGNIFICANT (HASH before RANGE) and sorting it would HIDE a real key change — pinned by a test in both directions.WarmThroughput. The issue filed this half UNMEASURED and it measured POSITIVE: on the same probe table, whose create input declared none,DescribeTablereturned{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000, Status: 'ACTIVE'}— so the existing emit-when-present guard could never do what its comment claimed ("only on tables that set warm throughput"). The value is AWS-computed and AWS-owned (it only ever rises with the table's traffic and cannot be lowered), so freezing it into anobservedPropertiesbaseline turns a later AWS-side increase into drift on a property the user never declared, whichdrift --revertthen re-sends throughupdate()as a decrease AWS rejects.readCurrentStatenow emits it only when the DESIRED bag declares it — never a blanket drop, since the type accepts an explicit value and a real change to one must stay visible — which needed thepropertiesparameter threaded in, the same one-line signature change (#1742) PROPOSES for the per-index sibling (that issue is still open, so this is the first site to carry the shape). "Declares" is spelled the way the WIRE spells it:create()/update()gate on truthiness, so a declaredWarmThroughput: nullsends nothing while AWS still computes 12000/4000, and an!== undefineddrift-side gate would re-create the identical phantom one value over — with--revertunable to clear it, since the write gate skips a falsy value. OnedeclaresWarmThroughputpredicate serves both drift-side consumers so the three spellings cannot drift apart, and a table-driven test pins emit-vs-ignore agreement across every bag shape. The gate alone would have shipped a regression, and that is the second half: every state record written by an earlier binary already carries the computed value inobservedProperties, so dropping it from the AWS side flips EVERY existing table to a one-sidedWarmThroughputdrift until the next deploy refreshes the capture.getDriftUnknownPathstherefore ignores the path on BOTH sides when the recorded template declares nothing, scoped per resource through the same seamAWS::ApiGatewayV2::Integration'sTlsConfiguses (#1602); a table that DOES declareWarmThroughputis compared normally. Every assertion was mutation-probed against the real source line it targets, and the whole fix was live-tested through the realcdkd driftCLI against a hand-seeded state file (no deploy — thepropertiesbaseline this class needs cannot be produced by one), where deleting either half reproduced its phantom drift verbatim. Two residuals the review surfaced are filed rather than ridden along: the GSI / LSI blobs are still emitted verbatim, soItemCountalone drifts every in-use table with an index (issue #1767 — wider than this fix and needing its own migration story), and a DECLAREDWarmThroughputAWS has since grown is unrevertable, since--revertissues a decrease AWS rejects (issue #1768). - ✅ S3: the lifecycle block now round-trips whole — legacy singular transitions, the rule-level expiration reshapes and the sent-but-unrecorded
Filter— and a malformedEventBridgeEnabledstops silently ENABLING notification delivery (issues #1754 / #1755 / #1759) —src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-{never-emitted-spellings,eventbridge-notification,effective-properties}.test.ts,tests/integration/s3-lifecycle/**,scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts. The three residuals the (#1748) / (#1751) PR filed rather than rode along, bundled because all three land in the same two blocks and the same recording-vs-wire decision. #1755 is the widest: it fires on an ORDINARY CDK-shaped lifecycle rule using no cdkd tolerance at all —ExpirationInDays/NoncurrentVersionExpirationInDayswere recorded against anExpiration/NoncurrentVersionExpirationreadback, and the empty-prefixFilterthe applier SENDS for a scope-less rule was recorded not at all — so state and the readback carried different KEY SETS for essentially every lifecycle-configured bucket CDK creates. The expiration half is fixed on the READBACK side (readLifecyclenow emits the three rule-level CFn scalars the registry schema actually declares; recording the SDK shape instead would put a spelling in state that no template can declare), the other two on the desired side, andeffectiveLifecycleRuleis the ONE fold both the recorder andcanonicalizeDesiredPropertiesrun. The scope half is wider than the issue's headline because the same round trip also moves a bare top-levelPrefix, rule-levelTagFiltersand the rule-levelObjectSize*members underFilter;lifecycleRuleScope/lifecycleUsesFilterFormwere hoisted to module level so the fold and the applier decide V1-vs-V2 through the SAME functions rather than two copies — the decision is made across the WHOLE rule list, so a pure per-rule fold could record aFilterthe wire never sent. #1754 — the legacy singular actions.Transition/NoncurrentVersionTransitionare folded into the plural array by sharingmergeLegacySingularitself, so the readback's only spelling is what state records. The collision arm is the reason it needed its own decision, and the answer is NOT a no-op callback: on aStorageClasscollision the wire drops the singular and WARNS, so folding both sides identically would make the comparison equal,update()would never be called again, and that warning would stop after one deploy — a colliding rule is left COMPLETELY alone instead, keeping the difference visible. #1759 — the notification residuals. A malformedEventBridgeEnabled(null,'yes', an array, an unresolved intrinsic) took the ENABLE arm becausecoerceCfnBooleananswersundefinedandundefined !== falseis TRUE, so a value cdkd cannot read turned a notification feature ON on a LIVE bucket with no warning anywhere — the destructive-default class (#1595). It now runsconfigBooleanRefusaland takes the per-path answer: THROW on a template-path create, warn-and-SKIP on the replay-reachable update path, where the skip unit is the WHOLE notification configuration becausePutBucketNotificationConfigurationis a full replace (the caller retains the previous value, the #1612 UPDATE answer). #1430's polarity survives intact — a usablefalsestill emits no block, a usabletruestill emits{}— and thes3-lifecycleinteg asserts both on both phases. The stringly-typedEventBridgeEnabled: 'true'is now recorded as the BOOLEAN the readback emits, and a declared-but-EMPTY notification family is DROPPED from the record — MEASURED on the reverse-mapper rather than assumed, because the sibling shape answers the opposite way:readLifecyclealways emits{Rules: []}so the lifecycle placeholder is RECORDED (#1718), whilereadNotificationemits a family only when it is non-empty. MEASURED and deliberately NOT folded, stated rather than quietly omitted:readNotificationemitsEventBridgeConfigurationunconditionally, so a configuration declaring no EventBridge block is one key short of the readback — nothing was declared, so there is no spelling to normalize, and supplying it would record a key the template does not have on every notification-configured bucket (the shape #1723 records); #1430 already scopes that difference as a one-time, self-clearing drift. Three more divergences came from the PR REVIEW, all of the same shape and all fixed here — which is the unit-upgrade lesson one level below the whole-rule fence:Transitions[].TransitionDatewas only RENAMED by the alias fold while the applier sendsnew Date(...)and the readback emits.toISOString(), so'2030-01-01'recorded against'2030-01-01T00:00:00.000Z'— the identical class the rule-levelExpirationDatefold closes one member over;mergeLegacySingular.filter(isPlainObject)s the plural array UNCONDITIONALLY, so keying the fold on the singular's presence made junk-element filtering depend on an unrelated key; and the nestedExpiration.Datewas folded on PRESENCE where the applier reads TRUTHINESS, so a falsy date would have recorded1970-01-01. The review also asked for #1755's fourth bullet, the per-item notificationId, to be MEASURED rather than dropped: the applier sendsId: t['Id'], AWS generates one when none is declared, andreadNotificationemits it — an AWS-COMPUTED value that belongs inobservedProperties, and one that fails the (#1643) knowable-at-send-time test outright, so it is deliberately not folded and the measurement is recorded in-code and pinned by a row. Tests: 67 new rows on the round-trip fence, upgraded from per-key to WHOLE-RULE comparison — which is precisely why the #1748 rows missed these divergences — plus the arms that must NOT fold (a colliding singular, a refusedStatusorFiltercontainer, an unreadableExpiredObjectDeleteMarker, a marker colliding withDays, a non-array notification family), each with its reason. Two pre-existing rows that PINNED the destructive behavior are inverted into fences of the new one. Mutation-probed in TWENTY-TWO cuts against the real provider (each fold neutered in turn, the collision arm removed, the refusal gate short-circuited, the readback reverted,coerceCfnNumberdowngraded totypeof, the declared-nullguard narrowed to "declared and non-null", the junk-element filter keyed back on the singular, the date normalization dropped); every cut goes red. One probe came back GREEN and the ROW was FIXED rather than the probe re-tuned: an all-junkRulesarray is already short-circuited by the empty-list arm, so only a MIXED array exercises the element-count guard. Thes3-lifecycleinteg gains a malformed-value arm on a new bucket rather than a new fixture: phase 1 deploys a usablefalse, phase 2 replaces it with the string'yes', and the block must be absent in BOTH — pre-fix, phase 2 created one. The critic's withdrawn-name fence moved 27 -> 26 (TagFilters), measured against a scratch copy of the pre-change provider through the--providers-dir=seam:--checkreports byte-identical totals, and deleting a real wire write still fails by name, so the folds vouch for nothing. TWO folds write their member under a COMPUTED key for that reason (EventBridgeConfiguration,TransitionDate) — measured both ways, and the naive named-member form really does retire those names from the reverse-map exclusion. Live-tested against the REAL synthesized fixture templates (both phases, every bucket): the recorded bag and whatreadCurrentStateemits carry identical keys and values, the malformed-value arm refuses with a warning on the update path and throws on a create, and #1430's polarity holds on all four true/false buckets. A SECOND independent review round found one more defect of the identical class and one hazard:foldTransitionDatefolded on PRESENCE where the wire reads TRUTHINESS, so a declaredTransitionDate: 0recorded1970-01-01T00:00:00.000Zagainst a wire that sends no date at all — MANUFACTURING the phantom drift the fold exists to remove, and the same presence-vs-truthiness slip the commit that added it had just fixed for the nestedExpiration.Date; andcfnDateToIsowas timezone-dependent (new Date('Jan 1 2030')resolves LOCAL), which is harmless for the wire (the applier shares the dependence) but not forcanonicalizeDesiredProperties, a DIFF-side pure function that runs wherever the CLI does — a UTC runner and a JST laptop would fold the same template to different ISO strings and manufacture a diff neither machine can clear. The fold is now restricted to the forms ISO 8601 fixes the meaning of (bareYYYY-MM-DD, aZ/±HH:MMdate-time, an epoch number) and leaves every other spelling alone, with a row asserting twoprocess.env.TZsettings canonicalize identically. The one measured residual is key ORDER — the record keeps the template's, the readback the reverse-mapper's — which is pre-existing, unchanged here, and invisible tocdkd drift, whose baseline is captured from that same reverse-mapper; stated in-code rather than left to be re-discovered. No CLI flag, dependency, or state-schema change. - ✅ S3: the notification / lifecycle never-emitted spellings are normalized on both sides, and a malformed inventory
Enabledstops silently ENABLING a report (issues #1748 / #1751) —src/provisioning/providers/s3-bucket-provider.ts,src/provisioning/config-shape.ts,.claude/rules/providers.md,tests/unit/provisioning/s3-bucket-provider-never-emitted-spellings.test.ts,tests/unit/provisioning/s3-bucket-provider-substituted-properties.test.ts. The two residuals the (#1707) / (#1717) / (#1718) PR filed rather than rode along; bundled because both land in the same provider and the same recording-vs-wire decision. #1748 — never-emitted spellings. Where the provider accepts more than one spelling on the DESIRED side but its reverse-mapper emits only ONE, a record written in the other can never match the readback:cdkd driftre-reports it forever and--revertre-issues the same call, with no warning anywhere because nothing is malformed and nothing is substituted.effectiveNotificationConfiguration/effectiveLifecycleConfigurationare now the ONE helper the appliers andcanonicalizeDesiredPropertiesshare, folding each tolerated spelling onto the emitted one and REMOVING the tolerated key (not setting itundefined, which survives astructuredCloneand leaves theunionWalkObjectsdrift path seeing two key sets). The audit found more than the issue named, twice. Matching the(a['X'] ?? a['Y'])ALIAS form turned up THREE lifecycle aliases where the issue named one (TransitionInDays ?? Days/TransitionDate ?? DateonTransitions[],TransitionInDays ?? NoncurrentDaysonNoncurrentVersionTransitions[]), so fixing the reported key alone would have left its siblings broken. Then the ROUND-TRIP fence — assert the recorded bag against whatreadCurrentStateACTUALLY EMITS for the configuration just sent, never against a literal — failed immediately on a divergence neither the issue nor the audit had named:readNotificationemitted the SDK LISTEventswhile every template carries the CFn scalarEvent(the registry schema declares noEventsmember), so the record and the readback disagreed on EVERY notification-configured bucket rather than only on the rareTopicArn-spelled one. That half is fixed on the READBACK side, by ARITY — a single-element list is the CFnEvent, a longer one has no CFn spelling and staysEvents— mirroring whatreadLifecyclealready does forTransitionInDays; one-time drift on upgrade, the accepted cost the sibling reverse-map changes in that file already document. Two literal-based fences would both have passed. #1751 — inventoryEnabled. The one member of that item whose wire read coerced SILENTLY ((config['Enabled'] as boolean) ?? true), so a declaredEnabled: nullwent out astrue— ENABLING a report the template may have been disabling — while the record keptnull. Fixed on the WIRE, not in the fold: the issue's option 2, because defaulting a malformed value onto a LIVE inventory is the destructive-default class (#1595) already refuses at that item's string members. It now SKIPS the configuration item with a warning on the replay-reachable update path and THROWS on a template-path create, which is what makes the fold's presence test correct rather than making the fold mirror a bad read;configBooleanRefusal(new inconfig-shape.ts, alongside acoerceCfnBooleanmoved there from the provider) is the predicate, and it runs the very function the wire read calls. A CFn STRING boolean is NOT malformed — CloudFormation is stringly typed — so'false'is sent COERCED and recorded coerced, the lossless-coercion arm of (#1633) that meets the (#1643) bar. A review-class finding shipped with it: the twin was first wired as{ ...out, LifecycleConfiguration: folded }, andgen-nested-key-coverage's write-evidence walk recognizes that named-member spread as a whole-blob HAND-OFF (its #1475 recognizer) — so a DIFF-side pure function vouched for the forward mapper and retired three reviewed allow-list entries (LifecycleConfiguration.TransitionDefaultMinimumObjectSize,…Rules.TagFilters.Key/.Value), i.e. silently switched the write pass off for that subtree while the critic's only complaint was that those entries were now "stale". A COMPUTED key (canonicalizeBlock) restores it; measured both ways through the critic's--providers-dir=seam, and the general lesson is recorded in.claude/rules/providers.md: when a critic calls an allow-list entry stale, first ask what NEW evidence made it resolve. Tests: 32 new rows plus a rewrittenEnabledblock (the old rows CHARACTERIZED the pre-fix behavior and are inverted into fences of the new one), each paired with counter-cases — a CFn-spelled template assertseffectivePropertiesis ABSENT, a nullish first spelling still falls through to the alias, a multi-elementEventsis left alone, a SKIPPED lifecycle Put keeps the previous value. Mutation-probed in SEVEN cuts against the real provider (silent coercion restored, each fold neutered, the readback reverted toEvents,deleteswapped forundefined, the twin unwired, a skipped Put folded anyway); each fails only its own rows. Two residuals filed rather than ridden along: the legacy singularTransition/NoncurrentVersionTransitionMERGE (issue #1754) and the RULE-levelExpirationInDays/NoncurrentVersionExpirationInDays/Filterdivergences the same fence measured (issue #1755), so "the aliases converge" is not mistaken for "the block converges". No CLI flag, dependency, or state-schema change. - ✅ A warn-and-SKIP delete is no longer counted and printed as
deleted(issue #1752) —src/types/resource.ts,src/types/deployment-events.ts,src/utils/resource-line.ts,src/provisioning/composite-id.ts,src/provisioning/providers/{glue,appsync,ec2}-provider.ts,src/cli/commands/{destroy-runner,destroy,state}.ts,tests/unit/cli/destroy-runner-skipped.test.ts,tests/unit/provisioning/composite-id-delete-skip-outcome.test.ts, plus cases intests/unit/{utils/resource-line,cli/destroy,cli/state-destroy}.test.ts.ResourceProvider.deletereturnedPromise<void>, so the destroy runner's only success signal was "did not throw" — and the five malformed-composite-physicalId arms (AWS::Glue::Table,AWS::AppSync::{DataSource,Resolver,ApiKey},AWS::EC2::NetworkAclEntry) return normally after issuing NO AWS call. The run therefore printed✓ <id> (<type>) deleted, counted the resource towardN deleted, DROPPED its state record, and exited 0, over a resource that may still be alive and billing — found by the #1657 live test, which reportedStack Cdkd1657Verify destroyed (3 deleted, 0 errors)having deleted nothing.deletenow returnsPromise<void | ResourceDeleteResult>;voidstill means deleted (so none of the ~80 other providers changed), and an arm that could not ADDRESS the resource returns{ outcome: 'skipped', reason }via the sharedcompositeIdSkipResult(). The runner then prints⚠ <id> (<type>) skipped (<reason>)— a yellow warning glyph, the one op that is neither the✓success line nor the✗ Failed to deletefailure line — counts it in a newskippedCount, emits aRESOURCE_SKIPPEDdeployment event (noerror: nothing was attempted), and renders(2 deleted, 1 skipped, 0 errors). Two decisions the issue left open: state RETENTION — a skipped resource stays inremainingResources, sostate.jsonis preserved with its record intact, because dropping it is the second half of the data loss (the user would have neither the AWS resource deleted nor an id to delete it with); and the EXIT CODE —cdkd destroy/cdkd state destroynow raisePartialFailureError(exit 2), which is not a new policy but the existing "state preserved, stack not destroyed" contract thaterrorCount > 0and a graceful interrupt already carry. The idempotent*NotFoundarms are untouched and still count asdeleted(the resource IS gone), which is why the issue's three-value'already-absent'sketch was reduced to two: no call site produces it and no consumer would treat it differently. A run with zero skips is byte-identical to before — theskippedsuffix renders only when non-zero. Every new test was mutation-probed (9 probes across the runner branch, thepreserveStateclause, the retry-loop capture, the line formatter, three provider arms and both exit-code branches; all went red). Nested stacks are covered too, which the 3-axis review caught as a HIGH gap:NestedStackProvider.deleterecursed intorunDestroyForStackand DISCARDED the child's result, so a skip inside a child re-created the identical mis-report one level up — the parent printed✓ <Child> (AWS::CloudFormation::Stack) deleted, dropped the child's row and exited 0 while the child's ownstate.jsonsat there preserved describing a live resource; it now returns{ outcome: 'skipped' }naming the child and its count. (errorCountis swallowed by the same call and is deliberately left alone — its answer is a THROW, a behavior change with its own blast radius.) Four more review findings shipped with it:ResourceDeleteResultis a DISCRIMINATED union soreasonis REQUIRED on'skipped'(a skip whose line reads a bareskippedis barely better than thedeletedit replaced) — the one guarantee no runtime test can fence, so it carries the repo's first*.test-d.ts;RUN_FINISHEDgainedcounts.skipped, without which a skip-only run recordedresult: 'FAILED'withdeleted: Nand nothing named as failed;cdkd eventsrendersRESOURCE_SKIPPEDyellow instead of the neutral cyan it shared withRESOURCE_STARTED(RESOURCE_RETAINEDdeliberately stays neutral — keeping THAT resource is the user's own instruction); and the warn text was re-scoped, because the same message is emitted by the deploy engine and rollback executor, where the record IS still dropped and the remedy it now names (cdkd state orphan) would be impossible. A review round then closed six more gaps in the same shape:NestedStackProvideralso propagates the child'sinterrupted(a SIGINT mid-child is the identical data loss reached through another field — with the child'serrorCountat 0 the parent'spreserveStateis FALSE, so it deleted the parent state.json AND the exports index and exited 0 while the child's preserved state.json described live resources);cdkd eventsnow RENDERScounts.skippedas⚠N(it was emitted but never displayed, so the text surface still showed a failed run naming nothing that failed — the symptom the field exists to remove) and renders a new per-eventreason, because the events store is the durable post-mortem and a bareRESOURCE_SKIPPEDthere cannot say why; the summary now names the state file the user must actually open, which for a nested-stack skip is the CHILD's (<parent>~<child>) rather than the parent's; the run-level exit message counts ENTRIES rather than resources, since a skipped nested-stack row is one entry however many child resources it covers and "N resource(s)" was false in exactly that case; the composite-id warning now names the plain template-removal DELETE (the most common deploy-side reacher) alongside the replacement / rollback ones; and three contract comments that still claimed a skip means "no AWS call was issued" were corrected — withNestedStackProvideras a second producer the invariant is "this row was not destroyed", and the child's siblings WERE deleted first. Deliberately out of scope, all filed:deploy-engine.ts/rollback-executor.tsdiscard the return value entirely, so the deploy-time / replacement / rollback deletes still mis-report — issue #1762; eight same-class warn-and-SKIP arms OUTSIDE the composite-id family (Lambda layer / permission, Custom Resource, IAM policy / user-group) still return barevoid— issue #1770; the child'serrorCountis still swallowed byNestedStackProvider.delete, whose answer is a THROW with its own blast radius — issue #1777; and six further delete-result discarders (thecloud-control-provider.tsself-cleanup / ASG delegation pair plus four intra-provider REPLACE paths, where a skip would orphan the old resource or leave a duplicate SNS subscription delivering every message twice) — issue #1778. None of those six is NEWLY reachable from this PR: each delegates to a provider that is not a skip producer. Together that is why this PR does not claim the whole class the issue describes is closed. - ✅
cdkd exportstops sending cdkd's composite physical id to CloudFormation IMPORT, and refuses non-importable types up front (issue #1659) —src/cli/commands/export.ts,tests/unit/cli/export-composite-identifier.test.ts,docs/cli-reference.md,docs/state-management.md,docs/troubleshooting.md,README.md. The issue's titled premise is falsified and did not ship. It namedAWS::Glue::Tableas the victim; live measurement (us-east-1, re-verified 2026-08-13) shows CloudFormation rejects that TYPE before it ever evaluates an identifier (ProvisioningType: NON_PROVISIONABLE, nohandlersblock, soCreateChangeSetanswersResourceTypes [AWS::Glue::Table] are not supported for Import), which makes all three of the issue's candidate directions dead code for it. What DID ship is the issue's direction 2 applied to the four types its sibling audit found, plus a pre-flight for the class Glue::Table actually belongs to. (a) Composite physical id, single-field CFn identifier.resolveResourceIdentifierbranched on the CFnprimaryIdentifier's field COUNT alone, and its single-field arm assumed "cdkd's physicalId IS the identifier value" — true for a scalar id, silently WRONG for a composite one, so anAWS::S3Tables::Tablewas imported as{TableARN: '<bucketArn>|analytics|events'}.COMPOSITE_ID_SPLITTERScould not fix it (that table is only consulted on the multi-field arm) and a split would not have helped anyway: the correct identifier is a different value entirely. A newCOMPOSITE_PHYSICAL_ID_IDENTIFIERStable is consulted BEFORE the arity branch, separating "cdkd's id is composite" from "the CFn identifier is multi-field", and resolves the value from the ARN the provider RECORDED —TableARNforAWS::S3Tables::Table,DataSourceArn/ResolverArnfor the twoAWS::AppSync::*children (recorded since #1681), with a bare-ARN physicalId accepted as the second shape becauseS3TablesProvider.importTablerecords exactly that for a Cloud-Control-routed table. An unrecorded ARN blocks the resource with a re-deploy-to-heal message rather than shipping a wrong identifier; a schema whoseprimaryIdentifierno longer matches the registered field refuses by name.propertiesOverlayis empty for all of them (every field isreadOnlyProperties, measured live).AWS::EC2::SecurityGroupIngressis the fourth member and is REFUSED: CFn identifies a rule by thesgr-...id AWS mints and cdkd records nothing carrying it, so the message points at #1761, which tracks the attribute-recording work inec2-provider.ts. (b) Pre-flight refusal for types CFn cannot IMPORT.NEVER_IMPORTABLE_TYPES' comment deliberately deferred to theCreateChangeSeterror; that decision is revisited, because the error is not exhaustive — a probe carrying three unsupported types named two of them, and a two-type probe named one, so the user got a fix-one-rerun loop after cdkd had already acquired the stack lock, prompted, and preprocessed the template.buildImportPlannow derives the verdict from the registry schema it ALREADY fetches for the identifier (no hand-maintained list) and blocks every offender in one pass, before the lock. The predicate requires TWO agreeing fields — noreadhandler ANDProvisioningType: NON_PROVISIONABLE— so a partial or unusualDescribeTyperesponse falls back to letting AWS answer rather than refusing an export that works today; the fallback identifier table likewise reportsunknown. Measured live:AWS::Glue::Table/AWS::Route53::RecordSet/AWS::Route53::RecordSetGroup/AWS::AppSync::ApiKey/AWS::EC2::NetworkAclEntry/AWS::SQS::QueuePolicy/AWS::SNS::TopicPolicyall satisfy both and are all rejected by AWS;AWS::S3::Bucketsatisfies neither and passes. The check runs AFTER theIMPORT_UNSUPPORTED_RECREATABLE_TYPESbranch, sinceAWS::IAM::Policyalso lacks areadhandler and cdkd has a real answer for it (pre-delete + phase-2 CREATE). One sibling splitter had to ship with it: the live test foundcdkd exportaborting on EVERY S3 Tables stack withcomposite primary identifier (2 fields: TableBucketARN, Namespace)—AWS::S3Tables::Namespacehad noCOMPOSITE_ID_SPLITTERSentry, and since CDK'sCfnTablerequires a namespace resource, theAWS::S3Tables::Tablefix above was unreachable in practice without it. It splits<tableBucketARN>|<namespaceName>in CFn identifier order and keeps the whole-mappropertiesOverlay(liveDescribeTypereports noreadOnlyPropertiesat all for the type and both fields are plain writable strings the synth template already carries). Review round 2 added three things worth naming. The recorded-attribute arm — the one that fires on essentially every export — did NO shape validation while the rare bare-physicalId fallback did, so a hand-edited row, an older binary's row holding the composite under the attribute name, or another service's ARN would each have been shipped as the IMPORT identifier and adopted the WRONG resource; both arms now share one predicate (arn:prefix + the type's own service segment + no|, since the S3 Tables composite is built FROM the bucket ARN) and both trim on return.buildImportPlanand theblockedhard-fail now run BEFOREacquireLockon the single-stack path — the docs claimed that and the code did not, so a stack that could not be exported at all first locked out a concurrentcdkd deploy; planning issues no AWS write, and the nested-stack path had always planned before locking. And--skip-import-support-preflightis the escape hatch the refusal was missing: the verdict is a registry HEURISTIC rather than AWS's published supported-for-import list, andblockedaborts the whole run, so a type AWS later makes importable would otherwise be unrecoverably blocked until a cdkd release (its neighbourIMPORT_UNSUPPORTED_RECREATABLE_TYPEShas--no-recreate-import-unsupported; this is the counterpart). Two follow-ups were filed rather than folded in: #1771 (AWS::EC2::Route/::EIP/AWS::Lambda::EventInvokeConfigare composite-identifier types with no splitter, so every VPC stack with a route is un-exportable today — the same class the Namespace entry closed) and #1772 (AWS::ApiGatewayV2::Stagenow reportsFULLY_MUTABLEwith areadhandler and a 2-field identifier, contradicting the two facts recorded as the reason it takes the downtime-incurring pre-delete + re-CREATE path; the measurement is recorded in-code and nothing is changed on it). Tests: 40 new cases, every one mutation-probed. Both arms of each polarity decision are fenced separately, including the one where the two pre-flight signals DISAGREE — no type published today has areadhandler while reportingNON_PROVISIONABLE, so the fixtures made the signals coincide and a probe showed the read-handler half was unfenced until an explicit arm was added. No CLI flag, dependency, or state-schema change. - ✅ The account-info cache stops pinning the first caller's region, and four more sites stop hardcoding
amazonaws.com(issues #1746 / #1745) —src/deployment/intrinsic-function-resolver.ts,src/provisioning/providers/cognito-provider.ts,src/assets/docker-asset-publisher.ts,src/assets/asset-redirect.ts,tests/unit/deployment/account-info-region-independent-cache.test.ts,tests/unit/provisioning/cognito-provider-url-suffix.test.ts,tests/unit/assets/partition-url-suffix.test.ts. Both are residuals of the two 3-axis review rounds on PR #1743, bundled because both land in the same cross-cutting resolver file. #1746 item 1 — the cache.cachedAccountInfostored a wholeAwsAccountInfo, so the FIRST caller'sresolverRegion(orAWS_REGION) was frozen and a later caller passing NOoverrideRegioninherited it. That was pre-existing forregionalone; PR #1743 madepartitionDERIVE from the region, so the stale region started dragging a stale partition with it — a first call from acn-north-1-scoped resolver cached{region: 'cn-north-1', partition: 'aws-cn'}and a subsequent no-override call from a us-east-1 context readaws-cn. The issue named two options; this takes the one it called cleaner, storing only the ACCOUNT.CachedAccountIdentity(accountId+ thefabricatedflag) is what the two caches now hold, andaccountInfoFor(identity, overrideRegion)derivesregion+partitionper call on the way out — for the process cache, the bounded fabricated-TTL window, AND the shared in-flight promise.resolveAccountIdentitytakes no region parameter at all, so the comment that the shared promise is safe across callers with different regions is now structural rather than a property to preserve, andwithOverrideRegionis gone (its whole job was patching a cached entry after the fact). #1746 item 2 — the guard.constructGuardedAttribute's fabricated-account refusal testedtypeof value === 'string'. Every account-bearingconstructAttributebranch returns a string today — the only non-string returns are the EC2 IPv6 CIDR lists, which carry no account — so this is hardening, not a live defect: the extractedembedsAccountIdalso walks arrays of strings, so a future list-valued attribute embedding an account cannot bypass the guard with no test failing. It is exported for its own test precisely because the array arm is unreachable through the public resolver API today. #1745 — the URL suffixes.derivePartitionAndUrlSuffix(region)returns aurlSuffixthat isamazonaws.com.cninaws-cn(andc2s.ic.gov/sc2s.sgov.govinus-iso*), and #1743 routedAWS::URLSuffix/AWS::StackId/ both ECRRepositoryUrisites through it. Four more sites still hardcoded the commercial suffix and emitted a hostname that does not resolve elsewhere — structurally valid, so nothing downstream catches it:CognitoUserPoolProvider'sProviderName/ProviderURLon BOTH create and update (fixed together, or an update would rewrite a correct suffix into a commercial-only one), all six ECR registry URIs inDockerAssetPublisher(the four push targets the issue lists, plus theecrLogincache key and the login ENDPOINT fallback the issue's grep missed — that one is whatdocker loginactually receives when AWS reports noproxyEndpoint), andasset-redirect.ts'sevaluatePseudoParam, whoseAWS::URLSuffixarm returned a hardcoded string under a comment claiming to mirrorIntrinsicFunctionResolver— a claim #1743 had just made false, so a foldedFn::Joinproduced an unresolvable host. The S3 trio is deliberately NOT in this change and #1745 stays open for it:DomainName/RegionalDomainName/WebsiteURLspan the resolver ANDs3-bucket-provider.ts, which an in-flight lane (PR #1744) owned, and the issue itself requires both halves to move together or theFn::GetAttanswer disagrees withreadCurrentState(the phantom-drift shape).WebsiteURLadditionally needs thes3-website-<region>vss3-website.<region>spelling verified per partition rather than spliced. Tests: 19 cases, every one paired with a commercial counter-case asserting BYTE-IDENTICAL output, which is what makes the change shippable without a non-commercial account to test against. The cache fix was mutation-probed by re-introducing the pre-#1746 behavior (a module-local first-region pin) into the real source: exactly the three region-inheritance rows fail and the eight others survive. No CLI flag, dependency, or state-schema change. - ✅ Every composite-physicalId decode site states the EXPECTED format (issue #1657, reported by an outside contributor) —
src/provisioning/composite-id.ts,src/provisioning/providers/{glue,s3-tables,appsync,ec2,apigateway}-provider.ts,tests/unit/provisioning/composite-id-format-message.test.ts,tests/unit/provisioning/composite-id-decode-messages.test.ts,tests/unit/provisioning/composite-id-decode-message-sites.test.ts. The behavior. A type needing more than one value to address its resource packs them into one pipe-joined physicalId, and when that id is malformed the decode site has to say what it wanted. Roughly half of them did (expected "restApiId|resourceId|httpMethod", got "..."— API Gateway Method x2, EC2 VPCGatewayAttachment / Route / SecurityGroupIngress) and half only echoed the value back (Invalid Glue Table physical ID format: <value>), which tells the user nothing they did not already have: the format is documented NOWHERE else (the companion docs gap is issue #1656), so on the silent half the message was the only possible route to the answer and it did not carry it. The change addscompositeIdFormatMessage(format, logicalId, physicalId, options?)as the decode-side counterpart of the module's existingpackCompositeId/compositeIdSeparatorRefusalpack pair, and routes all eleven previously-silent sites through it — Glue Table update + delete, S3 Tables Namespace delete, AppSync DataSource / Resolver / ApiKey update + delete (six), EC2 NetworkAclEntry delete, and theapplyTableTagsDiffarm PR review found unrouted — PLUS the five that already got it right, so those cannot drift back, and S3 Tables Table, whose hand-written expected-format text now shares the wording. A per-typeCompositeIdFormatcarries alabeland orderedsegments; every segment is a PLACEHOLDER whose name must match the type'spackCompositeIdsegment names. The first cut had a literal-segment form used exactly once, renderingAWS::EC2::VPCGatewayAttachmentasIGW|<vpcId>— andIGWis a token no packer emits (the real value is theinternetGatewayId), so the message would have instructed a user repairingstate.jsonto writeDetachInternetGateway(InternetGatewayId: "IGW"). Two independent reviewers caught it; the literal form was DELETED rather than corrected, and the segment names now mirror the packers (ipProtocol,namespace).alsoAcceptscoversAWS::S3Tables::Table's bare-ARN alternative. It is a WORDING change only, deliberately. Each caller keeps its own split-and-guard: the arities genuinely differ (AppSync Resolver and EC2 NetworkAclEntry accept AT LEAST three segments; the others destructure the first N and tolerate extras — the #1672 ambiguity, tracked separately and claimed by another lane), so folding them into one predicate here would silently change which ids deploy while claiming to fix a message. The issue's secondary observation — five of the sites arelogger.warn+returnon the DELETE path, so a malformed record leaves the AWS resource ALIVE whilecdkd destroyreports success — is answered without changing the warn-and-continue policy: those sites pass{ skipping: true }and the warning now says the resource is LEFT IN PLACE and the destroy will still report success, so the orphan is diagnosable rather than invisible. Two rows of the issue's own table were already stale and the PR says so rather than inventing work:AWS::S3Tables::Tablehad gained its expected-format text, androute53-provider.ts's message no longer exists at all — PR #1678 removed that arm in favour ofparseRecordSetCompositeIdplus a template-derived fallback, so there is nothing left to fix there. Tests: 24 new cases across three files — the builder's own rendering (the single-segment no-dangling-separator case, blank-value quoting, and the skip clause's presence / absence), six behavioral cases driving the realAppSyncProviderthrough both the throw and warn arms, and a per-SITE suite pinning the EXACT message at each of the eight sites that had no behavioral coverage — which is what makes a copy-pasted format constant detectable, since review proved a constant swap previously passed the whole suite. Each arm is paired with a well-formed counter-case proving the guard still only fires on malformed input; three existing assertions that pinned the old wording were updated. Mutation-probed twice against the real source: restoring the pre-#1657 literal atdeleteResolverfails exactly that row, and swapping the Route site's constant for the SecurityGroupIngress one fails exactly the Route row. No CLI flag, dependency, or state-schema change. - ✅
Fn::Substops laundering resolver REFUSALS into a literal${X.Y}, andgetAccountInfostops fabricating a partition (issues #1740 / #1730) —src/deployment/intrinsic-function-resolver.ts,src/utils/error-handler.ts,src/provisioning/cloud-control-provider.ts,tests/unit/deployment/intrinsic-sub-getatt-refusal.test.ts,tests/unit/deployment/account-info-partition-and-cache.test.ts,tests/unit/provisioning/cloud-control-fabricated-account-arn.test.ts. Bundled because both land in the same cross-cutting resolver file, which cannot host two parallel lanes. #1740 — theFn::Subcatch.${LogicalId.Attribute}resolves throughresolveGetAttinside a barecatch, so EVERY error was downgraded to a warning and the raw${...}text kept. Warn-and-keep is the deliberate answer for a genuinely unknown variable; it was also, silently, the answer for the three refusals the resolver raises ON PURPOSE —guardedPhysicalIdFallback's*Arn/*Urlshape hard-fail (the #1103 class), the--strict-getattrejection, and #1729'srejectPlaceholderArnAttribute. So the identical reference hard-failed in a resource property and degraded to a literal${Resource.Attribute}shipped to AWS by a green deploy when written inside anFn::Sub, with a warning (not found) naming the wrong cause and discarding whatever remedy the refusal carried. Now those three sites throwIntrinsicResolutionRefusalError(a newCdkdErrorsubclass whose ONLY consumer is this catch) and the catch re-raises that class; a genuine miss still keeps the placeholder, and its warning carries the underlying reason instead of assertingnot found. TheRefarm re-raises a refusal only when the variable has NO.— with one,Refis a speculative first attempt whose miss is how theGetAttinterpretation is reached. Where the intrinsic sits still decides what the user sees, and the PR body says so rather than over-promising "it now fails": in a resource property the throw fails the resource, while in a stack Outputdeploycatches it per-output and exits 0 withkeeping the previously persisted outputs(the pre-existing best-effort Output policy, deliberately unchanged — the win there is that the output is not UPDATED with a wrong value, loudly). #1730 —getAccountInfo. Two independent defects. (a)partitionwas hardcoded'aws'with an in-code comment admitting it (// Could be aws-cn, aws-us-gov, etc.), on the success AND the STS-failure path, so every ARN built from it was wrong outside the commercial partition — this module's ownFn::GetAttconstruction plus fourCloudControlProviderenrichment sites (KMS key, ECR repository x2, Kinesis stream) — and structurally valid, so nothing downstream could catch it. It now derives through the sharedderivePartitionAndUrlSuffix(region), INCLUDING on the paths that swap in a caller's override region:partitionis a function ofregion, so a cached entry handed back under acn-override would otherwise carry the partition derived for the cached one (withOverrideRegion, and the review-caught case a naive fix misses). PR #1727's workaround can now read the field, though preferring the helper where a region is in hand still avoids an STS hop. (b) The FABRICATED result (the hardcoded123456789012fallback) was cached for the process, so ONE transient STS blip poisoned every later caller in the run. Only a non-fabricated answer is cached now, mirroringwrite-only-properties.ts's "only SUCCESSFUL lookups are cached"; an operator-suppliedAWS_ACCOUNT_IDis a real answer to "which account" and is still cached as one. The fourCloudControlProviderARN sites additionally OMIT the attribute whenfabricatedis set (accountInfoForSynthesizedArn), mirroringAppSyncProvider.childImportAttributesfrom #1728 — an ARN built from the placeholder account carries no wildcard, so #1681'sisPlaceholderArncannot catch it and the value would be RECORDED into state and served as the resource'sFn::GetAttanswer, indistinguishable from a real one; the resolver's ownguardedPhysicalIdFallbackthen hard-fails an*Arnread with a message naming the cause, and the record heals on the next update. Directions (1) and (2) of #1730 — making the fabricated ACCOUNT ID itself loud, or refusing the deploy outright — remain open, so that issue is NOT auto-closed. The 3-axis review round found one BLOCKER and it is the most instructive part of this entry: the resolver-side refusal matched the colon-delimited:<accountId>:an ARN uses, butAWS::ECR::Repository'sRepositoryUriembeds the account with NO colons (<acct>.dkr.ecr.<region>.amazonaws.com/<repo>) — the single such site inconstructAttribute— so the resolver served the fabricated URI and silently NULLIFIED theCloudControlProvideromission of that same attribute. The match is a bare substring now, deliberately fail-SAFE (a value merely CONTAINING the placeholder digits is refused rather than served, and only while STS is failing). Review also caught a regression this change introduced: dropping the fabricated cache entirely madegetAccountInfore-issueGetCallerIdentity— with the SDK's own 3-attempt retry, plus one warning — on EVERYFn::GetAttand everyAWS::AccountId/AWS::Partition/AWS::StackIdpseudo-parameter, i.e. hundreds of calls on a large stack. A fabricated answer now gets a bounded 10s window (accountInfoClockis the test seam) instead of nothing, PLUS a sharedaccountInfoInFlightpromise so the parallel case collapses too — the TTL alone only helps SEQUENTIAL callers, andcdkd deploy --concurrency 10resolves ten resources' intrinsics at once. Together that is what "not cached the way the success result is" actually needs to mean. Two more review items rode along: ECR's registry host (<acct>.dkr.ecr.<region>.amazonaws.com) hardcoded the URL SUFFIX at both the resolver andCloudControlProvidersites —amazonaws.com.cninaws-cn, the identical defect one field over from the partition, at the very attribute the blocker was about — andAWS::URLSuffixwas issuing agetAccountInforound trip for a value that is a pure function of the region. Three smaller review items shipped in the same round:AWS::StackIdandAWS::URLSuffixwere the identical hardcoded-partition defect one site over (arn:aws:cloudformation:…and'amazonaws.com'inaws-cn) and now derive; a deadwithOverrideRegioncall on the fresh-resolve path was removed (regionis alreadyoverrideRegion || …, so the branch could never be taken — and one of the claimed mutation cuts was therefore not probing anything); and two comments insrc/utils/aws-partition.ts/appsync-provider.tsstill asserted the field this change fixes is hardcoded, which reads as a license to revert it. Tests: 60 cases across four files, each paired with counter-cases (a valid${X.Y}still substitutes; an unknown attribute with noArn/Urlsuffix still falls back to the physical ID; a real account still enriches, including acn-north-1ARN underaws-cn), since a test that only asserts the refusal cannot tell a precise guard from one that broke the deliberate warn-and-keep. Mutation-probed against the real source in TWELVE separate cuts — the twoinstanceofre-throws, the success-path / override-path / failure-path partition derivations, the fabricated-caching skip, the CC refusal, theconstructAttributerefusal, the colon-vs-bare account match, the TTL window, theStackId/URLSuffixderivations, the dotted-branch warning wording, the ECR URL-suffix derivation and the in-flight dedup — every cut fails only its own rows and every counter-case survives. No CLI flag, dependency, or state-schema change. - ✅ The nested-key critic now audits the three MIXED-CASE SDK clients (issue #1393 item 3) —
scripts/gen-nested-key-coverage.ts,tests/fixtures/cfn-schemas/AWS-{Events-Rule,Scheduler-Schedule,Glue-*}.json,docs/_generated/{nested-key,enrichment,sdk-attr}-coverage.{json,md},tests/unit/scripts/gen-nested-key-coverage.test.ts. Item 1 of #1393 namedclient-eventbridge/client-scheduler/client-glueas the evidence that a single per-targetkeyStylehides camelCase ISLANDS inside a PascalCase model (awsvpcConfiguration,capacityProvider,type/field/expression,scanRate/scanAll) — and the deeper gap was that those types were not audited at all, so the #1381 EventBridge / Scheduler Fargate-target create failures and the Glue CrawlerscanRatedrop had no fence behind their fixes. Nine types are now registered asNESTED_KEY_TARGETS, following the shape PR #1694 set forAWS::Lambda::EventSourceMapping:AWS::Events::Rule(76 audited paths),AWS::Scheduler::Schedule(47), and the seven auditable Glue types —::Table(88),::Crawler(46),::Connection(37),::Trigger(16),::Database(15),::SecurityConfiguration(9),::Job(7). 341 new audited paths, 0 blocking findings, 0 allow-list entries: the 19 non-same-spelling verdicts are allprovider-handled, each verified against the conversion that handles it rather than taken on the literal's word (toSdkEcsParameters/toSdkTargetfor the ECS-target islands,CFN_TO_SDK_DYNAMODB_TARGET_KEYSforScanRate,buildEncryptionConfigurationfor the CFn-pluralS3Encryptions→ SDK-singularS3Encryptionrename, andenforceIcebergTableInputAbsentforOpenTableFormatInput.IcebergInput.IcebergTableInput, where the handling is a pre-flight REFUSAL rather than a conversion).keyStyle: 'exact'is the load-bearing declaration: it is what routes each island to the case-insensitive near-miss bucket instead of lowercasing every PascalCase member past the test, which is the item-1 blind spot. None of the nine opts into the write-evidence pass yet, and the calibration test pins the measured forced-on numbers (Table36,Database11,Trigger9,SecurityConfiguration2, the other five 0) — but review established those four do NOT mean the same thing, and the first cut of this entry wrongly claimed they did.TableandSecurityConfigurationare builders that DO name their full member set, andTriggeris not a builder at all — itsPredicate/Actions/EventBatchingConditionare verbatimproperties['X'] as <SdkType>forwards, so the write pass has nothing to ask of it. (Stating one shape for the whole file was wrong twice: a blanket "they forward whole blobs" first, then a narrower sentence that still mis-filed Trigger. The file genuinely mixes both.)Database11 is a REAL SILENT DROP:buildDatabaseInputis a fresh-object builder naming onlyName/Description/LocationUri/Parameters, while CFn and the SDK'sDatabaseInputboth also declareTargetDatabase,FederatedDatabaseandCreateTableDefaultPermissions— none of which appears anywhere insrc/, so a template setting any of them is dropped today. Same spelling on both sides means the KEY pass structurally cannot see it (this is #1393 item-5 territory); only the write pass can. The opt-in is deliberately deferred to the provider fix rather than shipped with 11 allow-list entries, because an allow entry would silence a CI-blocking bucket for a live drop — the one thing this critic's design refuses. The 11 paths are instead pinned BY NAME inGLUE_DATABASE_DROPPED_PATHSplus a test, so fixing the provider fails that test and forcesfreshObjectMapper: truein the same change.AWS::Glue::Workflowis the one registered Glue type deliberately left out — its schema has no nested property at all, so a target would carry aminNestedKeysfloor of 0 and fence nothing. Its fixture was re-captured for this: review caught that assertingnestedPropertyPaths === undefinedagainst the STALE capture was unfalsifiable, since that field did not exist before 2026-08-10 and every stale fixture lacks it regardless of shape. The test now dates the capture past both extensions (generatedAt >= '2026-08-10') and asserts#topisdefinitionShapes' only definition — the latter is the real discriminator, sincedefinitionShapesalone proves nothing (it landed 2026-08-09, one day beforenestedPropertyPaths, andAWS-ECS-Cluster.jsonis a live capture with the first and without the second). A committed sibling control additionally guards against a broken re-capture sweep. Prerequisite worth repeating from #1694: opting an EXISTING type in needsnode scripts/refresh-cfn-schemas.mjs <TypeName>with an explicit type argument —--only-missingskips any type that already has a fixture file, and the older captures predate thedefinitionShapes/nestedPropertyPathssections the generator requires. Two false gaps disappeared as a side effect of the re-capture, the same way they did in #1694: the fixtures now carryprimaryIdentifier, whichgen-enrichment-coverageandgen-sdk-attr-coverageauto-classify as not-a-gap, soAWS::Events::RuleArnand theGlue::{Connection,SecurityConfiguration,Table}Idattributes stop being reported as SDK-fallback gaps they never were (98 down from 102). Three known BOUNDS are recorded in the header and pinned by tests rather than left for a reader to discover. (i) The seven Glue targets share one 8-class provider file, so the file-global literal rescue draws on a pool spanning seven resource types — demonstrated by injection: aScanRateunder::Jobis cleared by the CRAWLER class's literal and anIcebergTableInputunder::Crawlerby the TABLE class's, while a control name correctly flags, and the pool sizes are ASSERTED in the test rather than written in prose (an earlier revision exported a descriptive string carrying them, which nothing read and which would have drifted the first time the file grew; it is gone). Scoping the pool to the enclosing class is NOT done here:collectStringLiteralsis consulted by all 24 targets, including the 14 opted-in ones whose floors and all 22 allow-list entries were calibrated under file scope, so it is a re-measurement of the whole table — and it is precisely the un-scoped remainder of #1393 item 2, which PR #1574 closed only for write-evidence targets. (ii) The key pass matches SDK member names flatly, so CrawlerScanAll(onlyMongoDBTargetspells it PascalCase) and RulePlacementConstraints.Type/PlacementStrategies.Typeare cleared by unrelated interfaces and visible only in the shape pass. (iii) The shape backstop is not total: six CFn definitions on this group have no same-named SDK interface (CrawlerTargets, DatabasePrincipalPrivileges, JobDefaultArguments/NonOverridableArguments, TableIcebergTableInput/SerdeInfo) and are audited by neither pass — pinned exactly so the set cannot grow silently. (iv) The critic fences literal PRESENCE, not conversion CORRECTNESS, and this is the widest of the four:provider-handledmeans the provider NAMES the CFn spelling somewhere the literal collector can see, and nothing checks what it then does with it. Measured on the real tree — replacingEventBridgeRuleProvider.renameItemKeys's whole body withreturn item, so every ECS-target island stops being converted while every call site keeps naming the keys, leaves the shipped--checkat exit 0. The provider-side RED probes therefore delete the CALL (or the whole rename map) rather than the conversion body. The same bound is why the Glue probe must delete BOTH rename directions: the reverse map keeps the CFn spelling alive as a literal, which is the #1448 reverse-map asymmetry (the write pass excludes theread*/*ToCfnfamilies, the literal collector does not) — explicitly NOT bound (i), since both maps are module-level consts in the same file and class-scoping would not change it. Closing (iv) needs the shape-aware v2 of #1378. Tests: 9 real-repo assertions (per-type headroom and zero findings in each blocking bucket; the write-pass opt-out and theexactkeyStyle; the per-path island credits including theScanAllkey-vs-shape split; a per-BLOB path floor for all 21 blobs of the nine new targets (21 is the blob count; 24 is the TARGET count, and all 24 targets have 115 blobs, which this fence does not claim), since the aggregate floor lets::Crawlerlose all four non-Targetsshapes and::Schedulerits wholeFlexibleTimeWindow; a SECOND-LEVEL family floor for all 42 composite families, because the blob floors themselves carry 8-14 paths of slack on the three big blobs (Rule.Targets74/60,Scheduler.Target45/36,Crawler.Targets40/32) and an entire family fits inside it — including all eight crawler-target families, of which the 4-pathTargets.DynamoDBTargetsis thescanRateisland's own home; the 11 pinned Glue Database drops; theunmatchedDefinitionsset; the literal-bleed bound; and the Workflow exclusion) and 10 RED probes: 7 throughloadReport'sfixtureDirseam covering both blocking buckets across all three clients, and 3 through the SHIPPED--checkagainst a scratch copy of the REAL providers tree — deleting EventBridge's and Scheduler'sCapacityProviderStrategylowercasing and Glue'sscanRaterename bridge each exits 1 naming the re-bucketed paths. The provider-side trio is what proves theprovider-handledcredits above, per the repo rule that a checker must be shown to FAIL against real code; the fixture-seam probes alone are the weaker form. Tooling-only, nosrc/**diff, so no live-AWS test applies. Still open on #1393: item 5 (needs the shape-aware v2 of #1378), and item 3 for the remaining candidate types —AWS::EMR::Cluster(+ InstanceGroupConfig / InstanceFleetConfig) is blocked on a real prerequisite, since PR #1399 moved its conversion intosrc/provisioning/emr-configuration.tsand the key pass's literal scan reads onlyproviderFilewithout following same-package helper imports, soConfigurationProperties/StepPropertieswould false-positive; plus CodeBuild siblings, DynamoDB GlobalTable, WAFv2, ECR, S3Vectors and Cognito. - ✅ Three partition-sensitive PREDICATES stop hardcoding
arn:aws:(issue #1815, the remainder after PR #1834's seven ARN builders) — unlike the seven builders, these change BEHAVIOR rather than a recorded string, and all three CLASSIFY an ARN the caller already holds, so the partition is read OFF THE ARN (aws[a-z0-9-]*, the shapeIAM_ROLE_ARN_REinsrc/utils/role-arn.tsalready uses) rather than derived from a region throughderivePartitionAndUrlSuffix— that helper answers "given a region, which partition am I in", which is the right question when BUILDING an ARN and the wrong one here, where no region reaches the call site at all. A closed partition list is exactly what goes stale when AWS adds a partition. (1)CustomResourceProvider.isSnsServiceTokenis the SNS-vs-Lambda ROUTING decision for the whole provider — it gatessendRequest(SNS publish + S3 poll vs synchronous Invoke), the delete path's backing-Lambda pre-check andrecycleBackingFunctionExecEnv— so anarn:aws-cn:sns:/arn:aws-us-gov:sns:ServiceToken was classified Lambda-backed and every one of those paths took the wrong branch. (2)IAMManagedPolicyProvider.import's AWS-managed-policy refusal keyed onarn:aws:iam::aws:, and AWS-managed policies exist under every partition (arn:aws-us-gov:iam::aws:policy/AdministratorAccessis a real, attachable ARN), so a GovCloud / ChinaAdministratorAccessfell through and was ADOPTED as customer-managed —cdkd destroywould then rundetachAllPrincipalsagainst every user / role / group in the account beforeDeletePolicywas (always) rejected; the miss direction here is the dangerous one. (3)lambda-eventsource-provider.ts'sclassifyEventSourcehand-enumeratedarn:aws:andarn:aws-cn:per service and so returned'unknown'for GovCloud, all four iso partitions andaws-eusc—'unknown'is in neitherKINDS_WITH_FUNCTION_RESPONSE_TYPESnorKINDS_WITH_SOURCE_ACCESS_CONFIGURATIONS, so the type-discriminator gating silently stopped emitting its placeholders andcdkd driftsaw a one-sided difference on every clean run; the per-servicestartsWithchain is replaced by one service-segment capture plus a lookup table. 24 new unit cases, every partition per site, each with the commercial (and for site 3 theaws-cn) counter-case asserted unchanged — a commercial-only test cannot detect any of the three — plus non-ARN / wrong-service negative cases so the widened partition segment cannot start matching things that are not event sources or SNS topics. Binding verified by restoring each hardcoded literal in turn and confirming only the corresponding cases go red.glue-provider.ts:2141/:2709stay open on the issue (owned by a concurrent lane).
Recently Implemented (2026-08-12):
- ✅ CI teardown-completeness guidance:
--remove-protection/--skip-final-snapshot/--purge-events(skill v0.6.0) —README.md(CI section),plugins/cdkd-skills/skills/cdkd/SKILL.md, manifests bumped to 0.6.0. The CI section's destroy-on-close flow silently left three kinds of residue after the close job: protection-enabled resources (RDS / DynamoDB deletion protection, EC2 termination protection, etc.) survive until the scheduled sweep;DeletionPolicy: Snapshotresources leave a final snapshot behind on EVERY close (cost + clutter that accumulates per PR); and the deployment-event history keeps the state bucket non-empty. Both the README (linking the--remove-protectionandDeletionPolicy: Snapshotsections) and the skill's CI bullets now cover the full teardown:--remove-protectionfor one-pass deletion,--skip-final-snapshotwhen the environment's data is disposable (the skill gates it on explicit user confirmation — it is a data-loss opt-out), anddestroy --purge-events/events prune <stack> --allfor a fully empty state bucket. Surveyed the full destroy option surface for completeness:-f/--forceis confirmation-skip only, and no DeletionPolicy-Retain override flag exists (template-side control by design), so nothing else belongs in the CI guidance. - ✅ The
AWS::DynamoDB::TableBillingMode warning stops asserting an unchecked mode, and names its resource (issue #1734) —src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-billing-mode-shape.test.ts. Found by the round-5 test review of the PR closing #1683, which fixed both shapes inDynamoDBGlobalTableProviderand left the siblingAWS::DynamoDB::Tableprovider — same two shapes, different path — out of scope. Before: the update-pathrequireConfigStringonUnusablearm said "The table's current billing mode (X) is kept", where X isprevBillingMode— the RECORDED previous, not aDescribeTablereading. For a record AWS has drifted away from (an out-of-band re-price) that names a mode the table does not have, while reading as though it had been checked. Review correction: the first cut of this entry also claimed X is the CFn TYPE DEFAULT when the recorded previous is ABSENT. That is FALSE and the reviewer measured it — the arm sits inside theproperties['BillingMode'] !== undefinedbranch, so the desired side is always defined when the message fires, andprevBillingModetherefore resolves to the record (when usable) or the table's LIVE mode (when absent or unusable). The type-default arm needs an absent desired side and is unreachable from here. So the stale record was the only wrong route — which is still a real one, and still what the reword fixes. It also carried no logical id, while all FOUR other prefixed arms in the same provider do (the recorded-previous baseline warn, the BillingMode-flip refusal, the GSI-removal guard, the capacity guard), so on a stack with more than one table neither the user nor an integ assertion could tell which one warned. (An earlier draft of this entry said "both sibling arms ... the GSI and stream guards"; there is no stream guard in this provider, and the count was wrong — a review nit worth recording because it is the same accuracy class the change itself is about.) Now it readsAWS::DynamoDB::Table <logicalId>: … The mode this update compared against (X) is kept …, which is true on every branch. Message-only — no behavior change, and the existing suite already exercises both polarities, so no real-AWS run was needed. The two pinned assertions were updated and a FENCE for the prefix itself was added, anchored at the string START and carrying the BillingMode sentence: neither half fences it alone, since a bareAWS::DynamoDB::Tablesubstring also matches the guard's own message text and a bare prefix match would be satisfied by either sibling arm. Mutation-probed by stripping the prefix from the real provider — 6 rows fail. - ✅ The two #1681 residuals: an imported AppSync child records its ARN, and
Fn::GetAttstops serving a pre-#1681 placeholder (issues #1728 / #1729) —src/provisioning/providers/appsync-provider.ts,src/deployment/intrinsic-function-resolver.ts,docs/state-management.md,tests/unit/provisioning/appsync-child-ref-arns.test.ts,tests/unit/deployment/intrinsic-functions.test.ts. Shipped as ONE lane because both land in the same two test files and both are residuals of the same fix. #1728 — import.AppSyncProvider.import()returned{ physicalId, attributes: {} }for every non-GraphQLApitype, so a child adopted withcdkd importhad no ARN in state, the resolver'sREF_RETURNS_ARN_FROM_STATElookup missed, andReffell back to the raw compound id — the pre-#1681 behavior — withFn::GetAtt DataSourceArn/ResolverArn/Arnequally unresolved until the resource's next update (#1727) healed the record. It now calls the SAMEchildRefAttributesmappingcreate()/update()use, so the three spellings cannot drift, and records the COMPLETE set (Name/ApiKeytoo —importwrites the map outright, so a partial answer would leave those unresolvable as well). RECONSTRUCTED rather than read back: every segment is already in the supplied composite id,importadopts in the caller's own account and region, and the reconstruction costs one process-cached STS call instead of a Describe per imported child. It never throws — a failure warns and degrades to{}, i.e. exactly the pre-fix behavior, because adopting the resource is worth more than its bookkeeping attribute; a mis-arity id degrades the same way rather than guessing. Two of those degradations were review findings, and the first is the one that mattered: atrydoes NOT cover the credentials failure, becausegetAccountInfoCATCHES its own STS error and returns the hardcoded123456789012— so nothing throws, and an ARN built from it would be PERSISTED while carrying no wildcard forisPlaceholderArnto catch downstream, i.e. strictly worse than recording nothing.AwsAccountInfogained an additivefabricatedflag — set on the STS-failure arm AND on a SUCCESSFUL call that carries noAccount, which lands on the identical hardcoded id (absent otherwise, so no existing consumer changes; the fabrication itself remains issue #1730). The refusal lives inbuildAppSyncArn, NOT at the import call site, because a second review round showed the UPDATE path is the worse one:childRefAttributesrebuilds the ARN on every in-place update and anupdate()attribute map REPLACES the record's wholesale, so an STS blip mid-deploy would OVERWRITE a correct create-time ARN — destroying a known-good value where import merely fails to write a missing one. Each caller then takes its own answer: create warns and omits (unchanged), update reports NO attributes so the engine carries the existing ones forward, and import keeps the account-INDEPENDENT keys (Name/ApiKeycome out of the physical id) while dropping only the ARN. The second: the ARN is now built fromResourceImportInput.region— the regionimportkeyed the STATE RECORD by — rather than the provider client's own config, which can differ whenAWS_REGIONis unset and a profile region is set. #1729 — GetAtt.RefandFn::GetAttread the same cached attribute and #1681 guarded only theRefhalf, so a child created by a pre-#1681 binary still resolved{"Fn::GetAtt": ["MyDataSource", "DataSourceArn"]}toarn:aws:appsync:*:*:apis/...— structurally valid, unusable, indistinguishable downstream from a real ARN.rejectPlaceholderArnAttributenow refuses it, scoped to theREF_RETURNS_ARN_FROM_STATEtypes AND their declared ARN attribute names (the issue's narrowest direction): a wildcard-bearing ARN is only KNOWN to be a placeholder for those three attributes, and another type could legitimately cache an ARN-shaped string carrying*in a position the predicate inspects — soAWS::AppSync::ApiKey.ApiKey,AWS::AppSync::DataSource.Nameand every non-AppSync*Arnare untouched, each pinned by a test. It THROWS where theRefhalf degrades, and that divergence is the decision:Ref's fallback is the raw compound id, while this value is requested under an ARN-suffixed name, so serving a non-ARN would be exactly the shape mismatchguardedPhysicalIdFallbackalready hard-fails on (the #1103 class — a green deploy shipping a wrong value into a stack Output). The error names the cause and the remedy (re-deploy; the next in-place update heals the record). Both fixes mutation-probed: disabling either makes exactly its own three rows fail. - ✅ S3 inventory / analytics: the destination SHAPE is normalized, the recording fold gets its
canonicalizeDesiredPropertiestwin, and the CREATE-path empty-collection skip stops being silent (issues #1707 / #1717 / #1718) —src/provisioning/providers/s3-bucket-provider.ts,.claude/rules/providers.md,tests/unit/provisioning/s3-bucket-provider-substituted-properties.test.ts,tests/unit/provisioning/s3-bucket-provider-shape-and-empty-skip.test.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts. Three residuals of the (#1686) / (#1671) / (#1670) PRs, bundled because all three land in the same provider and the twin is shared. Before: (a) the appliers accepted the destination block in two spellings (the flattened CFn{BucketArn, …}and the nested SDK{S3BucketDestination: …}) plus aBucket/BucketArnalias, and recorded it at the branch the TEMPLATE declared — whileinventorySdkToCfn/analyticsSdkToCfnemit only the flattened form withBucketArn, so a record in either tolerated spelling could never match the readback andcdkd driftre-reported it forever with no warning anywhere (nothing is malformed, nothing is substituted); (b) the (#1686)Schedule->ScheduleFrequencyrecording fold shipped without itscanonicalizeDesiredPropertiestwin, so an UNCHANGED template redeployed as1 to updateforever (measured us-east-1, 2026-08-12); (c)applyAllSubConfigsForCreateskipped an empty lifecycle / CORS collection in SILENCE, so a fresh bucket came up without a declared configuration and nothing said so; (d)Enabled/IncludedObjectVersions/OutputSchemaVersionwere always SENT (and always read back) but recorded only when substituted, so an item omitting one recorded fewer keys than the readback produces and the whole array compared unequal. Now two module-level pure folds —effectiveInventoryItem/effectiveAnalyticsItem, over a sharedeffectiveS3BucketDestination— are the ONE helper both the appliers (called with the values that went on the wire) and the newS3BucketProvider.canonicalizeDesiredProperties(called with none) use, so state and template can never be folded to different keys; the destination normalizes wholesale to the flattened CFn spelling, which supersedes (#1670)'s write-back-at-the-declared-branch decision by dissolving its reason (no other key survives to carry the malformed value); the defaulted-but-sent members are recorded and defaulted on BOTH diff sides; and the create-path skip warns by name. The (#1717) obstacle did not reproduce and that is stated rather than omitted: the issue measured the twin makinggen-nested-key-coveragereport theInventoryConfigurationssegmentRenamesentry STALE, and with the folds written as module-level functions all three passes report 0 divergences, the committed matrix does not drift a byte, and the entry is still inusedSegmentRenames— no entry removed, no staleness check widened. What did move is that file's withdrawn-name measurement (Enabled/ScheduleFrequency/BucketArnleft the reverse-map withdrawal set); the hazard that would matter — a RECORDING write vouching for a forward mapper that stopped writing the SDK member — was measured away per-name with real-code probes: deleting the wire write forIsEnabled/Bucketfrom a scratch copy of the real provider still fails the critic by name. The fold is keyed off the DECLARED shape, never off a refusal, so (#1670)'s finding 3 survives: a malformed value passes through intact andcdkd diffkeeps reporting it until the template is corrected. The (#1718) sibling audit the issue asks for was run and is not uniform: intelligent tiering defaultsStatuson the wire and reads it back, so it gets the same fold (effectiveIntelligentTieringItem, nosentparameter — that applier has no warn-and-substitute arm at all, since a malformedStatusSKIPS the item per (#1595)); metrics defaults no scalar member and needs none, which is now fenced by a test rather than left as a claim in a comment. Review of this PR caught one real defect before merge: the folds first resolved every default with??, which reads a DECLAREDnullas absent and would have folded the template side onto the substituted value — concealing the malformed value whose warning is the user's only signal, i.e. exactly what (#1670)'s finding 3 refused a twin over. Defaults now resolve by PRESENCE (key in container), the two-source schedule read prefers a present-but-malformed first source, and three fence rows use the NULLISH spellings specifically (every malformed-value row written earlier used a blank string / array / intrinsic, all non-nullish, so all of them passed against the broken fold). Each fence was mutation-probed. The 3-axis review then found a SECOND route to the same concealment, this one INTRODUCED by the first cut: a malformedScheduleCONTAINER ('Weekly'/[]/42) fell through to the default AND deleted the key, while the applier SKIPS such an item — so the folded desired side could compare EQUAL to the retained previous item and the skip warning would stop. A declared-but-unfoldable container is now left completely alone. The review also retired theonSubstitutedcallback (all three call sites had been left passing a comment-only no-op, so the helper ran a secondconfigStringRefusalper read to invoke nothing), corrected a stale paragraph that still said these sites take NO twin, narrowed the create-path announce gate to!= null, and fenced themetrics-needs-no-fold conclusion against the WIRE rather than against the absence of a fold. Two residuals were filed rather than ridden along: the notification / lifecycle alias families that (#1707) also names (issue #1748, sinceCloses #1707would otherwise orphan them) and the pre-existingEnabled: nullcoercion (issue #1751, where two reviewers proposed opposite fixes). The now-unusedwithDeepValue/withoutKeyhelpers were deleted with their rationale carried onto the folds. - ✅ The two replay-CREATE
AWS::DynamoDB::GlobalTablearms #1683 left unanswered now record what they SENT (issues #1724 / #1726) —src/provisioning/providers/dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-replay-create-effective-props.test.ts,tests/unit/provisioning/dynamodb-globaltable-provider-sibling-effective-properties.test.ts. PR #1722 answered three arms of the "state records something other than what was SENT" class and NAMED these two rather than fixing them, so both are closed here from the same measurement. (1) The GSI omit now DROPS the key (#1724). On a state replay a malformedGlobalSecondaryIndexeswarns andtoSdkGlobalSecondaryIndexesreturns the well-defined EMPTY list, soCreateTablegoes out with NO indexes — while the engine recorded the malformed desired blob, a recordreadCurrentStatecan never match and the next update reads as its previous side (the #1552 class). Nothing was applied, so the answer is the.claude/rules/providers.mdreplay-CREATE + SKIP row: drop the key. Deliberately NOT the SUBSTITUTE shape its two siblings take — those APPLY a default, so what they record is what they sent; which arm you are on is a property of the GUARD, not of the path. Only the TOP-LEVEL key is dropped: aReplicas[].GlobalSecondaryIndexesoverride is a separate template key this guard never read, and its wiring runs on its own rather than offsdkIndexes. (2) TheBillingModesubstitution now strips the capacity blocks it never sent (#1726). SubstitutingPAY_PER_REQUESTalso skipscreateParams.ProvisionedThroughput, hands the SUBSTITUTED mode to the GSI translator (so every PROVISIONED-only per-index member is dropped before the call), and skips auto-scaling registration — yet onlyBillingModewas rewritten, leaving the same permanent phantom drift the arm exists to remove, one key over. Which members are safe to strip was the open question, and the answer comes from whatreadCurrentStateemits for a PAY_PER_REQUEST table, per member, not from the CFn schema: the top-levelWriteProvisionedThroughputSettingsemits{}and the per-replica / per-index / per-replica-index blocks are omitted — every one of those emissions is type-discriminator-gated onbillingMode === 'PROVISIONED'in that same method. So the new purestripProvisionedCapacityKeysremoves exactly that set and deliberately PRESERVES the on-demand ceilings, which go on the wire under this very mode (OnDemandThroughputis attached when the mode is not PROVISIONED). It is non-mutating at every nesting level, because the replay caller's bag ISpreviousState.propertiesand the rollback executor spreads the answer shallowly. NocanonicalizeDesiredPropertiestwin is needed for either arm, and the reason is the arms' own rather than an inherited carve-out: the bag ALREADY records aBillingModediffering from the declared one, so the next deploy ALREADY classifies an in-place UPDATE — the strip folds into that same UPDATE instead of manufacturing a new one, and the update path re-sends the capacity settings once the template's mode is corrected. Tests: 12 new cases plus an INVERTED pre-existing pin — the sibling suite carried a test asserting the OLD behavior ("KEEPS the PROVISIONED-only capacity blocks"), written expressly so that changing the answer had to change the test; it now asserts the strip and that the substituted mode survives it. Three mutation probes, each fencing a different way to get this wrong: reverting the strip fails 3 cases, disabling the GSI drop fails 3, and a BLANKET strip that also removes the on-demand ceilings passes every other assertion and fails exactly the preservation case. The replica-override and valid-blob rows fence the two over-reach directions. Residual, filed rather than silently carried: the UPDATE-side twin — a suppressed billing flip fires no capacity call either, yet still records the declared blocks — is #1738, and the create-side answer does NOT transfer to it (the kept mode can be either value, and the resource already exists, so the rules file's retain-the-previous-value row applies rather than the drop). No CLI flag, dependency, or state-schema change. - ✅
AWS::Route53::RecordSet's three composite-id packers join the #1672 refusal (issue #1711) —src/provisioning/providers/route53-provider.ts,src/provisioning/composite-id.ts(header),tests/unit/provisioning/composite-id-provider-guards.test.ts. Before: the #1672 sweep adoptedpackCompositeIdat every composite packing site across glue / s3-tables / appsync / ec2 / apigateway / lambda-event-invoke-config and deliberately skippedroute53-provider.ts, which packs${hostedZoneId}|${recordName}|${recordType}at three sites (create, update, import). The reason was CONCURRENCY, not a technical one — a parallel lane (#1702) owned the file while the sweep was in flight — and the exclusion was backwards on the merits:recordNameisproperties['Name'], the only TEMPLATE-chosen segment the sweep left unguarded, while the segments it DID guard as uniform defense-in-depth (sg-…,igw-…,acl-…) are all AWS-generated. Now all three sites route through the shared helper. The first cut claimed the blast radius here is milder than the Glue sibling's —parseRecordSetCompositeIdrequires EXACTLY three parts, so a four-part id is REJECTED rather than silently mis-decoded — and concluded it closed an id-nothing-can-decode path rather than a wrong-resource-deleted one. That was FALSE, and review caught it; the paragraph below is what the code actually does. The three sites take three different answers, and the differences are the substance:creategets the ordinaryCreateContext.replayingStatedowngrade (warn-and-pack), which required threadingcontextthroughcreate()intocreateRecordSet— the provider declared nocontextparameter at all, the silent-ignore failure mode.claude/rules/providers.mdrecords forSNSTopicProvider;updatetakes theupdateRouteprecedent and downgrades UNCONDITIONALLY. The first cut threw there, on the argument that every valueupdate()packs is template-borne and therefore user-editable — FALSE for the two callers.claude/rules/providers.mdnames:rollback-executor.ts's revert arm andcdkd drift --revertboth callupdate(..., previousState.properties, ...), so the desired bag can be a cdkd STATE record, and a refusal there would leave a record an older binary wrote under the ambiguous id UN-REVERTABLE with no template edit that repairs it.update()takes noCreateContext, so it cannot tell the two apart; the pre-guard behavior therefore stands and the ambiguous id becomes ANNOUNCED rather than silent, while the refusal keeps its teeth on the create path where the value is always template-borne; andimport()neither throws nor adopts, taking the method's ownadoptVerbatimescape hatch instead — the verbatim id decodes to nothing, so delete / drift fall back to the template properties, where the packed composite would have frozen a mis-arity id into state that nothing can parse (the #1658 shape). Both mutating sites compute the id BEFOREChangeResourceRecordSetsand OUTSIDE the wrappingtry, so a refusal can neither orphan a record AWS has already written nor be mis-reported asFailed to create / update record set. The import arm calls the bare predicatecompositeIdSeparatorRefusalfor the DECISION andpackCompositeIdfor the join, which cannot throw there — deliberate, so the three sites share one spelling of the join and cannot drift. A reviewer then found the guard was standing one layer too high, and that is the more valuable half of this change.parseRecordSetCompositeIdasks only for three NON-EMPTY segments, so a record NAME carrying two pipes —a|b|c.example.com., which is exactly the CloudFormation physicalIdcdkd import --migrate-from-cloudformationpre-populates fromDescribeStackResources— parsed as a valid composite meaning zonea, nameb, typec.example.com., at THREE sites that never reached the new packing guard:importRecordSet's early accept,resolveRecordSetIdentity's short-circuit, anddeleteRecordSet's own parse. The last is where it costs something:cdkd destroyissuedChangeResourceRecordSetsagainst hosted zonea, hit theNoSuchHostedZonealready-deleted arm, and reported SUCCESS while the real record stayed live and billing — the silent-wrong-resource class this whole family exists to close, reached from ABOVE the packers rather than through them. All three now cross-check the parsed segments against the template (compositeAgreesWithTemplate): a genuine composite agrees with the template on the two segments they share, a look-alike does not, and a disagreement falls through to resolving the identity from the properties. Each check is skipped when its template side is not a usable string, so an unresolved intrinsic leaves the prior behavior exactly as it was; the NAME compare goes throughcanonicalizeQueryName, so case and CDK's trailing dot do not cause a false rejection, and a false rejection costs only one verification call and lands on the same physicalId anyway. The TYPE check alone is not sufficient —a|b|Aon a record whoseTypereally isAagrees on the type and still decodes to zonea— which is why both are applied. Tests: 12 cases. The refusal set — create refused before the AWS call, the id SHAPE named in the message (a wrong segment name would point the user at a composite this type never packs), the clean create id, the replay downgrade, an explicitreplayingState: falsecreate that must still throw, update warn-and-pack, the clean update id, and the import verbatim adoption — plus the four look-alike rows. Those last are asserted on the DELETE path rather than onimport's return value, and that is the load-bearing part: the bug and the fix adopt the SAME id string and differ only in what it is taken to MEAN, so an import-return assertion passes under both (measured — it did). Mutation-probed against the real provider: reverting the two mutating packers to raw joins and dropping the import refusal fails exactly the 4 refusal / adopt cases; replacing the update downgrade with a bareundefinedfails exactly the update case; reverting all three cross-checks fails exactly the 3 look-alike rows with the genuine-composite control surviving. Known bound, recorded rather than fixed: on the degraded path wherecloudformation:DescribeTypeis unavailable, aNamechange classifies in-place rather than as a replacement, soupdate()warn-and-packs a template-borne ambiguous id and UPSERTs a second record — the create-path refusal keeps its teeth only when the registry lookup succeeds. The helper's header exclusion note is retired, leavingintrinsic-function-resolver.tsas the single recorded exclusion (excluded by KIND — its joins buildRefVALUES nothing decodes). Directions 1 (stop packing) and 2 (escape the separator) remain OPEN on #1672. No CLI flag, dependency, or state-schema change. - ✅
Refto a Route53 RecordSet / AppSync child resolves to CloudFormation's value instead of cdkd's compound id (issues #1681 + #1712) —src/deployment/intrinsic-function-resolver.ts,src/provisioning/providers/appsync-provider.ts, newtests/unit/provisioning/appsync-child-ref-arns.test.ts, plusdocs/state-management.md. Before:cfnRefValueFromPhysicalIdtranslated a compound physicalId back to CloudFormation'sRefvalue through two Sets — after-LAST-pipe and before-FIRST-pipe — and four types fit NEITHER, so{Ref: <resource>}handed the consumer cdkd's raw compound.AWS::Route53::RecordSetstores<hostedZoneId>|<name>|<type>while CFn'sRefis "the name of the record", the MIDDLE segment (after-last yields the record TYPEA, before-first the zone id). The threeAWS::AppSync::*children store<apiId>|…while CFn'sRefis the resource ARN, which is no segment of the id at all — and the ARN attribute the provider recorded forFn::GetAttwas itself string-built asarn:aws:appsync:*:*:…, i.e. a literal*in the region AND account positions, soFn::GetAtt DataSourceArn/ResolverArn/Arnwere ALSO returning an unusable value (the ApiKey one additionally used the pluralapikeyswhere the documented segment is the singularapikey). Route53 was the one likely to be hit in practice:CfnRecordSet.refis what an L1 template andCfnOutput(value: record.ref)use, so a record name composed into a string emittedZ1D633PJN98FT9|www.example.com.|A. After: two mechanisms sit beside the existing Sets —REF_RETURNS_SEGMENT_AT_INDEXextracts an INTERIOR segment at an EXACT declared arity (a mis-arity'd id falls through to the raw value rather than returning a confidently-wrong middle segment), andREF_RETURNS_ARN_FROM_STATErecovers the ARN through the existingstateLookupseam the S3Tables / Backup / CodeCommit cases use.AppSyncProvidernow records the REAL ARN:CreateDataSource/CreateResolverreport one, so it is taken from the response verbatim, andAWS::AppSync::ApiKey(whoseCreateApiKeycarries no ARN field) is reconstructed from the deploy's own partition / region / account via the shared STS-backedgetAccountInfo— the same helperCloudControlProvideruses. Two degradations are deliberate: an IMPORTED child recordsattributes: {}, so itsReffalls back to the raw compound id rather than a fabricated ARN; and a record written by a PRE-fix binary holds the*:*placeholder, which the resolver REFUSES (matched positionally on the region / account fields, so a legitimate ARN whose RESOURCE segment contains*is unaffected) — handing it out would be a regression introduced by the recovery itself, since it is no more usable than the compound id and looks valid. Such a record heals on the resource's next update: the three child update paths now report the COMPLETE attribute set (update()previously returned none, and the engine carries the existing map forward, so only a REPLACEMENT would ever have refreshed it). All fourRefsemantics were docs-verified 2026-08-12. #1712 rode along as the stale-comment half:intrinsic-function-resolver.ts's Glue entry still saidcreateTablepacks the id UNGUARDED, which #1672 had made untrue on the template path — narrowed to the rollback-executor REPLAY case, where the refusal deliberately downgrades to a warning. Tests: resolved-value assertions per entry (never Set membership — the two pre-existing extractions both return something plausible for Route53, so membership would not distinguish a correct entry from either wrong one), negative-polarity arity cases, the imported-child and placeholder fall-throughs, a positional-guard case for an ARN whose resource segment contains*, and provider-side assertions on the recorded value plus a whole-class fence that no child records a wildcard-bearing ARN. Both halves were mutation-probed against real code (removing each mechanism / restoring the placeholder fails the suite naming the case). The five pre-existing AppSync suites gained agetAccountInfomock — they were reaching REAL STS, so their result depended on the machine's credentials. - ✅ An empty
BucketEncryption/OwnershipControlscollection no longer DELETES the live configuration (issue #1713, the #1671 sibling) —src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-shape-and-empty-skip.test.ts. Before:applySubConfigDiffstreated an empty collection in TWO incompatible ways. Lifecycle / CORS SKIP the Put and leave the live configuration alone (the #1671 arm), whileOwnershipControls/BucketEncryptionnormalize BOTH sides throughemptyListConfigToUndefinedBEFOREdiffSubConfig— so a non-empty previous against a declared-but-empty desired becameundefined, took the onDelete arm, and issuedDeleteBucketOwnershipControls/DeleteBucketEncryption. ForBucketEncryptionthat silently drops a declaredaws:kmsdefault down to the SSE-S3 / AES256 default on a template whose only fault is a condition-pruned or intrinsic-collapsed array — reachable from an ORDINARY template path, not just a state replay. Measured against live CloudFormation (us-east-1, 2026-08-12, the same account and date as the #1671 A/B): updating a deployed bucket from anaws:kmsdefault plus aBucketOwnerPreferredrule toServerSideEncryptionConfiguration: []/Rules: []drives the stack toUPDATE_ROLLBACK_COMPLETEwithThe XML you provided was not well-formed or did not validate against our published schema, and BOTH live configurations survive the rollback unchanged. So CloudFormation treats the empty collection as an INVALID template, NOT as a removal — the identical answer #1671 measured for lifecycle / CORS — and cdkd was deleting instead. Now both arms route through the sameemptyCollectionSkiphelper the lifecycle / CORS arms use: the call is skipped, the live configuration is untouched, the PREVIOUS value is recorded (so a later template that genuinely REMOVES the block still derives the removal), and the skip is ANNOUNCED rather than silent. The fold itself is deliberately NOT what changed, and that is the whole design:readCurrentStateALWAYS emitsBucketEncryption: { ServerSideEncryptionConfiguration: [] }/OwnershipControls: { Rules: [] }for a bucket with no explicit setting, socdkd drift --revertfeeds that placeholder straight back throughupdate()and both sides must keep normalizing for empty-vs-empty to compare EQUAL and issue no call at all. A new predicatedeclaresEmptyCollectionrides alongside the fold and splits only the arm the fold reaches — declared-but-empty SKIPS, ABSENT still DELETES — which is what keeps the template-side remedy (drop the whole property) working, exactly as the skip's warning tells the user. The predicate tests "present, and the fold erased it" rather than re-deriving the empty shapes, so a MALFORMED block (which the fold passes through unchanged, and which therefore never reaches the Delete arm) is still refused by name by the apply call rather than being swallowed into the skip. This REVERSES a contract issue #1466 pinned, and the reversal is the substance rather than a side effect: that issue's row assertedempty placeholder desired + real previous REMOVES (empty == not declared)on the reasoning that it is "a genuinedeclared -> not declaredtransition … matching CloudFormation". The CFn-parity half was ASSERTED, not measured, and the A/B above measures the opposite. The row is inverted with both the old reasoning and the new measurement recorded in place, plus a new sibling row pinning the transition #1466 actually meant (ABSENT desired still REMOVES) — which is the discrimination the whole fix rests on, since before it the two desired sides were literally the sameundefinedafter the fold and could not disagree. The scope is exactly what was MEASURED and no wider, which two failing #1466 rows enforced during the work:emptyListConfigToUndefinedalso folds a bare{}and a{listKey: null}-only block toundefined, #1466 pinned BOTH as removals, and the #1713 A/B exercised neither — sodeclaresEmptyCollectiontests the list key PRESENT and EMPTY directly rather than delegating to the fold, which would have reversed two more contracts on evidence that does not cover them. Review then found the fix NOT shippable, and closing that is the larger half of this entry (issue #1732).readCurrentStatespells "this feature is not set" as the SAME empty collection —readOwnershipControlsreturns{Rules: []}onOwnershipControlsNotFoundError— andcdkd drift --revertbuilds its desired bag from that readback. So the revert caller sends a bag byte-identical to the collapsed template while meaning the OPPOSITE: "restore the unset state", where DELETE is correct. Skipping it regressed revert on the two properties where revert had WORKED (lifecycle / CORS already skipped since #1671, so nothing was lost there), and worse — the skip'sretainPreviouswrites the AWS-CURRENT value intoeffectiveProperties, whichdrift.tspersists intoobservedProperties, so an out-of-band change was laundered into the baseline and the nextcdkd driftreported clean. Nothing caught it because the unit tests, the mutation probe and a three-phase real-AWS live test all exercise the TEMPLATE caller; a reviewer prompted specifically to ask "does any other caller depend on the old behavior?" did. The fix is a newUpdateContext— theupdate()sibling ofCreateContext, optional so none of the 77 providers implementingupdate()changed, with one fielddesiredFromAwsReadbackset only bydrift.ts's revert call. The NAME is load-bearing rather than cosmetic: the rollback executor's revert arms are state-borne too, but their desired bag ispreviousState.properties— a TEMPLATE recorded earlier — so{Rules: []}there means what the template meant and SKIP is right; they deliberately pass no context, and astateBornespelling would have swept them in and deleted a live configuration during a rollback. Review then found the SAME laundering two arms below, on the lifecycle / CORS revert path (readLifecyclereturns{Rules: []}onNoSuchLifecycleConfiguration,readCorsreturns{CorsRules: []}onNoSuchCORSConfiguration— the identical unset-spelling). Those are not folded, so a revert bag reaches the onPut arm, hits the empty guard, skips, and records the AWS-current rules as the new baseline. Pre-existing (#1671) rather than a regression here — but the enumerate-every-caller rule this change itself wrote does not get to skip its own siblings, so both are fixed in the same PR with the same one-line predicate. Tests: 7 new cases plus 2 reversed / added #1466 rows — the two regression rows (no Delete, no Put, previous value recorded), and THREE controls that are what make them non-vacuous: removing the property entirely still Deletes BOTH configurations, thereadCurrentStateplaceholder round-trip still issues NO call in either direction, and a malformed block still reaches the applier and throws. Mutation-probed against the real provider in BOTH directions, which is what proves the two arms cannot be collapsed into one: neutralizing the two skip guards fails exactly the 2 template regression rows with all controls surviving, ignoring the flag (skip always wins) fails exactly the revert row, and inverting it (delete always wins) fails exactly the 2 template rows. Live-verified end to end on one binary: an out-of-bandOwnershipControlsaddition is still removed bydrift --revert(the pre-fix binary's behavior, preserved), while a collapsed-array redeploy still leaves a declaredaws:kmsdefault untouched. No CLI flag, dependency, or state-schema change. - ✅ Composite physicalIds refuse a segment carrying cdkd's
|separator at deploy time (issue #1672, fix direction 3) — newsrc/provisioning/composite-id.ts, plussrc/provisioning/providers/{glue,s3-tables,appsync,ec2,apigateway,lambda-event-invoke-config}-provider.ts, newtests/unit/provisioning/composite-id.test.ts+tests/unit/provisioning/composite-id-provider-guards.test.ts. Before:ResourceProviderpasses a SINGLEphysicalId: stringas a resource's identity while CloudFormation keeps the physical id and its containing scope as separate values, so every composite-id type PACKS its segments into one string joined by an UNESCAPED|. A segment that itself contained a|produced an id with the wrong arity, and every decode site splits the stored id back apart — a Glue table nameda|bin databasemydbrecordedmydb|a|b, which decodes to databasemydb, tablea. Both halves are non-empty, so every existing well-formedness guard passed and the DEPLOY SUCCEEDED; what broke was everything after it.cdkd destroydeleted the WRONG table if one existed under the decoded pair, or tookdeleteTable's warn-and-skip arm and reported success while the real table stayed alive and billing, andcdkd driftread backundefinedforever. No warning was emitted at any point. AWS accepts such a name (live probe, us-east-1 2026-08-12:glue:CreateTablewithTableInput.Name: 'a|b'succeeds) and CloudFormation manages the resource fine, so the limitation is cdkd's own. Now a sharedpackCompositeId(resourceType, logicalId, segments, options?)joins the segments and REFUSES with aProvisioningErrornaming the resource type, the logical id, the offending segment's meaning, its value and the id shape (<databaseName>|<tableName>), pointing at the issue; a bare-predicate siblingcompositeIdSeparatorRefusalreturns the same sentence WITHOUT acting on it, for theimport()paths whose answer to an unusable id is warn-and-skip (skipped-not-found) rather than a throw that would abort the wholecdkd importover one row. Both share one message builder. Adopted at 13 CREATE-path packing sites across 6 providers —AWS::Glue::Table,AWS::S3Tables::{Namespace,Table},AWS::AppSync::{DataSource,Resolver,ApiKey},AWS::EC2::{EIP,VPCGatewayAttachment,Route,SecurityGroupIngress,NetworkAclEntry},AWS::ApiGateway::Method,AWS::Lambda::EventInvokeConfig— plus the three import-path adoptions (AWS::S3Tables::Namespace,AWS::S3Tables::Table,AWS::EC2::EIP). What is actually MEASURED is one segment, and the entry says so rather than implying a survey:AWS::Glue::Table'sTableInput.Nameis the only value probed live (glue:CreateTablewithName: 'a|b'succeeds, us-east-1 2026-08-12). Its siblingDatabaseNameis guarded on the same footing but unprobed — both are user-chosen and the lowercase-alphanumerics rule usually quoted for Glue names is an Athena / Data Catalog convention, not an API constraint. Every OTHER segment is either structurally incapable of carrying a|(AWS-generated ids, ARNs, CIDRs, numbers, booleans, closed-set HTTP verbs) or is user-chosen but constrained by AWS in a way that should reject one upstream of cdkd — AppSyncName/TypeName/FieldNameare GraphQL identifiers ([_A-Za-z][_0-9A-Za-z]*), a LambdaFunctionNameis[a-zA-Z0-9-_]+, an S3 Tables namespace / table name is lowercase alphanumerics and underscores, and an EC2IpProtocolis a protocol name or number. Those are guarded because the guard is cheap and uniform, NOT because the hazard was demonstrated; the in-code comment at each site says which of the two it is. The one case worth naming separately is EC2IpProtocoland LambdaQualifier/FunctionName: cdkd's ownrequireConfigStringaccepts any non-blank string there, so cdkd would pack whatever the template wrote even though AWS would then reject the call — refusing first is what keeps the ambiguous id out of the idempotent "already exists" arm.AWS::Route53::RecordSetis deliberately NOT covered here (a parallel lane owned that file), and directions 1 (stop packing where the decode sites already receive the properties bag) and 2 (escape the separator) remain OPEN on the issue — this closes the data-loss path, it does not restore CloudFormation parity. Placement is load-bearing: the id is computed BEFORE the create call at every site where all segments are known pre-call, so a refusal cannot leave an orphan resource AWS has already created with no state record; the two sites where a segment comes from the create RESPONSE (AppSyncapiKeyId, EC2 EIP) are exactly the ones whose segments cannot carry a|, and both sit OUTSIDE thetrywhosecatchre-wraps asProvisioningError('Failed to create …')— inside it, a refusal would be mis-reported as an AWS creation failure for a resource AWS had in fact created. Replay safety: the refusal downgrades to a WARNING (and packs the ambiguous id, i.e. the pre-guard behavior) wheneverCreateContext.replayingStateis set, since the rollback executor's reverse-replacement arm creates from a cdkd STATE record that no template edit can repair; the EC2 Route and SecurityGroupIngress sites reuse the callback theirupdate()already passes UNCONDITIONALLY, because those paths delete-then-re-create and a throw would strand a deleted route / revoked rule.S3TablesProvider.creategained aCreateContextparameter so its two guards take the same downgrade: the refusal is structurally unreachable there (a table-bucket ARN plus AWS-constrained names), but.claude/rules/providers.mdrecords a MISSINGcontextparameter as its own failure mode — no type error, no warning, just a refusal that still fires on a replay, theSNSTopicProvidercase — so three lines retire the reasoning burden rather than leaving it to be re-derived.GlueProvider.import()already refused the same shape viaresolveTableIdentity(issue #1651), so its composite is left alone.dynamodb-globaltable-provider.ts's|joins were audited and left untouched — they are internal cache / diff keys, not physicalIds — andintrinsic-function-resolver.ts's two joins are excluded by KIND: they build aRefVALUE, not a recorded physicalId, so nothing ever decodes them. Both exclusions, plus the route53 one, are recorded in the helper's header so the "every composite packer" claim is not read as wider than it is. The predicate tests the STRINGIFIED value, nottypeof value === 'string', which is what makes the guard reach the shape a reviewer found: every call site reads its segments through an unvalidated cast (tableInput['Name'] as string | undefined), so a hand-writtenName: ['a|b']is truthy, survives the provider's own required-field gate, arrives as an ARRAY, andString(['a|b'])is'a|b'— the exact ambiguous id, formerly unrefused.CompositeIdSegment.valueis typedunknownto say so. Recorded known bound: a plain object (an unresolved intrinsic) stringifies to[object Object], carries no|, and is not refused — that is a malformed-value problemconfig-shape.tsowns, and the resulting id is wrong without being wrong in a way that decodes to a different resource. Tests: 55 cases across two new files — 18 on the helper (clean join, number / boolean stringification, the ARRAY-valued segment, an empty-string segment, multiple separators, a leading and a trailing separator, the object-segment known bound, the refusal and its message content,ProvisioningErrorcarrying the type + logical id, EVERY offending segment named rather than just the first, theonRefusaldowngrade, and a fence that the predicate and the action emit the SAME sentence) and 37 on the providers, covering EVERY guarded site rather than one per file — including the six a reviewer found untested (AppSynccreateApiKey, EC2createEipand itsimport()arm, EC2createVpcGatewayAttachment, EC2createNetworkAclEntry— the only site with number / boolean segments — and S3 TablesimportNamespace— that last one is fenced at the HELPER level only, sinceparseNamespaceCompositeId's exact-two-part split makes the provider arm structurally unreachable, so deleting it would not fail a test and the in-code comment says why) and the replay / downgrade arms that no test previously pinned (both EC2 create-dispatchreplayingState ? cb : undefinedternaries,updateRoute's unconditional callback, and the AppSynccreateResolver/ Lambda-EIC replay-warn arms). Every provider refusal case also asserts the AWS client was never called, so the pre-flight placement is pinned rather than assumed. Every fix in the review round was MUTATION-PROBED individually rather than covered by the aggregate: reverting the predicate totypeoffails exactly the two array cases; reverting either EC2 create-dispatch ternary to a bareundefinedfails exactly its replay case; droppingupdateRoute's callback fails exactly the update-path case; moving either post-call pack back inside itstryfails exactly the not-a-creation-failure case; dropping the S3 Tablescontextfails exactly its two replay cases; and making the EIP import arm ADOPT rather than skip fails exactly the skip case. Neutralizing the guard outright fails 42 of the 55 — the 13 survivors are the clean-path controls that fence the happy path. No CLI flag, dependency, or state-schema change. - ✅
cdk.json"app": "node bin/app.js"no longer runs asnode node bin/app.js(issue #1714) —src/synthesis/app-executor.ts,tests/unit/synthesis/app-executor.test.ts. Before:AppExecutor.guessExecutableprefixed the current node executable whenevertrimmed.endsWith('.js') || trimmed.split(/\s+/)[0]?.endsWith('.js'), then rewrote token 0. The SECOND disjunct expresses the intended rule ("a bare.jspath needs an interpreter"); the FIRST is true for any command whose LAST token is a.jsfile, INCLUDING one that already names its runner — sonode bin/app.js, the commandcdk init --language javascriptwrites intocdk.json, became"<node>" "node" bin/app.jsand every synth died withError: Cannot find module '/path/to/app/node'(MODULE_NOT_FOUND). Now: only the first token decides, sobin/app.js(with or without args) still gets the interpreter whilenode bin/app.js/npx tsx bin/app.js/ any.tsentrypoint go to the shell verbatim — matching upstreamguessExecutable, which prefixes an interpreter only when the referenced token is a real file on disk. A quoted first token ("bin/app.js") is now recognized too and re-quoted cleanly, which the substring test never handled. Why no test caught it: every fixture undertests/integration/**uses"app": "node bin/app.ts", whose last token ends in.tsand whose first token isnode— both disjuncts false — so the broken form was unreachable from the entire fixture set, and the one existing unit case (bin/app.js) exercises only the arm that was already correct. Tests: 5 new cases pinning both polarities — the runner-named form unchanged (mutation-probed: restoring the whole-string check fails it), a non-node runner of a.jsfile, args preserved after a bare entrypoint, a quoted entrypoint, and a whitespace-padded.tscommand passed through trimmed. No CLI flag, dependency, or state-schema change. - ✅
cdkd exportno longer aborts on VPC + IGW stacks or on REST v1 API stacks (issues #1691 / #1692) —src/cli/commands/export.ts,tests/unit/cli/export.test.ts. Both areCOMPOSITE_ID_SPLITTERSdefects surfaced by the review of PR #1680, and both abort the WHOLE command becauseexport.tsis all-or-nothing. #1691 —AWS::EC2::VPCGatewayAttachment: the entry produced{VpcId, InternetGatewayId}and its comment asserted both fields were writable Properties. LiveDescribeType(us-east-1, 2026-08-12) says the identifier is[AttachmentType, VpcId]withAttachmentTypeinreadOnlyProperties, soresolveCompositeId's field check threwdid not produce field 'AttachmentType'on any stack with a VPC + Internet Gateway — i.e. essentially every public-subnet VPC. The splitter now derivesAttachmentTyperather than hardcodingInternetGateway: the Cloud-Control-written shape already carries it verbatim as segment 0 (a template declaringVpnGatewayIdtrips the #614 silent-drop routing, soVPN|vpc-…really occurs in state), the SDK-written<gatewayId>|<vpcId>shape derives it from WHICH gateway property state recorded, and a last-resortigw-/vgw-prefix read covers a state entry carrying neither; anything else refuses with a named error rather than guessing.propertiesOverlaynarrows toVpcId, since writing a read-only property intoPropertiesis rejected at changeset-create. #1692 — fiveAWS::ApiGateway::*children:Deployment/Stage/Authorizer/Model/RequestValidatorall have composite CFn identifiers while cdkd stores a bare id, and none had a splitter, socdkd exportwas unusable for REST v1 stacks (every CDKapigateway.RestApiemits a Deployment AND a Stage). They share onerestApiChildSplitterfactory that accepts BOTH stored shapes — the SDK providers' bare child value paired withRestApiIdfrom the recorded properties, and Cloud Control's|-joined composite in CFn primaryIdentifier order. That order is not uniform and the factory takes it as a parameter:Deploymentdeclares[DeploymentId, RestApiId](child FIRST), every sibling declaresRestApiIdfirst.propertiesOverlaynarrows toRestApiIdfor the three whose child field is read-only (Deployment/Authorizer/RequestValidator) and is left at the default whole-map forStage/Model, which declare no read-only properties at all — verified live per type, with the reason recorded per entry.ModelandRequestValidatorhave no SDK provider today, so they only ever route through Cloud Control; the bare form is accepted anyway so a future provider needs no state migration. Tests: 20 new cases — AttachmentType derived from each gateway property, from the CC-written shape, from the id prefix, and the named refusal when none resolves; each REST child in both stored shapes with its overlay asserted; theDeploymentchild-first ordering pinned separately from its siblings; and the three refusal shapes (blank id, >2 segments, missingRestApiIdin state). The pre-existing case that pinned the WRONG VPCGatewayAttachment map is replaced rather than kept. No CLI flag, dependency, or state-schema change. - ✅ Three
AWS::DynamoDB::GlobalTablearms record what they SENT — and a fourth is deliberately left unanswered (issue #1683, the #1653 siblings) —src/provisioning/providers/dynamodb-globaltable-provider.ts,.claude/rules/providers.md,docs/provider-development.md,tests/unit/provisioning/dynamodb-globaltable-provider-sibling-effective-properties.test.ts,tests/integration/dynamodb-globaltable/. #1653 answered only theStreamSpecificationarm; siblings in the same provider put a value on the wire that differs from the declared one and still recorded the DESIRED bag — the permanent phantom driftreadCurrentStatecan never clear. (1)BillingModewarn-and-SUBSTITUTE (create): under thereplayWarndowngrade a malformed value is replaced byPAY_PER_REQUESTand the table really is created on-demand, so the SUBSTITUTED mode is now recorded — #1633's "what you return is what you SENT", reached by the same single-call-site exception.claude/rules/providers.mdlicenses for a read of KNOWN warn-and-DEFAULT class. The callback is COMPOSED ontoreplayWarn's own and installed only when that callback exists, so a template-path create keeps refusing rather than silently gaining a downgrade. (2)desiredGsiUnusablewarn-and-SKIP (update): the GSI diff is suppressed, so AWS keeps the index set it already holds and the effective bag now retains the PREVIOUS list — not the create side's OMIT, which here reads as "the template declares no GSIs". The previous side is VALIDATED first through the SAME translator (a probe call with a flag-only callback — the helper is pure, so this cannot drift from a hand-writtentypeoftwin the way a re-derived shape check would), the retained array is COPIED rather than aliased (rollback-executor.tsspreads the answer shallowly), and the key is DROPPED when both sides are unusable. (3)BillingModewarn-and-KEEP (update): the SAME property one path over, named by the issue's arm 1 and caught by the re-review — an unusable desired value does NOT flip the table, it keeps the previous mode, so the effective bag now records that mode rather than the malformed onereadCurrentStatecould never match. It needs no both-sides-unusable branch: the kept mode already falls back to whatDescribeTablereports, so the retained value is always one AWS holds. Note the create and update arms of one property answer DIFFERENTLY because what reached AWS differs. One residual is named rather than stripped — substitutingPAY_PER_REQUESTon the create path also skips every PROVISIONED-only capacity block, which stays recorded although nothing sent it (#1726). (4) theneedsStreamauto-enable (create) is NOT answered, on purpose. It is the same class — cross-region replication requires a stream, so cdkd enablesNEW_AND_OLD_IMAGESon a template that declared none, with nothing malformed and no guard logging a refusal — and a 3-axis review established that recording it in isolation is exactly the shape the twin rule forbids.DiffCalculator.comparePropertieswalks the key UNION, so an UNCHANGED template would classify an UPDATE on the next deploy;update()(whose stream gate isproperties['StreamSpecification'] !== undefined, false for a template that declares none) would return no effective bag and re-record the desired one, dropping the key again. Net: one spurious no-op UPDATE and no durable record — never a disabled stream, which is why this is a follow-up rather than a hazard. ThecanonicalizeDesiredPropertiestwin that would fix it cannot simply be added: it is pure and synchronous and does not know the deploy region, whileneedsStreamdoes. Tracked as issue #1723 together with the prior question of whether the arm is needed at all (drift-calculatordescends only into keys present in state, and theobservedPropertiesbaseline already carries the stream). A fifth arm found by the same review — the replay-CREATE GSI omit, which sends no indexes yet records the malformed blob — is filed as #1724; both are named in-code so neither reads as already-settled. Tests: 23 unit cases covering all three answered arms in both polarities, the composition on BOTH paths, and a pin that the auto-enable still SENDS the stream while recording nothing. Every composition claim is mutation-probed and labelled by what it actually fences: the create-path pair and the update path's GSI and BillingMode sites each fail when assigned{ ...properties }, while the update path's stream site passes because that arm always runs first — recorded in-code rather than claimed as covered. Real-AWS: thedynamodb-globaltableinteg gains assertions that a skipped GSI update retained the previous list and that a suppressed billing flip recorded the previous mode while leaving the live table PROVISIONED — both on the SAME resource in one deploy, so the run is also the live twin of the composition test, and its #1571 / #1585 junk-state phases now seed by hand-patchingstate.json(properties only;observedPropertiescame from a real read-back and was never junk) because arm 2 closed the path that used to write that record — the recovery machinery still matters for every record an older binary wrote, so the coverage stays rather than being deleted along with its producer. No CLI flag, dependency, or state-schema change. - ✅
AWS::Kinesis::Streamback-fillsDesiredShardLevelMetrics+MaxRecordSizeInKiB(issue #609) —src/provisioning/providers/kinesis-provider.ts,src/provisioning/property-coverage.generated.ts,tests/unit/provisioning/kinesis-provider-metrics-and-record-size.test.ts,tests/unit/provisioning/kinesis-provider-readcurrentstate.test.ts,tests/integration/kinesis-stream-mode-switch/. Before: both properties were insilentDrop, so a template declaring either was rejected at deploy-time pre-flight (with the--allow-unsupported-propertiesescape hatch). Now:MaxRecordSizeInKiBrides onCreateStreamInputat create andUpdateMaxRecordSizeat update (that API has noStreamNamemember, so it resolves the ARN the same way theUpdateStreamModepath does), andDesiredShardLevelMetricsis applied viaEnableEnhancedMonitoring/DisableEnhancedMonitoring— reconciled as a removal pass then an addition pass, since enhanced monitoring is a SET with no "replace" API. Both are read back byreadCurrentState;MaxRecordSizeInKiBneeded a secondDescribeStreamSummarycall becauseDescribeStream'sStreamDescriptionshape does not carry it. TheALLshorthand is the interesting half. Measured us-east-1 2026-08-12: AWS EXPANDSALLinto seven metric names and never stores the literal, so recording the template'sALLwould be permanent phantom drift that no readback could ever match. The provider therefore expands on the wire and records the expanded list viaeffectiveProperties— paired withcanonicalizeDesiredProperties, which narrows BOTH diff sides through the same shared helper, per the.claude/rules/providers.mdcontract that the recording half alone breaks the next deploy (state would hold seven names while the template still saidALL, and every latercdkd diffwould report a change the user never made). The integ asserts exactly that: after theALLdeploy, AWS reports the seven names, cdkd state records the seven names, andcdkd diffis CLEAN. The readback order is AWS-chosen (matched neither request order nor alphabetical), so the type also declaresDesiredShardLevelMetricsingetDriftUnorderedPaths. A declared metric list has THREE states, not two: an ABSENT declaration is a template removal and correctly disables every live metric, while a declared-but-UNUSABLE one (an unresolved intrinsic, or a MIXED array) must not collapse to the same empty list — doing so would disable live monitoring on the strength of a value cdkd could not parse.readShardLevelMetricsclassifies the three and each caller picks the action: create REFUSES (template-borne, downgraded to a warning underCreateContext.replayingState), update WARNS and skips the whole reconcile (rollback /drift --revertreplay state records throughupdate(), where a throw has no template-side remedy). A mixed array is refused rather than partially sent, since filtering the intrinsic away would put a list on the wire the template never declared while state recorded the declared one.MaxRecordSizeInKiBis coerced through one helper on BOTH comparison sides (matching the siblingShardCount), so a string-valued template does not reach the SDK as a JSON string nor re-issue the call on every deploy; and a non-not-foundDescribeStreamSummaryfailure RETHROWS rather than returning a snapshot missing the key, whichcdkd driftwould report as2048 -> undefinedand--acceptwould then erase from the baseline. The slice's third property,WarmThroughputMiBps, is deliberately NOT shipped: it needs an account-level Kinesis minimum-throughput billing commitment this account lacks (ValidationExceptionon create), so it cannot be asserted against real AWS and would violate #609's per-PR "the integ must prove the property reached AWS" rule — split out to issue #1695 and left insilentDrop. - ✅ Cross-stack references fall back to CloudFormation when the producer is not in cdkd state (issue #1697) —
src/deployment/intrinsic-function-resolver.ts,src/deployment/deploy-engine.ts,src/cli/options.ts,src/cli/commands/{deploy,diff,diff-recursive,export}.ts,tests/unit/deployment/intrinsic-cfn-fallback.test.ts, docs (cross-stack-references.md/cli-reference.md/supported-features.md/architecture.md/ README / rules / distributed plugin skill). Before: both cross-stack intrinsics read ONLY cdkd's S3 state, so a cdkd-deployed consumer referencing a producer stack still managed by CloudFormation (cdk deploy/ raw CFn) died at resolve time —Fn::ImportValuewithexport not found in any stack,Fn::GetStackOutputwithMake sure the producer stack has been deployed via cdkd— blocking the mixed-estate scenario cdkd is positioned for (shared infra on the CDK CLI, app stacks on cdkd) and forcingcdkd exportmigrations to proceed leaf-first. Now a cdkd-state miss falls back to CloudFormation:Fn::ImportValue→ paginatedListExportsin the consumer's region (CFn's own semantic for the intrinsic — this was a template-compatibility gap),Fn::GetStackOutput→DescribeStacksoutputs in the target region (same-account only; theRoleArncross-account path keeps reading the producer account's cdkd state exclusively). cdkd-first precedence is inherent (the fallback fires only after the index + state scan miss), so cdkd-to-cdkd behavior is byte-identical. CFn-sourced resolutions are WEAK references — deliberately NOT recorded intostate.imports/state.outputReads, since cdkd cannot destroy-protect a producer it does not manage and CFn's export-in-use protection cannot see cdkd consumers — so no state schema change. Lookup failures (missingcloudformation:ListExports/DescribeStackspermission) degrade gracefully: warn + the original not-found error (which now names both searched sources). Opt-out:--no-cfn-fallbackondeploy+diff(threaded throughDeployEngineOptions.cfnFallbackinto the resolver, inherited by nested-stack child engines via the options spread, and honored bycdkd diff's best-effort resolvers incl. the recursive child-parameter resolution so preview and apply resolve identically). Thecdkd exportcross-stack consumer scan's warn/refuse text is updated to match (consumers now keep resolving via the fallback; only--no-cfn-fallbackconsumers retain the leaf-first constraint). Tests: 14 new resolver cases (fallback hit, pagination, cdkd-first precedence with no CFn call, opt-out, graceful degradation warn, both-sources error text, weak-reference non-recording on both bags, available-outputs error, does-not-exist-as-miss, region pinning, per-region client caching) + engine threading assertions + a cross-account assertion that theRoleArnpath never takes the fallback; verified live with a realaws cloudformation deployproducer consumed by a cdkd deploy. - ✅
AWS::Route53::HostedZoneimport auto-resolves from the template'sName(issue #1702) —src/provisioning/providers/route53-provider.ts,tests/unit/provisioning/route53-provider.test.ts,docs/import.md. Before:docs/import.mdlisted the type under Auto-resolved, butimportHostedZone()returnednullunless an explicit--resource <logicalId>=<physicalId>override was supplied — it was override-only in fact, and the contradiction only became visible when PR #1678 corrected the provider's own JSDoc. A type listed as auto-resolved that silently declines produces askipped-not-foundrow the user did not expect, and under--migrate-from-cloudformationthe state write happens anyway, so the zone was left out of cdkd state after the CloudFormation stack had already been retired. After: with no override the zone is resolved from the template'sName— the physical-name route the Auto-resolved section documents — through the SAMEresolveHostedZoneIdhelper the RecordSet path uses, so it inherits #1678'scanonicalizeQueryNamequery-side positioning (a boundedListHostedZonesByNamepage cannot skip past the zone) and its split-horizon ambiguity refusal. That refusal would have made the common split-horizon pair unresolvable, so the helper gained one option:requirePrivateZonenarrows the name match to the public or private side BEFORE the ambiguity decision, and the new module-leveltemplateZoneVisibilityderives that side from the template's ownVPCs(absent = public; an array carrying at least one element that is not purely an unresolved intrinsic = private; everything else = UNDECIDABLE, leaving the refusal in place — a non-array, an empty list, and an all-intrinsic list such as[{Ref: 'AWS::NoValue'}], which is non-empty yet may resolve public). Narrowing changes what a NEGATIVE proves, and that is the subtle half.absenceIsSoundreasons from AWS returning the page AT the query key: without narrowing this line is only reached with an EMPTY match set, so any returned zone is a LATER one and the same-named run is provably over. With narrowing, the filter can empty the match set while the whole page is same-named zones of the OTHER side — and if that page is truncated the zone we want may be the next entry, so treating it as absence would decline a zone that exists and let the next deploy CREATE a duplicate. The narrowed case therefore demands stronger evidence: a DIFFERENT name on the page, or an untruncated one. The two ways of failing are kept DISTINCT (corrected during review — the first cut declined everything on the argument that a throw would fail the whole run, which is FALSE:importOneinimport.tscatches per resource and recordsoutcome: 'failed'). A proven absence returnsnull->skipped-not-foundand the next deploy creates the zone; an ambiguous name or a failed lookup THROWS ->failedfor that row alone, so a deniedroute53:ListHostedZonesByNameis reported as a denial instead of being flattened into "no matching AWS resource" — which was wrong at exactly the moment--migrate-from-cloudformationretires the CFn stack. Both messages name the--resourceremedy through a newsubjectoption, since the shared helper's RecordSet wording ("pass the<zoneId>|<name>|<type>composite") names a physicalId shape a hosted zone does not have. An explicit--resourceoverride still wins and is only verified. Tests: 18 cases — auto-resolution + query canonicalization, both split-horizon sides (the private zone listed FIRST in the public case, so a take-the-first-match implementation fails), the three undecidable-VPCsrefusals (non-array, empty list, all-intrinsic elements) plus the concrete-element-with-Ref-inside acceptance, both truncated-page directions, the no-match / wrong-side declines, the denied-lookup failure, override precedence, and — added by the 3-axis review — post-narrowing ambiguity (two PRIVATE zones sharing a name, which narrowing cannot disambiguate), a zone whose response carries noConfigblock, and both--resourceverification arms. Every guard probed by mutation against the real provider: reverting the soundness rule fails 1, the naiveVPCs.lengthcheck fails 1, decline-everything fails 5, disabling the visibility filter fails 3, removing the auto-resolution fails 5, "narrowing means take the first survivor" fails 1, and swallowing every override-path error fails 1. - ✅ The S3 inventory schedule is recorded in the CFn spelling, and an empty rules collection records the previous value (issues #1686 / #1671) —
src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-shape-and-empty-skip.test.ts(new),.claude/rules/providers.md. Two siblings of the #1612 / #1670 recording class, both producing PERMANENT phantom drift rather than a wrong AWS call. #1686 — a SHAPE mismatch. Before: the inventory applier accepts two spellings of the schedule on the desired side, the CFnScheduleFrequencyand the SDKSchedule: { Frequency }(the #1605 fall-through), butinventorySdkToCfnemits only the former — and the live CFn registry schema declaresScheduleFrequencyREQUIRED with noSchedulemember at all, so the SDK spelling is a cdkd-only tolerance. A record carryingSchedulenamed a key the readback can never produce: everycdkd driftre-reported it and--revertre-issued the same Put. #1670 had fixed the adjacent VALUE half (the fall-through records what went on the wire); the KEY survived alongside it, and the case that mattered carried no malformed value at all — an item declaring only the SDK spelling sends the right cadence and records the wrong key. Now the normalization keys off the DECLARED shape rather than off the refusal: whenever the item declaresSchedule, the effective item recordsScheduleFrequency(the value that went on the wire) and DROPSSchedulevia a newwithoutKeyhelper — the removal twin ofwithDeepValue, removing the key rather than setting itundefined, sinceJSON.stringifydrops anundefinedmember but astructuredCloned state record keeps it and theunionWalkObjectsdrift path walksObject.keys. The tolerance is kept rather than retracted, because refusing the SDK spelling would retract the #1605 fall-through whose purpose is that a malformed first source lands on a value the record ALSO carries instead of skipping a live inventory report. #1671 — an empty-collection SKIP. Before: the lifecycle / CORSonPutarms skip the Put for an empty rules array while AWS still holds the previous rules, and the engine recorded{Rules: []}— a Put that never ran, written as though it had; unlike the malformed-value skips this arm is reachable from an ORDINARY template path, since a condition-pruned or intrinsic-collapsed template synthesizes an empty array. The issue's open question was whether an empty collection is a REMOVAL intent, which would make the skip itself wrong. It is not, measured by live A/B against CloudFormation (us-east-1, 2026-08-12): updating a deployed bucket toLifecycleConfiguration: { Rules: [] }+CorsConfiguration: { CorsRules: [] }drives the stack toUPDATE_ROLLBACK_COMPLETEand BOTH live configurations survive the rollback unchanged — so it is an INVALID template, not a removal, and turning the arm into a Delete would both diverge from CFn and destroy a configuration the user still wants. The registry schema only says the shape is legal (Rules/CorsRulesrequired, nominItems), which is why the behavior had to be measured rather than read. Now the skip stands, records the PREVIOUS value (the #1612 UPDATE answer, now with CFn behavior behind it —undefinedwhen the previous side declared none, which removes the key so a later genuine removal still derives one), and is ANNOUNCED with a warning naming the property and the remedy, since CFn's own answer to this template is a loud failure. It stays a warning rather than a throw so thereadCurrentStateround-trip the arm exists to absorb (drift --revertfeeds an always-emitted empty-rules block back throughupdate()) keeps working. Known residual: the fold ships WITHOUT itscanonicalizeDesiredPropertiestwin, so a template declaring the SDK spelling keeps reportingInventoryConfigurationschanged on everycdkd diffand re-issues an idempotent per-IdPut (measured: an unchanged template redeploys as1 to update). Shipped anyway because the alternative is worse in the direction that MUTATES — without the fold,cdkd driftreports the key forever and--revertre-issues the call — and because the twin has a real obstacle: acanonicalizeDesiredPropertiesfolding this key makesgen-nested-key-coveragereport the still-correct plural->singularsegmentRenamesentry forInventoryConfigurationsstale, while removing that entry surfaces genuineno-write-evidencedivergences. Tracked in issue #1717. Tests: 20 unit rows — 10 for #1686 (SDK-spelling-only, the five malformed fall-through shapes, the redundant-Schedulecase, the unmutated-desired-bag fence, and a convergence row driving the record and a readback-shaped snapshot through the realcalculateResourceDrift) and 10 for #1671 (lifecycle + CORS each asserting NO Put AND NO Delete, both warnings incl. the absent-previous wording, the ABSENT / non-array arm, the key removal, and two CONTROLS: a non-empty array still applies, and REMOVING the property entirely still Deletes). Mutation-probed both ways at the time of the probe (16 rows then, 20 now): reverting the #1686 normalization fails exactly the #1686 rows, and reverting the #1671 lifecycle arm fails exactly its rows with the CORS row still green, so the two arms are independently fenced. Audit: the sibling sweep #1686 asks for was done mechanically — 158 live-registry-schema property names diffed against every desired-side key read — yielding four non-CFn reads, two legitimate SDK RESPONSE-side spellings (IsEnabled,AccountId),Schedule(fixed here), and the analytics / inventoryDestination.S3BucketDestinationnested branch plus itsBucket/BucketArnalias, filed as issue #1707 because fixing it revisits #1670's write-back-at-the-declared-branch decision. No CLI flag, dependency, or state-schema change. - ✅ The reverse-replacement replay-CREATE records the provider's
effectiveProperties(issue #1682) —src/deployment/rollback-executor.ts,tests/unit/deployment/rollback-executor.test.ts,tests/integration/rollback-replay-effective-props/(new),.claude/rules/providers.md,.claude/rules/code-layout.md,docs/testing.md. Before: issue #1644 made everyupdate()caller honoureffectiveProperties, but the CREATE side kept one caller that did not — and it is the one that needs it most. The reverse-replacement arm re-creates the OLD resource frompreviousState.properties, i.e. from a cdkd STATE record rather than a template, so it is the only create path whose input bag can carry a malformed block an older binary wrote and which the provider deliberately WARNS about and SUBSTITUTES instead of refusing (the #1544replayWarndowngrade). That arm typed its local result as{ physicalId; attributes? }—effectivePropertieswas not even declared on the type — and rebuilt the state record fromprev.properties, so whatever the provider reported was discarded. Every provider substituting from its replay-CREATE arm was therefore writing into a void, which made the.claude/rules/providers.mdeffective-properties table's replay-CREATE row unreachable in production for all of them. Now the arm mirrors its UPDATE-side twinrecordAfterRollbackUpdatevia a newrecordedPropertiesAfterReplayCreate: the bag handed tocreate()ISpreviousState.properties, so a returnedeffectivePropertiesreplaces the record'spropertieswholesale, and reporting none keeps the previous bag rather than blanking the record (the gate is PRESENCE, not emptiness —{}is a legitimate complete answer meaning "I sent nothing"). It applies on the name-idempotent ADOPT path too: that arm's warning says state records "the pre-replacement properties" and it still does, since a substitution repairs an unusable field of that same bag rather than swapping in the new generation's values. Tests: 5 unit cases — the substitution recorded (and COPIED, not aliased), the default polarity keepingprev.properties, an EMPTY report honoured as complete, the delete-new-first collision retry's SECOND create attempt, and the ADOPT path. 4 of the 5 fail against the pre-fix source; the default-polarity case passes both ways by design, as the regression guard for the arm every rollback hits. Real-AWS coverage: a new fixture, because no existing one reaches this branch —rollback-failure-injectionrolls back CREATEs, which is a delete, not a re-create. It deploys anAWS::EC2::Route, injects a second destination key into the state record (the shape recorded before #1591 narrowed it), flips the create-only destination with a failure wired AFTER the route so the replacement COMPLETES and rollback classifiesreverse-replacement, then asserts the post-rollback record kept the SUBSTITUTED bag and that two consecutivecdkd driftruns converge. The vehicle is a route rather than theAWS::S3::Bucketthe issue names because a bucket's reverse-replacement re-create must re-acquire a just-deleted GLOBALLY unique name, whose release is not immediate — flaky for a reason unrelated to what it tests — while a route's<RouteTableId>|<Destination>identity is stack-scoped and deterministic;createRoute's multi-destination warn arm is gated on the sameCreateContext.replayingStateflag, so the engine path is identical. Verified in us-east-1 BOTH ways: PASS with the fix (6 phases, 5 deleted / 0 errors / 0 orphans) and, against a rebuilt pre-fix binary, a FAIL at the assertion phase carrying the#1682 REGRESSIONmessage — so the fixture demonstrably catches the defect rather than passing vacuously. No CLI flag, dependency, or state-schema change. - ✅ NLB flag-OMISSION semantics A/B-verified: AWS RETAINS, so cdkd's omit-the-member behavior is correct (issue #1619, split out of #1609 item 2) —
src/provisioning/providers/elbv2-provider.ts,tests/unit/provisioning/elbv2-lb-targetgroup-props.test.ts, new fixturetests/integration/nlb-source-nat/. The open question:EnablePrefixForIpv6SourceNatandEnforceSecurityGroupInboundRulesOnPrivateLinkTraffichave no AWS API of their own — they ride onSetSubnets/SetSecurityGroupsinupdateLoadBalancer. When ONLY the flag is removed cdkd issues no call at all and the live value is retained, and that arm was settled. The COMBINED case was not: with the flag removed in the SAME deploy that changesSubnets/SecurityGroups, the Set* call IS issued and is issued WITHOUT the member, and AWS's omission semantics there (retain vs reset-to-default) had never been A/B'd. Both in-code arms carried aCaveat:comment saying so. If AWS reset, cdkd would silently flip a live NLB's IPv6 source-NAT — or, security-relevantly, its PrivateLink inbound-rules enforcement — on an unrelated subnet / security-group change. Measured (us-east-1, 2026-08-12, one NLB per flag so the two cannot cross-talk, each templated at its NON-default so retain and reset are distinguishable): AWS RETAINS on omission for both. Source-NAT heldonacross a 2 -> 3 subnet change, keeping the per-subnetauto_assignedprefixes and auto-assigning one to the new subnet; enforce held the non-defaultoffacross a 1 -> 2 security-group change. The delta: no behavior change — omitting is correct and re-sending would be redundant. The twoCaveat:comments are replaced by the verified statement plus the measurement date, per the issue's "whichever way AWS goes, pin it". Also recorded: the enforce flag is settable and readable on ANY SG-bearing NLB, so the PrivateLink endpoint service the issue scoped as necessary is NOT needed — which is most of why the fixture is cheap. Tests: two unit tests for the previously-uncovered combined arm (each asserts the member is OMITTED; both were mutation-probed against a re-send-the-retained-value implementation and fail under it), plus thenlb-source-natinteg fixture, which pairs each retention assertion with a companion-property readback proving the Set* call actually fired — without that a cdkd regression that skipped the call would leave the flag untouched and the assertion would pass vacuously. Scope of the evidence, since the provider can emit two request forms: the fixture regression-fences theSubnetMappingsarm; the plainSubnetsarm was measured by hand in the same A/B and retained identically, but nothing in the repo reproduces it, and the in-code comment says so rather than implying both are guarded. Theinteg-aws-commandsinvocation CEILING was re-slacked 3000 -> 3400 (the tree had grown to 2988 against a ceiling last set at 2635, so ordinary fixture growth, not a parser false positive, had exhausted it). - ✅ A custom-resource failure that nothing explained now shows the backing function's log tail (issue #1687, surfaced by the review of #1685) —
src/provisioning/providers/custom-resource-provider.ts,tests/unit/provisioning/custom-resource-provider-authz-log-tail.test.ts. #1674 taught cdkd to read the backing Lambda's invocation log tail, but it only ACTED on it when the tail matchedCR_TRANSIENT_AUTHZ_LOG_SIGNALS. For every other terminal failure — a 404, a Python traceback, a JSON decode error —decodeInvokeLogTailran and the result was discarded, so the user still saw onlyCustom resource handler returned FAILED: Command '[...]' returned non-zero exit status 1.and still had to open CloudWatch. That is literally the second half of what #1674 reported, and a spec reviewer caught that #1685 had closed only the first. Now a terminal FAILED whose reason carried no authz wording AND whose log matched no signal emits the decoded tail as an EPHEMERALlogger.warn, capped byCR_LOG_TAIL_WARN_MAX_CHARS. The arm is deliberately narrow in one direction and deliberately broad in the other. It does NOT fire when the reason itself named the cause (the #756 path) or when the log matched a signal (the #1674 path) — both have already explained the failure, and repeating the tail beside an explained one is noise. It DOES fire for every other terminal FAILED rather than trying to judge whether the reason was informative, because cdkd cannot tell an explanatory handler message from an unhelpful one, and at that point the user is debugging a failure and the tail is exactly what they would go and fetch. It stays out of the thrown message, which is the #1685 data-class rule applied again: a thrown message is persisted todeployments/{runId}.jsonl, which outlivescdkd destroyand is contractually free of anything that may carry secrets, and a log tail is arbitrary handler stdout. Review corrected two over-assertions, both about claiming more than cdkd knows.LogResultis NEVER absent on aLogType: 'Tail'invoke — Lambda always emitsSTART/END/REPORT— so the first cut'slogTail !== undefinedguard filtered nothing and would have dumped several hundred characters of zero-value boilerplate on EVERY unexplained failure;hasHandlerLogOutputnow requires at least one handler-authored line, and it is a positive test rather than a size threshold precisely because the boilerplate is long. And the tail belongs to the DISPATCH invoke: for a handler that works inline that IS where the failure happened, but for the CDK Provider framework's async pattern the FAILED arrives frompollS3Responseand was authored by a LATER Step-Functions-driven execution whose log this is not — a mismatch that costs only a redundant retry on the SIGNAL path but would present an unrelated log as THE explanation here, so the message names the invocation it came from and says the failure may have happened elsewhere. The wording also says cdkd "could not classify the reason" rather than that the reason carried no recognizable cause, since a perfectly informative reason simply may not match an authz signal. A second review round then found the boilerplate set itself incomplete: it omitted the COLD-START platform lines (INIT_START/INIT_REPORT/ the SnapStartRESTORE_*pair /EXTENSION), which is where it mattered most — the IAM-propagation race this mechanism exists for IS a cold-start phenomenon, so the filter would have been inert in its own main case. The same round aligned theFunctionErrorarm onto the same filter, so the constant's claim ("a tail consisting only of these carries no diagnostic value") is true of the whole file rather than one arm; Lambda's ownTask timed out after .../Runtime exited with error: ...lines are NOT boilerplate by the pattern, so the realistic crash shapes still print. Tests: 11 new cases (the traceback case asserting the tail reaches the warn and NOT the thrown reason, the boilerplate-only guard, the dispatch-invoke disclosure, the cap, the two noise guards, and SUCCESS), bound by five mutation probes against the shipped source — removing the arm fails 3, dropping the boilerplate guard 1, dropping the!reasonIsAuthznoise guard 1, removing the cap 1, dropping the dispatch-invoke disclosure 1. No CLI flag, dependency, or state-schema change. - ✅
cdkd importin cdkd-assets mode records the PRE-rewrite asset references in state (issue #1652) —src/cli/commands/import.ts,tests/unit/cli/import.test.ts,docs/design/1002-cdkd-asset-storage.md,docs/import.md. Before: design §7.1 specified "rewrite before writing state, so imported state matches what the next deploy would write (no spurious first-deploy churn)", andimport.tsrewrotestackInfo.templatein place BEFOREbuildStackStatereadtmplResource.Properties. That optimization is only sound for a resource whose live value the import actually reads back from AWS, and most providers'import()return just a physical id —IAMPolicyProvider.import()returns{ physicalId, attributes: {} }and never reads the live policy document. So state claimed the cdkd asset bucket while AWS still held the CDK bootstrap one, the deploy diff compared a rewritten template against a rewritten state, the change classifiedNO_CHANGE, and the live resource was never corrected. For theAWS::IAM::Policythats3deploy.BucketDeploymentgenerates that is a silent split-brain: the handler role keeps a grant oncdk-<qualifier>-assets-*whileSourceBucketNamesnamescdkd-assets-*, surfacing whenever the custom resource next runs as a runtimeAccessDenied— and it silently opts the stack back out of thecdk gcprotection issue #1002 exists to provide.cdkd diffshowed nothing, so there was no signal at import time; the only recovery wascdkd drift --revertor re-importing beforecdkd bootstrap. Now the template is still rewritten (that is what makes assets resolve, and nothing else about §7.1 changes), but astructuredClonesnapshot taken immediately BEFORE the rewrite is what feedsbuildStackState+resolveImportedProperties, sostate.propertiescarries the CDK-bootstrap values AWS actually holds and the first post-importcdkd deployproduces the corrective UPDATE. The recursive--migrate-from-cloudformationchild walk takes the same treatment — each nested child template is snapshotted before its own §7 rewrite — since a child's state write is a separate code path that would otherwise keep the old behavior. The trade is explicit and is the point of the change: one deploy's worth of churn in exchange for correctness when adopting a stack something else deployed. An info line naming the rewritten-reference count is emitted at import time so that churn is expected rather than surprising. The deploy-driven legacy → cdkd-assets migration was never affected (state there already held the oldcdk-*names, so its diff always produced a real UPDATE); this brings the import path in line with it. Unchanged:--use-cdk-bootstrap-assetsstill pins legacy destinations end to end, asset-less apps and legacy regions are byte-identical (no clone is taken when no redirect applies), and no state-schema, CLI-flag or dependency change is involved. Tests: the pre-existing#1002 PR 2case is INVERTED to assert the state-side pre-rewrite value while additionally asserting the template object WAS rewritten in place (so a fix that simply dropped the rewrite would fail), plus the info line; a new nested-child case asserts the same for the--migrate-from-cloudformationwalk. Both fail against the pre-fix source and pass after. - ✅ The three grandfathered
*Once-leak files are fixed, so the detector's allow-list now holds only its canary (issue #1655) —tests/unit/provisioning/elbv2-provider-roundtrip.test.ts,tests/unit/provisioning/iam-role-provider.test.ts,tests/unit/provisioning/s3-tables-provider-roundtrip.test.ts,tests/once-leak-allowlist.json,scripts/gen-once-leak-allowlist.ts,.claude/rules/testing.md,docs/testing.md. Before: the runtime detector added by issue #1618 measured 27 cross-test consumptions across these three files and grandfathered them, so each was exempt from the check — including for any test added to it later. After: every over-priming is removed, all three files are dropped fromtests/once-leak-allowlist.json, and the list contains nothing but the deliberately-leakingonce-leak-canary.test.ts, which is the documented goal state. The fix is priming-side rather than amockReset()drain inbeforeEach: a drained suite can never be flagged again, which would re-create exactly the grandfathering the ratchet exists to remove. What the 27 turned out to be: in all three files the surplus described a call the code path never makes, and in all three the priming's own COMMENT asserted that call —s3-tablesprimed aListTagsForResourcethatreadTableCurrentStategates onresp.tableARN, a field the mockedGetTableresponse omitted;iam-roleprimed three policy/tag no-op responses although a sibling test's helper in the same file already documented that those helpers issue ZERO sends for props carrying no policies or tags; andelbv2primed aDescribeTagsat seven sites althoughupdate()derives the tag diff from the desired-vs-previous property bags and never reads AWS tags. So the measured consumption contradicted the annotation every time. Whether any assertion was hiding a defect: no — this was checked per the issue's scope item 2 rather than assumed. Two shifted tests had genuinely never executed their own branch (s3-tables's Format-lessGetTablecase was reading the previous test's leftover tags response, and theiam-roleclear-on-removal trio was taking a leaked{}as itsGetRoleresponse), and both still pass once the branch actually runs, because each asserts on the COMMAND INPUT rather than on the response. Nothing to file separately. Kept honest afterwards by two guards that catch DIFFERENT regressions, which a probe forced us to state precisely: dropping the files from the allow-list re-arms the detector for the surplus case (re-introducing one primer and running withCDKD_ONCE_LEAK_DETECT=1fails, naming the priming test), whileexpect(mockSend).toHaveBeenCalledTimes(N)beside the existing assertions catches the complementary one — the code path changing how many calls it makes, which is what silently invalidates a priming in the first place. The count pin does NOT catch a surplus primer, because an unconsumed response leaves the count unchanged; that was measured, not assumed. Also fixes a mis-titled test (s3-tables' Format-less case announcedAWS::S3Tables::TableBucketwhile exercising::Table). Nosrc/**change: unit-test priming, the generated allow-list, its generator's now-stale$comment, and the two testing docs. Verified with the full suite underCDKD_ONCE_LEAK_DETECT=1(594 files, 11,179 tests, zero leaks, exit 0) plus thedetector canarystep, which must still FAIL with the detector's own wording — confirmed exit 1 carryingprimed by an EARLIER test. - ✅ The #756 custom-resource authz retry now fires when the handler SWALLOWED the denial (issue #1674, reported by @sakurai-ryo) —
src/provisioning/providers/custom-resource-provider.ts,tests/unit/provisioning/custom-resource-provider-authz-log-tail.test.ts. The bug: #756 retries a custom resource that FAILed on the IAM-propagation race (cdkd's fast SDK path writes the backing function's execution-role policy and invokes it seconds later, so the cold start caches pre-policy credentials), and it classifies on the FAILED reason string viaCR_TRANSIENT_AUTHZ_SIGNALS. But the reason is written by the HANDLER, so a handler that wraps an SDK / CLI failure in its own message — ordinary handler hygiene — erases every authz phrase before cdkd ever sees it. CDK'ss3deploy.BucketDeploymentis the widely-used instance and is GUARANTEED to hit the race, because CDK generates the handler role, its inline policy and the custom resource in the same stack:aws_command()letssubprocess.check_callraise,str(CalledProcessError)is onlyCommand '[...]' returned non-zero exit status 1., and the asset-object 403 reaches cdkd with no authz wording at all. So a resource #756 would have recovered failed the deploy on its FIRST attempt, with a message that reads like a handler bug; the 403 existed only in the backing function's CloudWatch log, which the user had to open to find out. The fix:invokeLambdanow passesLogType: 'Tail', so the invoke response carries the last 4 KB of THAT invocation's log inline (LogResult, base64).sendRequestreturns the decoded tail alongside the cfn-response, and when the reason itself carried no authz wording the retry consultsCR_TRANSIENT_AUTHZ_LOG_SIGNALS— the reason set PLUS the CLI / SDK spellings of the same denial (an error occurred (403)/accessdenied/access denied) — against the tail. Why the tail rather than the issue's suggested options: reading CloudWatch Logs after the fact (its option 2) would need a new client,logs:GetLogEventson cdkd's own credentials and an extra API call, and would target whatever is latest in the log stream rather than the invocation that failed; the tail rides the invoke response cdkd already awaits, so it costs none of those and is invocation-scoped. Triggering on the deploy-time IAM edge (its option 1, and the one the reporter favored) was NOT taken:CreateContextcarries onlyreplayingState, so there is no plumbing from the deploy engine to the provider today, and adding it would land indeploy-engine.ts— a cross-cutting file — to replace a signal the log tail already supplies precisely. Diagnostics were the other half of the report and are fixed too, and WHERE each thing is surfaced turned out to be a data-class decision rather than a formatting one — three independent reviewers converged on it. The axis turned out to be WHO AUTHORED THE TEXT, not how long it is, and it took TWO review rounds to land there — both earlier positions were wrong, and each was rejected by two reviewers independently. Round one: the first cut appended the WHOLE decoded tail to theFunctionErrorthrow. A thrown message is captured byextractDeploymentEventErrorintodeployments/{runId}.jsonl— a store that OUTLIVEScdkd destroyand whose own header restricts it to error + metadata, explicitly never anything that may carry secrets — so aprint(event)in a handler would have written that resource'sResourcePropertiesinto permanent state. Round two: the fix for that folded the matched LINE intocfnResponse.Reason,truncateReasond to 200 chars, on the argument that "the reason is already handler-authored free text persisted through this same path". That argument does not transfer, and the truncation is not the safeguard it looks like: a reason is text the handler CHOSE to hand to CloudFormation, a log line is text it wrote for itself, and 200 chars bounds the VOLUME while being precisely where a dumped properties bag begins. So what is folded in now is the matched SIGNAL PHRASE — one of the fixedCR_TRANSIENT_AUTHZ_LOG_SIGNALSstrings, authored by cdkd — and the persisted record carries zero handler-authored bytes while still saying "the log matched an IAM-authorization signal" instead of pointing back at CloudWatch. The verbatim line and the full tail live only in ephemerallogger.warns, themselves capped byCR_LOG_TAIL_WARN_MAX_CHARSbecause CI logs and scrollback are not nothing either. The tail is also returned UNDECODED fromsendRequestand decoded only on a FAILED whose reason missed, so the happy path pays nothing. Known bounds, recorded rather than papered over: the tail always belongs to the DISPATCH invoke — for a handler that works inline and PUTs the cfn-response itself (BucketDeployment, the case this exists for) that IS where the failure happened, but for the Provider framework's genuinely async pattern it is not, so a stray denial logged by theonEventwrapper buys a bounded extra retry naming the wrong execution; that path is otherwise covered by the reason string, so the cost is redundancy, not a wrong answer. A reviewer proposed gating the tail onisAsyncPatternto close that; it was NOT taken and the reason is recorded in-code, because that flag means only "the invoke returned no direct payload", which is equally true ofBucketDeployment(its Python handler returnsNone) — gating on it would have switched off exactly the case the change exists for. A bare403/forbiddenis likewise deliberately not a signal. Accepted cost: a handler that merely LOGS a genuine permanentAccessDeniedbuys the bounded retries, so the failure is DELAYED, never masked. Tests: 36 unit cases pinning the reporter's shape VERBATIM (the realBucketDeploymentreason and a real log tail) rather than a paraphrase that happens to still match — retry-then-succeed, the swallowed denial named in the warning,LogType: 'Tail'asserted on every invoke, the denial still reported when retries are exhausted, and the four ways the fallback must NOT fire (a handler bug whose tail carries no denial, no tail at all, an undecodableLogResult, the SNS path with no Lambda invoke), plus classifier cases covering the CLI / SDK denial spellings and non-denial log noise (downloading 403 objects,HTTP/1.1 403). Binding-proved by mutation against the shipped source: droppingLogType: 'Tail'fails 12 cases, disabling the log-tail retry arm 6, folding the VERBATIM handler line into the reason 4, appending the tail to theFunctionErrorthrow 1, and removing the ephemeral warn cap 1. The secret-leak probe is the one that changed most: the first version placed its marker at the END of the tail, so a regression folding a TRUNCATED tail sailed past it — the assertion is now anchored at the end of the message and catches any appended content. Real-AWS wire probe (us-east-1, 2026-08-12), because a mocked SDK test endorses a wire assumption instead of proving it: a throwaway Python Lambda shelling out viasubprocess.check_callreturned the reasonCommand '['/bin/cat', '/nonexistent/asset.zip']' returned non-zero exit status 1.with no trace of the cause, while the same invoke'sLogResultdecoded tocat: /nonexistent/asset.zip: No such file or directory— confirming that a handler's SUBPROCESS stderr does reach the tail, which is the single assumption the whole fallback rests on. Probe resources (function, role, auto-created log group) were deleted and verified gone. Four integs (custom-resource-provider,bucket-deployment— the resource the issue reports —,aws-custom-resource,custom-resource-getatt-data) all destroyed cleanly with 0 errors and 0 orphans. Two follow-ups the review surfaced are filed rather than bundled: the tail is still discarded for NON-authz terminal failures (issue #1687), and a handler that CRASHES on the same 403 (FunctionError) is still not retried although the signal is already in hand (issue #1688). No CLI flag, dependency, or state-schema change. - ✅ Glue: every catalog-scoped call threads
CatalogId, and{Ref: <AWS::Glue::Table>}resolves to the table name (issues #1675 / #1667) —src/provisioning/providers/glue-provider.ts,src/deployment/intrinsic-function-resolver.ts,tests/unit/provisioning/providers/glue-provider.test.ts,tests/unit/deployment/intrinsic-functions.test.ts. #1675 — the silent leak:GlueProvider.deleteTablesentDeleteTableCommand({DatabaseName, Name})with NOCatalogIdwhilecreateTableandimportTableboth forwarded it. Omitting the field selects the caller account's DEFAULT Data Catalog, so a table adopted into a cross-account or Lake Formation federated catalog answeredEntityNotFoundException, the delete path's warn-and-continue idempotency treated that as "already gone", andcdkd destroyreported SUCCESS while the table survived. cdkd's physical id (<databaseName>|<tableName>) encodes no catalog, but the properties bagResourceProvider.deletereceives does. A new shareddeleteCatalogId()/catalogIdForApi()pair reads the field so an unresolved intrinsic OBJECT is DROPPED rather than sent (theimportableStringguard #1651 introduced) and a YAML-numeric account id is COERCED to a string rather than dropped —--migrate-from-cloudformationreads the stack's ORIGINAL template, where an unquotedCatalogId: 123456789012parses as a JSON number, and dropping it would retarget the DELETE at the default catalog where a same-named table can exist and be destroyed instead. The NotFound arm now DISCRIMINATES: a NotFound after a correctly-targeted delete stays at debug, while one after cdkd knowingly fell back to the default catalog WARNS and names the manual remedy (not "re-run" — oncdkd destroythe state record is already gone). The account-id pseudo parameter ({Ref: AWS::AccountId}/ the twoFn::Subspellings — what@aws-cdk/aws-glue-alpharenders for an environment-agnostic stack) is deliberately EXCLUDED from that warning: its resolved value IS the API default, so dropping it is provably harmless and warning would fire a leak alarm on every ordinary destroy. Both log arms name the catalog actually addressed, so a LITERAL-but-wrongCatalogId(a typo, a stale account id) is diagnosable from the log. Sibling audit of every Glue delete path:deleteTablewas the missing one;deleteDatabaseandGlueConnectionProvider.deletealready forwarded it but through a bareas string | undefinedcast that would have sent an intrinsic object, and both moved onto the shared guard;DeleteWorkflow/DeleteSecurityConfiguration/DeleteJob/DeleteCrawler/DeleteTriggerhave no gap (neither the CFn type nor the SDK request shape declaresCatalogId), so Table / Database / Connection are the complete set. The same guard was extended to every OTHER read whose bag can be a raw template — the threereadCurrentStatereaders andGlueConnectionProvider.import(the straggler #1651 left on a bare cast), socdkd driftstops handingGetTable/GetConnectionan object. CREATE / UPDATE reads deliberately keep their cast (the deploy engine resolves intrinsics first; that is the separate #1513 decision), with the rollback-replay residual recorded in-code. #1667 — the wrongRef:AWS::Glue::Tablewas in neither ofcfnRefValueFromPhysicalId's compound-id Sets althoughGlueProviderstores<databaseName>|<tableName>, so{Ref: <Table>}resolved tomydb|my_tablewhere CloudFormation returns the TABLE NAME — and that value was pushed to AWS by whatever consumed it (a crawler'sTargets.CatalogTargets[].Tables, aCfnOutput, a Lake Formation permission). The type joinsREF_RETURNS_SEGMENT_AFTER_PIPE; the fix also reachescdkd orphan's{Ref: <orphan>}rewriter, which shares the same pure helper. Pre-#1651 the path was unreachable forcdk deploy-managed stacks (they could not be imported at all), which is what widened its reach. Composite-type sibling audit (every row ofdocs/state-management.md's composite table plus the two types it lists as ACCEPTING a composite), recorded as an AUDIT RECORD comment above the Set: correct to exclude becauseRefis a synthetic / AWS-generated id —ApiGateway::Method,EC2::NetworkAclEntry,EC2::Route,EC2::VPCGatewayAttachment,Lambda::EventInvokeConfig; correct to exclude because the docs page documents noRefat all —EC2::SecurityGroupIngress(and its Egress sibling) andLambda::Permission; already correct —EC2::EIP,S3Tables::Namespace/::Table,ECS::Service; KNOWN WRONG and not fixable by a Set entry, filed as #1681 —AppSync::ApiKey/::DataSource/::Resolver(Refreturns the resource ARN, so it must be RECONSTRUCTED like the WAFv2 WebACL case, not extracted) andRoute53::RecordSet(Refreturns "the name of the record" — the MIDDLE segment of<hostedZoneId>|<name>|<type>, which neither extraction direction yields). Two stale claims in the resolver were corrected in the same pass: the Set is no longer described as Cloud-Control-only (it now carries SDK-provisioned provider-built compounds too), and the after-last-pipe entry no longer claims the extraction is universally safe —createTablepacks${db}|${name}UNGUARDED, so a deploy-created table nameda|bis already broken independently of this entry (the create-path half of #1672). Tests: 24 new Glue cases (exactDeleteTableCommandinput withCatalogId; the drop-on-intrinsic, no-CatalogId-declared, absent-bag, numeric-coercion, non-finite, empty-string and explicit-nullarms; both polarities of the pseudo-parameter carve-out; the Database / Connection skip-helper call sites bykind; the malformed-physicalId skip arm the issue quotes as the leak surface; the four read-path sites) plus 3 resolver cases asserting the RESOLVED valuemy_table— bare, throughFn::Join, and the pipe-free no-op. Binding proofs run for each fix by stripping it from real code and observing the named failures.intrinsic-function-resolver.tsis in theinteg-broadmarkgate scope, so a broad real-AWS integ is required before merge. No CLI flag, dependency, or state-schema change. - ✅
AWS::S3::Bucket's warn-and-SUBSTITUTE reads now record the value they SENT, and the analytics readback stops emitting a shape CloudFormation does not declare (issue #1670) —src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-substituted-properties.test.ts,.claude/rules/providers.md,docs/provider-development.md. The gap: #1612 fixed the warn-and-SKIP arms; the warn-and-SUBSTITUTE reads in the same appliers were deliberately left alone (they are the reason the skip signal had to be explicit rather than anonUnusablewrapper) and were the same bug class unfixed. The issue named three —applyAnalyticsConfigurations'StorageClassAnalysis.DataExport.OutputSchemaVersionand destinationFormat, plusapplyInventoryConfigurations' destinationFormat— and review found a FOURTH inside the same method: the inventoryScheduleFrequency->Schedule.Frequencyfall-through (#1605), which warns, SENDS the second source, and left the malformed first source in the record. Each WARNS and then SENDS a substituted value, so the Put SUCCEEDS and the engine recorded the malformed DECLARED value while AWS holdsV_1/CSV/ the fallen-back cadence;analyticsSdkToCfn/inventorySdkToCfnread all of them back, so the difference was visible to the comparator and never converged — the #1591 / #1633 phantom-drift loop reached through a per-item Put. Now the fourId-keyed appliers return aPerItemApplyOutcome({skipped, substituted}) instead of a bare index list, and both call-site recorders fold the substituted item intoeffectiveProperties: on UPDATE and on the replay-CREATE arm alike the item is kept IN PLACE, in its DECLARED shape, with only the substituted field replaced — a substitution is not a skip (the configuration WAS applied), so collapsing the two arms would drop the item on create and retain a stale previous item on update, manufacturing a fresh phantom drift in place of the one being removed. Three details are load-bearing: the write-back targets the destination branch the template actually declared (the CFnDestinationblock is accepted flattened AND nested, so a hardcoded branch leaves the malformed value alive at the other key and adds a stray one —s3BucketDestinationSegmentsderives it from the picked bag's IDENTITY rather than re-runningisFlattenedDestination, since a third copy of that branch condition is what the predicate's own header warns about);readSubstitutedConfigStringhands the recorder the value the read RETURNED rather than the fallback literal, and forwards ONEConfigStringOptionsbag to both theconfigStringRefusalprobe and thereadConfigStringit fronts, so neither "recorded vs sent" nor "probe vs read" can drift apart; and the item is COPIED, never mutated, because the desired bag is still read byDiffCalculatorafterwards. WithoutonUnusablethere is no probe and the template-borne CREATE still REFUSES, which the substitution deliberately does not weaken. A send-side record is only half a fix, and the review caught the other half missing:analyticsSdkToCfnemitted the SDK's NESTEDDestination: { S3BucketDestination: … }wrapper, a shape the CFn schema does not declare at all —tests/fixtures/cfn-schemas/AWS-S3-Bucket.jsoncontains zero occurrences of that key and itsnestedPropertyPathsend atStorageClassAnalysis.DataExport.Destination.{BucketAccountId,BucketArn,Format,Prefix}, while the inventory sibling already emitted the flattened form. So the WHOLE analytics destination sub-object differed from any template-shaped or effective-properties-shaped baseline on every comparison, and the analyticsFormathalf of this fix would have done nothing. The mapper now emits the flattened CFn shape with the same!== undefinedper-field guards asinventorySdkToCfn(keeping theBucket->BucketArnrename, and the SDK'sBucketAccountIdspelling, which isAccountIdon the inventory side — the two mappers look alike and are NOT interchangeable on that member). Transitional note: a bucket whoseobservedPropertieswere captured by an older binary holds the nested shape, so the firstcdkd driftafter upgrading reports the analytics destination once; the next deploy (orcdkd drift --accept) re-captures the flattened shape and it converges. Deliberately NOcanonicalizeDesiredPropertiestwin, and NOT by inheriting #1612's carve-out — that one is about a SKIP, whose effect is not a pure function of the desired bag, whereas a substitution's is, so the #1633 twin rule genuinely reaches these sites and was answered on the merits, in three findings now recorded as a reusable checklist in.claude/rules/providers.md. (1) The hazard the twin averts — a create-only property re-reading as a REPLACEMENT — cannot arise:AWS::S3::Bucket'screateOnlyPropertiesisBucketName/BucketNamePrefix/BucketNamespaceand neither array property is named in either half of the type'sReplacementRulesRegistryentry, soisClassifiedis false, the createOnly fallback decides, and the un-canonicalized diff derives an in-place UPDATE re-issuing the same idempotent Put. (2) Sharing the twin with the provisioning path is a COST question, not an impossibility one — a path-conditional substitution is no obstacle in itself, sincenarrowIngressIpProtocolalso throws without anonUnusableandcanonicalizeDesiredPropertiesbridges it with a no-op callback; what differs is that EC2 folds one top-level scalar while the effective value here is rebuilt per ITEM at the declared destination branch inside a per-Idloop, so the shared pure helper would re-implement the applier's item walk. (3) The twin would CONCEAL the defect, silencing the one warning the user gets on UPDATE while an identical fresh deploy hard-refuses — and EC2 accepts exactly that concealment because there it PREVENTS a replacement of a rule AWS already holds, whereas finding 1 says there is no replacement to prevent here. The accepted cost is the mirror image of the drift this removes —cdkd diffkeeps reporting the property until the template is corrected, which is TRUE and ends with one template edit — and a unit row fences the decision so implementing the hook later has to be a re-derivation rather than a reflex. The #1643 bar ("record what AWS will REPORT") is met by measurement of the API surface:StorageClassAnalysisSchemaVersionhas exactly one member (V_1),AnalyticsS3ExportFileFormatexactly one (CSV),InventoryFormatisCSV | ORC | Parquetwith no aliasing, and both reverse mappers copy the fields straight back — there is no service-side value MAPPING (theIpProtocol: 6 -> tcpshape), so the send-side record is the one that converges. Tests: 25 cases — the five malformed shapes each asserted against the value read back off the SENT command (not merely against a literal), both destination branches with the sibling-key-absent assertion, two substitutions accumulating in one item, a SUBSTITUTED and a SKIPPED item coexisting in one array, the substituted item recorded at ITS OWN index behind a skipped and a clean one (a hardcoded0passes all 5727 provisioning tests otherwise), the replay-CREATE item kept rather than dropped with its wire value asserted, theScheduleFrequencyfall-through with its skip-arm and precedence siblings, a closed-loop round trip (desired -> effective record -> the PUT configuration echoed back through the List API ->readCurrentState-> the realcalculateResourceDriftreports nothing) paired with a negative twin proving the declared value does NOT converge, the desired bag asserted unmutated, and three negative-polarity rows (a well-formed value and an ABSENT field both record NOTHING —effectivePropertiesundefined, not equal-to-desired — and a template-path create still throws). Five mutation probes: neutering the substitution report fails 14 of 19 pre-existing rows, hardcoding the flattened destination branch fails exactly the 2 nested rows, hardcoding the recorded index fails the index row, deleting theScheduleFrequencyrecord fails its row, and reverting the analytics mapper to the nested shape fails the convergence row. Live coverage:tests/integration/s3-analytics-inventorygains three phases, because the fixture passed for the wrong reason — nothing in it carried a malformed value, so no substitution arm was ever reached, nothing read the state record back, and nothing compared the recorded bag against the readback (the only comparison that could have caught the nested-vs-flattened mapper). A newCDKD_TEST_UPDATE=malformed-substitutemode blanks the three template-expressible fields (a BLANK STRING is what an unresolved intrinsic produces, is exactly whatconfigStringRefusalrefuses, and needs no cast to express). Phase 2 asserts the deploy succeeds, that each substitution was ANNOUNCED — the anti-vacuity guard, since the substituted values equal phase 1's and a run where the Put never fired would satisfy every state assertion — that AWS holds the defaults, that the STATE record holds the SUBSTITUTED value, and that the recorded AND observed analytics destinations are FLATTENED; the observed side is written byanalyticsSdkToCfnfrom real AWS data, so it is the live proof of the mapper half. The mode leaves every other value at its phase-1 setting, so the substituted result is byte-identical to the phase-1 template (verified by synthesizing all three modes locally) and phase 3 requirescdkd diff --jsonto report no analytics/inventory difference, twice — with a NEGATIVE twin that rewrites the recorded destination into the pre-fix NESTED shape and requirescdkd diff --failto NAME it, so a non-zero exit for an unrelated reason cannot satisfy it.diffrather thandriftbecause the drift comparator prefersobservedPropertiesand both sides of that comparison come from the same readback mapper, so a wrong-shaped mapper agrees with itself. Phase 4 hand-patches the state record malformed and redeploys the valid template: that does NOT reach the substitution arms (on a redeploy the desired bag comes from the TEMPLATE) but does prove only the DESIRED side is guarded, so a value an older binary recorded cannot wedge the stack. TheScheduleFrequencyfall-through stays unit-only — the CFn schema declares noSchedulemember and aws-cdk-lib's L1 renderer drops a member it does not declare, so no CDK template can carry the second source. No CLI flag, dependency, or state-schema change. - ✅ DynamoDB GlobalTable
StreamSpecificationand Lambda URLAuthTypewarn arms now reporteffectiveProperties(issues #1653 / #1654) —src/provisioning/providers/dynamodb-globaltable-provider.ts,src/provisioning/providers/lambda-url-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-stream-spec-effective-properties.test.ts,tests/unit/provisioning/lambda-url-provider-authtype-effective-properties.test.ts,.claude/rules/providers.md,docs/provider-development.md. The two non-S3 sites the #1612 audit found. Before: both guards let the deploy SUCCEED while the declared value never reached AWS as written, so the engine recorded the malformed desired bag —readCurrentStatecould never match it, every latercdkd driftre-reported the same difference, anddrift --revertre-issued the same call. Now each returns the bag actually delivered, and the answer differs per arm because what reached AWS differs. GlobalTable UPDATE (the #1551 warn-and-SKIP) retains the PREVIOUSStreamSpecification— theUpdateTablenever ran, so AWS still holds the previously-applied configuration. Lambda URL's previous-value arm records the previousAuthType, which is literally what went on the wire. Lambda URL's OMITTED arm (previous side unusable too, soAuthTypeis left out of the merge-semantics update) DROPS the key: recording the create default'NONE'would describe a PUBLIC function URL that may still be IAM-guarded, and recording the live value off theUpdateFunctionUrlConfigresponse would be what AWS HOLDS rather than what cdkd SENT — a read-back value belongs inobservedProperties, not in thepropertiesbaseline the #1160 absent-field removal derivation reads — while a dropped key is never compared bydrift-calculator, so the phantom drift is gone either way. Neither gains acanonicalizeDesiredPropertiestwin, per the #1612 carve-out: these are a SKIP and a SUBSTITUTION, not pure narrowings, so canonicalizing the desired side would derive a REMOVAL and disable the live stream / auth type. The GlobalTable replay-CREATE arm deliberately DIVERGES from the carve-out's "replay-CREATE -> DROP the key" answer, and both rules files now record why: that answer is scoped to a SKIP, where nothing reached AWS, whereas this arm's downgrade SUBSTITUTES theNEW_AND_OLD_IMAGESdefault and a stream really is created — so #1633's "what you return is what you SENT" binds instead, and the value is recorded in the CFn shape (StreamViewTypeonly — GlobalTable'sStreamSpecificationdeclares noStreamEnabled, verified against aws-cdk-lib's converter) so the effective bag is indistinguishable from an ordinary template-path create's. That arm was NOT observable when this landed — the only caller that setsreplayingStatediscardedeffectiveProperties— but #1682 / PR #1696 has since wired the reverse-replacement create to honour it, so the arm is LIVE; what it still lacks is per-provider live coverage (the #1696 fixture proves the engine path viaAWS::EC2::Route), tracked with the S3 create arm from #1660 as #1706 and flagged in-code at both the provider arm and the fixture. Two review findings shipped with it. The previous value is now VALIDATED through the sameconfigStringRefusalpredicate the desired side runs before being retained:previousPropertiesis a STATE record, so on a replay it can holdnull/''/ a bare string, and copying that in re-created the same phantom drift from the other direction; when both sides are unusable the key is dropped, matching the Lambda arm, and the retained value is COPIED because the rollback executor spreads the answer shallowly. AndLambdaUrlProvider.creategained a replay warning for an ABSENTAuthType: an absent value is not malformed, so neither the refusal norreplayWarnfires and'NONE'applies silently — meaning the new drop would have let a later reverse-replacement replay recreate the URL as PUBLIC with no evidence anywhere, strictly worse than the malformed-value-in-state behavior it replaced. The default still applies (refusing would make the URL unrestorable) but is now announced, and a template-path create with a legitimately absentAuthTypestays silent. Tests: 30 cases across the two new files — exact recorded values on every arm, the six malformed previous-side shapes, the empty-block previous that must still be retained, the copy-not-alias pin, the identically-malformed pair that never reaches the skip at all, the wire-negative proving nothing was sent, and the negative polarities (a well-formed value, an absent value and the diff-based no-op all answer with noeffectiveProperties); mutation-probed, with the create warn, the previous-side validation and the copy each failing their own cases when reverted. Sibling arms in the same GlobalTable method —BillingModewarn-and-SUBSTITUTE,desiredGsiUnusablewarn-and-SKIP, theneedsStreamauto-enable and theStreamSpecification: {}silent default — are the same class, out of scope here, and tracked in #1683 with in-code pointers. No CLI flag, dependency, or state-schema change. - ✅
AWS::Glue::Tableimport accepts CloudFormation's physicalId, not only cdkd's composite form (issue #1651, reported by @sakurai-ryo) —src/provisioning/providers/glue-provider.ts,tests/unit/provisioning/providers/glue-provider.test.ts. The bug: cdkd's physicalId for a Glue table is the composite<databaseName>|<tableName>, built bycreateTablebecauseGetTable/UpdateTable/DeleteTableall need both segments whileResourceProvider's read-side methods (getAttribute,readCurrentState) receive a single string. CloudFormation's physicalId for the same type is the TABLE NAME ALONE —Refreturns it,DescribeStackResourcesreports it, and it never contains|. Auto-mode import merges CFn-derived ids into the overrides map before the loop (#1128 / #1130), soknownPhysicalIdlegitimately arrived as"my_table";importTabledidconst [dbName, tName] = knownPhysicalId.split('|')and returnednullon the resultingtName === undefinedWITHOUT ever calling AWS. Everycdk deploy-managed Glue table therefore reportedskipped-not-foundundercdkd importandcdkd import --migrate-from-cloudformation, while the accompanying hint told the user topass --resource <LogicalId>=<physicalId>— pointing them at exactly the id the code had just rejected. The template-derived fallback below would have succeeded (CDK rendersDatabaseNameas aRefto the siblingAWS::Glue::Database, whose CFn physicalId IS the database name, sosubstituteOverrideRefsresolves it to a literal beforeimport()runs), but the presence ofknownPhysicalIdshort-circuited past it — which is what makes this an early-return defect rather than a missing feature.docs/import.mdlists the type under "Auto-resolved (no--resourceflag needed)", a claim the fix makes true. The fix:resolveTableIdentityresolves the(databaseName, tableName)pair from EITHER shape — a|inknownPhysicalIdmeans the composite, its absence means CloudFormation's bare table name, paired with the template'sDatabaseName. The probe result is ALWAYS normalized back to<db>|<table>before it is recorded, sinceupdateTable/deleteTable/readTableall split the stored id — adopting a table under the bare CFn name would have written a state record the rest of the provider cannot use, trading a visible not-found for a silent one (deleteTable's malformed-id arm warns and SKIPS, so the failure would have surfaced as a leaked table on acdkd destroythat reported success).|is the discriminator, and the review established its limit rather than letting the code assume it away: the "lowercase alphanumerics and underscore" rule usually quoted for Glue is the Athena / Data Catalog CONVENTION, not the API's constraint — a live probe (us-east-1, 2026-08-12) showedglue:CreateTableACCEPTS a table literally nameda|b. Such a table is deliberately NOT adoptable, and arriving at that answer took two rounds. The first response to the probe was a fallback: try the composite reading, then retry the bare one. Three independent reviewers converged on why that is wrong — the fallback SUCCEEDS by recording<db>|a|b, andupdateTable/deleteTable/readTableall destructure the stored id into exactly TWO segments, so that record decodes as database<db>, tablea— a DIFFERENT table, whichdeleteTablewould then delete. It traded a visible not-found for precisely the silent corruption the normalization exists to prevent, and the test written for it asserted the broken id as correct output, locking the invariant in. Issue #1658 landed mid-review as independent confirmation:AWS::Route53::RecordSet'simport()accepts CloudFormation's id verbatim and the adopted stack becomes UNDESTROYABLE — the same class, and the reporter's framing is that the accepted-into-state half is the more severe one. So the fallback was REMOVED and replaced by a round-trip guard:resolveTableIdentityrefuses any resolved segment containing|, whatever branch produced it (the composite and bare branches are|-free by construction, but a templateTableInput.Nameofa|bis not), and the refusal happens BEFORE any AWS call. A third review round then found that guard incomplete, in the direction that matters: an id with MORE than two segments destructured to its first two, somydb|a|bread as databasemydb, tablea— both|-free, so the guard passed — and cdkd probed and adopted a DIFFERENT table under an id that round-trips cleanly and therefore never looks wrong again. That shape is on the INVITED path rather than hypothetical, because the refusal warning tells the user to pass<databaseName>|<tableName>, so someone whose table is nameda|btypes exactly it. The composite branch now requires EXACTLY two segments. The refusal also stopped guessing its own reason:resolveTableIdentityreturns a discriminated{ok: false, reason}(pipe-in-name/unpairable/unidentified) because re-deriving the cause at the call site got it wrong —'db|'contains a|but its real defect is the empty segment — and the three causes need three different messages, one of which ("rename the table, no id shape will work") is not the--resourcehint at all. A non-not-found error is rethrown rather than reported as absent — a throttle or an authorization failure means the answer is UNKNOWN. An explicit override wins over the template'sTableInput.Name— the user named a specific resource to adopt.importableStringalso covers the override itself, so a--resource-mappingentry of""(whichparseMappingJsonaccepts, unlike the--resourceflag) stays not-found instead of sendingGetTable({Name: ''}), whoseInvalidInputExceptionis notEntityNotFoundExceptionand would abort the whole import run. A bare id with no templateDatabaseNameto pair with still returnsnullwith no AWS call, but now WARNS with the composite form spelled out, because the caller-side hint tells the user to pass--resource <LogicalId>=<physicalId>and passing CloudFormation's bare name again fails identically — the dead end this issue is about, one step over. Second, independent defect, same issue:CatalogIdwas read asproperties['CatalogId'] as string | undefinedand forwarded toGetTable/GetDatabasewhenever truthy.@aws-cdk/aws-glue-alphasetscatalogId: Stack.of(this).account, which renders as{"Ref": "AWS::AccountId"}for an environment-agnostic stack; a pseudo parameter is never in the overrides map, sosubstituteOverrideRefsleaves the intrinsic in place and the cast handed an OBJECT to the Glue API as if it were a catalog id. The newimportableStringguard drops any non-string, which matches the API default (the caller's own account) — i.e. what the intrinsic would have resolved to anyway. It is applied toCatalogId,DatabaseNameandTableInput.Nameon the import path in bothimportTableandimportDatabase. Deploy-path reads are deliberately untouched: the deploy engine resolves intrinsics before the provider sees them, so guarding there is the separate #1513 decision. Scope correction on the issue's fourth suggestion: adding anAWS::Glue::Tableentry toCOMPOSITE_ID_SPLITTERSinsrc/cli/commands/export.tswould be dead code.DescribeTypereportsprimaryIdentifier: ["/properties/Id"]— a SINGLE field — soresolveResourceIdentifiertakes the single-key branch and returns{ Id: physicalId }before ever reaching the splitter lookup. The export-side defect is real (cdkd's composite is handed to CFn asId) but needs a different mechanism, so it is tracked separately rather than bundled here. Tests: 17 unit cases plus a real-AWS integ phase. Units: the bare CFn id paired with the template database, the composite accepted with NO templateDatabaseNamepresent (so the composite cannot come to depend on it), the template-only fallback, a bare id with nothing to pair with, the warning naming the composite form, BOTH precedence directions where the override and the template DISAGREE (a review finding — with agreeing values, swapping the precedence passed every other test in the file, so it pinned nothing),EntityNotFoundExceptionwith an explicit call-count assertion (another review finding —toBeNull()alone is satisfied by "declined before calling AWS", i.e. the pre-fix behavior), a non-not-found error rethrown rather than reported as absent, thea|brefusal with no AWS call, an empty-string override staying not-found, an unresolvedCatalogIdintrinsic dropped on both the Table and Database paths, a literalCatalogIdstill forwarded, and an unresolvedDatabaseInput.Nameintrinsic no longer probed with. A 3-case ROUND-TRIP fence covers the property #1658 shows actually matters, independent of which branch produced the id: whatever is recorded must have exactly two segments and decode back to the pair that was probed. The headline case was mutation-probed: forcing the composite branch unconditionally fails it. Real-AWS coverage — the fix is about an id auto mode had already resolved, so a mocked test cannot show the resolution actually happened: theimport-auto-modefixture (which exists for #1128, the RESOLUTION half of this same path) gains a Glue database + table pair.DatabaseNameis aRefto the sibling database — what CDK emits, and the reason a bare table name can be paired at all — phase 2b asserts CloudFormation's reported table id carries no|before anything relies on it, phase 3 asserts the CFn lookup seeded all THREE ids (without it, dropping the table's override would letimportTable's template fallback produce the identical composite and the fixture would stay green while testing nothing — a review finding), phase 4b asserts the ADOPTED id is the composite, and phase 5 gone-probes both Glue resources. Verified in us-east-1: 3 imported / 0 not found, 3 deleted / 0 errors / 0 orphans. Review also found two defects OUTSIDE this diff, filed rather than bundled: #1667 —{Ref: <AWS::Glue::Table>}resolves to the composite because the type is in neither ofintrinsic-function-resolver.ts's compound-id sets, a pre-existing bug this fix WIDENS the reach of (previously such stacks could not be adopted at all, so the path was unreachable); and #1668 —S3TablesProvider.import()adopts an unverifiedknownPhysicalId, the silent variant of this same class. The suite'sbeforeEachalso gained amockGlueSend.mockReset(), sincevi.clearAllMocks()does not drain themockResolvedValueOncequeue and a test failing before it consumes its primed response shifts it into the next one — the leak class issue #1618's detector exists to catch. No CLI flag, dependency, or state-schema change. - ✅
AWS::S3::Bucket's warn-and-skip replay arms now reporteffectiveProperties, so a skipped Put stops producing permanent phantom drift (issue #1612) —src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-provider-effective-properties.test.ts,.claude/rules/providers.md,docs/provider-development.md. Before: issues #1579 / #1581 / #1595 / #1605 each converted a hard refusal into a warn-and-skip on the replay-reachable paths — the right call, since a refusal there leaves the resource UN-ROLLBACKABLE with no template-side remedy. But the deploy then SUCCEEDS, soDeployEngine.propertiesToRecordrecorded the desired bag, malformed value and all, for a Put that never reached AWS.readCurrentStatecould never match it, every latercdkd driftre-reported the same difference, anddrift --revertre-issued the same skipped call — the #1591 phantom-drift loop, reached through a SKIP instead of a narrowing. The engine side has been ready since #1591 and, since #1644, so havedrift --revertand both rollback revert arms; the missing half was that the S3 skip paths never RETURNED it. Now all eight skip-capable appliers report their skip explicitly —applyVersioning/applyLifecycleConfiguration/applyLoggingConfiguration/applyReplicationConfigurationreturn aPromise<boolean>"applied", and the four per-Idappliers (applyMetricsConfigurations/applyAnalyticsConfigurations/applyIntelligentTieringConfigurations/applyInventoryConfigurations) return the indexes of the items they skipped — andcreate()/update()fold those intoeffectiveProperties. Reporting the skip EXPLICITLY rather than by wrappingonUnusableis load-bearing: that callback is shared by SKIP-class guards (configStringRefusal/requireConfigObject/requireConfigArray) and warn-and-DEFAULT reads (readConfigStringwith the options bag, where the applier proceeds WITH a substituted default — the analyticsOutputSchemaVersionand the analytics / inventory destinationFormat), and a wrapper cannot tell them apart, so a defaulted-but-APPLIED configuration would have been recorded as skipped and manufactured exactly the drift this removes. What is recorded differs per path and neither answer generalizes: on UPDATE the PREVIOUS value is retained (the Put never ran, so AWS still holds the previously-applied configuration; dropping the key instead would be wrong in the other direction — a later template that REMOVES the block would derive no removal and the live configuration would survive forever), on the replay-CREATE arm the key is DROPPED (the bucket is new, nothing was applied, there is no previous value to keep), and for the per-item appliers the effective array substitutes the previous item of the sameIdIN PLACE — dropping it when the skipped item was an ADD — preserving the DESIRED order, becauseDiffCalculatorcompares arrays positionally and a reordered effective array would manufacture a fresh phantom drift. Two skips beyond the malformed-value class take the same remedy: the Object-Lock arm that declines to suspend versioning (a structural AWS constraint, identical state consequence), and the create-path logging GATE. Deliberately NOcanonicalizeDesiredPropertiestwin, which is the opposite of what.claude/rules/providers.mdanddocs/provider-development.mdpreviously read as an unconditional rule — both now carry the carve-out. That rule is about a NARROWING, a pure function of the desired bag; for a SKIP the twin is actively destructive, because canonicalizing the desired side drops the malformed configuration from it and a previous side holding a VALID configuration then derives a REMOVAL — deleting the live lifecycle / replication configuration over one unusable field. Tests: 16 cases (per-path recording, the ABSENT-previous key removal, per-item in-place substitution, a skipped ADD dropped, and the two negative rows assertingeffectivePropertiesstays UNDEFINED when nothing was skipped); five mutation probes confirm they fence the behavior — dropping the key instead of retaining previous fails 7, appending retained items instead of substituting in place fails 1, always answering with the bag fails 2, recording[]instead of removing the key fails 1, and neutering the replay-CREATE arm fails 1. One non-obvious constraint is pinned in-code: the per-item loops keep a hand-kept index over a plainfor…ofrather thanconfigs.entries(), because the nested-key critic's write-evidence walk resolvesconfigas a value read off the desired property bag and cannot follow an array-destructuring pattern — the tidier spelling reported 9 falseno-write-evidencedivergences. No CLI flag, dependency, or state-schema change. The audit of item 3's non-S3 warn-and-skip sites foundAWS::EC2::Routealready covered by #1591, and two live gaps filed as #1653 (DynamoDB GlobalTableStreamSpecification) and #1654 (Lambda URLAuthType). - ✅ EC2: the two
IpProtocolresiduals split out of #1643 (issues #1648 / #1649) —src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/{ec2-canonicalize-ip-protocol-diff,ec2-provider-readcurrentstate}.test.ts,tests/integration/drift-revert-arrays/verify.sh,.claude/rules/code-layout.md. #1648 — the DIFF side. #1643 taught the readback that AWS substitutes a canonical NAME for four protocol numbers, butcanonicalizeDesiredPropertiesstill folded only the TYPE (#1633's stringification). So a template edit rewritingIpProtocol: 6to'tcp'read as a CHANGED property, and sinceIpProtocolis create-only onAWS::EC2::SecurityGroupIngressthe diff classified a REPLACEMENT — deleting and re-creating a rule AWS already held exactly. Both diff sides now get the name fold, for the standalone type and forAWS::EC2::SecurityGroup's inlineSecurityGroupIngress[]/SecurityGroupEgress[]lists (lower stakes there — not create-only — but the diff previewed anupdate()that, since #1643 keys both spellings identically, issues no revoke/authorize at all). The fold sits incanonicalizeDesiredPropertiesand deliberately NOT innarrowIngressIpProtocol, which also feeds the CREATE path: what cdkd puts on the WIRE is not the defect, since AWS accepts either spelling and storestcpfor both. #1649 — Tags were in neither camp. Every SIBLING standalone type (SecurityGroupIngress,Route,VPCGatewayAttachment,SubnetRouteTableAssociation,NetworkAclEntry,SubnetNetworkAclAssociation) declaresTagsingetDriftUnknownPathsbecause its read cannot return them.AWS::EC2::SecurityGroupwas neither declared NOR read, so a templated tag block compared againstundefinedand reported permanent phantom drift on theproperties-fallback baseline — the very population #1643's comparator half exists for, which is how it surfaced. Of the two candidate fixes the better one turned out to be available:DescribeSecurityGroupsreturnsTagsin the CFn[{Key,Value}]shape verbatim (measured us-east-1 2026-08-12), so the provider READS them rather than declaring them unreadable, and a REAL tag change becomes detectable instead of being permanently hidden.normalizeAwsTagsToCfnstrips the reservedaws:-prefixed entries a template can never declare; the key is omitted entirely when AWS reports no tags, so an untagged group is never compared against[]. Landing this let thedrift-revert-arraysstep 6bTagsworkaround be deleted. Both halves mutation-probed; 9 + 3 unit tests. - ✅ "Use in CI: per-PR environments" section in README + distributed skill v0.4.0 —
README.md(new section after the wait-modes section),plugins/cdkd-skills/skills/cdkd/SKILL.md, both manifests bumped to 0.4.0. CI was one of cdkd's main use cases with no assembled guidance: the pieces (diff --fail,publish-assets,--no-waitCI-minutes rationale,force-unlockafter cancelled jobs) were scattered, and--role-arn— the switch-role pattern most CI users need — appeared in README only as a Prerequisites link and in the skill not at all. The new README section follows the maintainer's Zenn article (cdkd-pr-environment-ci; content provided by the maintainer, deliberately NOT linked from the README — English-only reader base): per-PR stacks via-c prNumbercontext + name suffix (state keyed by (stackName, region), per-stack locks → concurrent PR deploys), the OIDC base-role →--role-arn/CDKD_ROLE_ARNswitch into a dedicated admin-equivalent deploy role (base role holds ONLYsts:AssumeRole; trust policy pinned back;cdk-hnb659fds-*roles unusable), a minimal GitHub Actions workflow (deploy on open/sync/reopen; destroy on close viastate destroy --yeswith no checkout/npm ci/synth), and housekeeping (state list --jsonsweeps,gcoutside deploy hours because of the lock abort,force-unlock,state show --jsonfor PR comments). The skill gains a matching "Use cdkd in CI" section placed AFTER the destructive-operation guards so its non-interactive exception reads as a refinement: in a CI workflow--yesis the sanctioned confirmation (the approval happened at workflow review), while interactive sessions keep the confirmation rules. One fact-check against the article: the stale-lock TTL is 30 minutes in current code (lock-manager.tsttlMinutes ?? 30), not the article's 15 — docs follow the code. - ✅ A runtime
*Once-leak detector now fails any test that consumes another test's primed mock response (issue #1618) —tests/once-leak-detector.ts,tests/setup.ts,tests/once-leak-allowlist.json,scripts/gen-once-leak-allowlist.ts,vite.config.ts,.github/workflows/ci.yml,.claude/rules/testing.md,docs/testing.md. Before:vi.clearAllMocks()clears call RECORDS but does not drain the queue seeded bymockResolvedValueOnceand its four siblings, so a test that primed more responses than its code path consumed leaked the remainder into a later test in the same file — which then read a response describing a different resource, took a different branch, and still PASSED, because the assertions on that branch are typically absence assertions (toBeUndefined(),not.toHaveBeenCalled()) that "the code never got there" satisfies just as well as "the guard correctly declined". Issue #1588 hit exactly this; its only symptom was onelogger.warnthat was never called, and locating it took a full instrumentation pass. After: every*Oncespelling funnels throughmock.mockImplementationOncein@vitest/spy, so instrumenting that one method wraps each queued implementation in a closure that remembers which test primed it; a value shifted off the queue during a DIFFERENT test fails that test, naming the earlier test to fix. The detector is inert unlessCDKD_ONCE_LEAK_DETECT=1, so the defaultvp run testis byte-for-byte unchanged and no in-flight branch is disturbed — the newonce-leak-detectCI job (vp run test:once-leak) is what arms it. Three pre-existing offenders are grandfathered intests/once-leak-allowlist.json(regenerate withvp run gen:once-leak-allowlist; fixing them is issue #1655), and an empty list is the goal state. Notable non-choices, all measured rather than assumed: the issue's own proposal — a static lint requiringmockReset()wherever a*Onceprimer appears — was rejected because 182 of the 265*Once-using files have no reset and the mechanical swap breaks 1181 tests, i.e. it checks a proxy and needs a 182-file remediation batch; and the first implementation's "queue non-empty when the test ends" heuristic was rejected after a probe suite showed it flags a suite that drains withmockReset()inbeforeEach(a setup-fileafterEachruns BEFORE the next test'sbeforeEach), which is the very remediation the docs prescribe. Checking the real defect implicated 3 files instead of 182 and needed no remediation batch. A deliberately-leaking canary suite plus adetector canaryCI step close the gate's own vacuity hole: with the grandfather list honoured, "the detector went dead" and "the tree is clean" produce the identical green result, so that step re-runs the canary alone with the allow-list ignored and requires a failure carrying the detector's own wording (an exit-code-only check would pass on a deleted canary file, whose "No test files found" also exits 1). The three-axis review then caught a real blocker:consumeOnceis afunctiondeclaration and therefore constructable, sotests/setup.ts's pre-existingwrapConstructableImplementationsaw a constructable value, passed it through unwrapped, and an ARROW implementation underneath died onReflect.construct— makingvp run test:once-leakdiverge behaviourally fromvp run test. Both patches now sharetests/constructable-implementation.ts. The same review found the construction test was a SURVIVOR (@vitest/spy already reaches the wrapper viaReflect.construct, so athis-mutating probe passes under.applytoo) and that thevi.spyOnarm had NO coverage at all — deleting it kept every check green while 13 real files lost protection. Both are now mutation-verified. 31 unit tests for the detector and allow-list; the detector was additionally probed against real code in both directions — a deliberate over-priming intests/unit/utils/aws-region-resolver.test.tsmade it exit non-zero naming the leak, and the allow-list suppressed it. - ✅
drift --revertand the rollback revert arms now honour a provider'seffectiveProperties(issue #1644, found by the test review on PR #1641) —src/cli/commands/drift.ts,src/deployment/rollback-executor.ts,tests/unit/cli/drift.test.ts,tests/unit/deployment/rollback-executor.test.ts,docs/cli-reference.md. Before:effectivePropertiesis how a provider says "this is the bag I ACTUALLY sent" — the loop breaker behind #1591 (AWS::EC2::Route) and #1633 (AWS::EC2::SecurityGroupIngress) — andDeployEngine.propertiesToRecordrecords it in place of the desired bag. The three OTHERprovider.update()call sites discarded the return value entirely:cdkd drift --revertand both rollback revert arms (revert,revert-failed-update). So a narrowing announced on those paths never reached state, the record kept describing a value AWS does not hold, the nextcdkd driftreported the identical difference, and--revertre-issued the identical revoke + re-authorize with the identical warning — forever, for anyone using--revertas their repair tool. The deploy path self-heals, so a singlecdkd deployconverged; the loop persisted only on these commands. Now all three record it. The two shapes differ because the bag handed toupdate()differs. The rollback arms sendpreviousState.propertiesverbatim, soeffectivePropertiesis its complete replacement and the arms store{...restored, properties: effectiveProperties}— physical id, attributes, dependencies and policies of the restored record all survive untouched.--revertsendsbuildRevertNewProperties's output — AWS-CURRENT values for every non-drifted key merged with the state baseline for the drifted ones — so writing that back wholesale would import AWS-authored values into state for keys nobody reverted, quietly turning--revertinto--accept. It therefore persists a per-key DELTA (collectNarrowedTopLevelKeys): only top-level keys where what the provider DELIVERED differs from what it was HANDED, with a key the provider dropped entirely represented as an explicitundefinedthe caller turns into adelete(a{...baseline, ...delta}spread would otherwise leave anundefined-valued key in the JSON). Three details of that delta were found by the PR review and each closes a route back to--acceptbehavior: presence is decided bykey in effectiverather than by comparing toundefined, so a genuinelynull-valued key on both sides is not a phantom drop while a DROP of one still is; the comparison is a key-order-INDEPENDENT deep equality rather thanJSON.stringify, because a provider that rebuilds a nested object with the same members would otherwise register as changed and the value written back for such a key is the AWS-current one that was SENT; and only a key the BASELINE already declares may move, since the sent bag starts as the AWS-current snapshot and carries keys state never tracked. A fourth: on a resource with NOobservedPropertiesonly a DROP is recorded, never a value — that baseline is the raw template andbuildRevertNewPropertiesran inpreserveUntemplatedmode (#1626), so the sent value deliberately carries every AWS-authored path the template never declared, and writing it intopropertieswould make the DESIRED baseline describe AWS-side data and silently disable the #1160 absent-field removal derivation that reads it. The capture also sits AFTER the per-resource success accounting in its owntry, so a provider handing back a non-comparable bag cannot re-report an already-succeeded revert asAWS update failed. The delta lands in the same field the drift comparator uses as its baseline —observedPropertieswhen the resource has one, elseproperties— matching--accept, sopropertieskeeps the user's last-deployed template intent: a narrowing is an AWS-side fact, not a template edit. The write happens ONCE per stack, after the concurrent per-resource tasks settle and still under the stack lock, with the sameexpectedEtagoptimistic-locking andmigrateLegacyhandling as--accept; a run where no provider narrowed anything writes no state at all, so the pre-existing "--revertdoes not touch state" behavior is unchanged for every resource type that does not report a narrowing. The write is BEST-EFFORT, unlike--accept's: there the state write IS the operation, here AWS has already been reverted and this is a secondary convergence step, so a failure warns and the command carries on — throwing would skip every later stack's revert under--all, a regression against not writing at all, and the warn path's only cost is that the narrowing re-surfaces on the nextcdkd drift(i.e. exactly the pre-fix state). Tests: 6 drift cases (observed-baseline write,propertiesfallback, the ONLY-provider-changed-keys fence with a drifted key and an AWS-onlyTagsaddition that must NOT move, a dropped key, and the negative case that must not write) + 3 rollback cases (both arms record, the no-narrowing arm keepspreviousStateby identity, and the caller's object is not mutated in place); all mutation-probed — neutering the drift wiring fails 4, neutering the rollback recorder fails 2. Applies to everyeffectivePropertiesproducer, soAWS::EC2::RouteandAWS::EC2::SecurityGroupIngressconverge on these paths today and any future producer inherits it. No CLI flag, dependency, or state-schema change. - ✅
IpProtocolreadback canonicalization forcdkd drift(issue #1643, the residual of #1633 / #1591) —src/utils/ip-protocol.ts(new, the shared value canonicalizer),src/analyzer/drift-protocol-normalize.ts(new, the path scoping),src/cli/commands/drift.ts,tests/unit/analyzer/drift-protocol-normalize.test.ts,.claude/rules/code-layout.md. Before: #1633 madeAWS::EC2::SecurityGroupIngressrecord theIpProtocolit actually SENDS, which fixed the two arms where cdkd's value differed from the declared one only in JS TYPE. It did NOT fix a protocol NUMBER that AWS RENAMES: forIpProtocol: 6cdkd sent and recorded'6', AWS held'tcp',readSecurityGroupIngressCurrentStatereturned'tcp', and everycdkd driftreported the difference forever while--revertrevoked and re-authorized into the same state — permanent phantom drift via a third route, and one #1633 deliberately left untouched for the inlineAWS::EC2::SecurityGroup.SecurityGroupIngress[]/SecurityGroupEgress[]rules as well. Now a purecanonicalizeIpProtocols(baseline, aws, resourceType)pass runs on BOTH comparison sides indrift.ts, immediately after the #1515 principal pass. This is the issue's option 2 (readback normalization) rather than option 1 (a send-side table innarrowIngressIpProtocol): the divergence is AWS's spelling of a value it owns, not a question of what cdkd sent, so normalizing at the comparison also covers theproperties-fallback baseline (a resource deployed before observed-capture, whose baseline is the user's raw template) and the inline-rule shape, neither of which a send-side fix reaches. The mapping table was MEASURED, not guessed — the issue explicitly warned against assuming the IANA list: probing one rule per protocol on a scratch security group (us-east-1, 2026-08-12, ingress AND egress) showed AWS renames exactly FOUR values (1->icmp,6->tcp,17->udp,58->icmpv6) while-1,0,2,4,27,41,47,50,51,88,89,94,103,112,132and255all read back as the number. So the rename set is the closed set of protocols EC2 has a NAME for, and-1being outside it is exactly why #1633's live test came back clean. A second probe established that case is AWS's to decide too —TCP/Tcp/tcpauthorized on one port collapsed into ONEtcppermission — so a name is matched case-insensitively and lowered, but ONLY the four known names, which keeps the pass from ever making two genuinely different values compare equal. Numbers canonicalize to their STRING form, since an unquoted YAMLIpProtocol: 47parses to a number while AWS always reads back a string. Scope is a CLOSED path table (the two standalone rule types plus theAWS::EC2::SecurityGroupinline arrays) rather than a walk for any key namedIpProtocol, because a blanket rewrite would turn an unrelated'6'into'tcp'; EVERY number is stringified (not only an integer one, so a malformed6.5keeps its pre-#1633 key andNaN/Infinitydo not collapse onto one JSONnullbucket); a value that is neither string nor number - an unresolved intrinsic above all - passes through untouched, and an unchanged bag is returned by identity. The real-AWS run then found a SECOND, lower layer the issue's premise had wrong. #1643 states thatreadSecurityGroupIngressCurrentState"returnstcp"; it does not — it returnedundefined, andcdkd driftreported the standalone rule as "drift unknown" rather than as drift. A standalone rule's physicalId is<groupId>|<protocol>|<from>|<to>built from the value cdkd SENT, so forIpProtocol: 6the tuple pre-filter (sg-…|6|…vs an AWSIpPermissions[].IpProtocoloftcp) matched nothing, and the full-signaturesgRuleKeycompare would have missed for the same reason. That half is structurally invisible to a comparison-side fix, because no AWS-side bag is ever produced — soec2-provider.ts'ssgProtocolKeynow routes through the samecanonicalizeIpProtocolValue, which heals the standalone lookup AND the inline-rulereconcileSgRulesordering from ONE definition of protocol identity. That mirrors the reason its existing comment already gives for stringifying a legacy numeric-1: normalizing at the KEY heals the deployed population with no migration. Both halves are needed and neither subsumes the other — the provider half makes the AWS-side bag exist, the comparator half stops itstcpfrom reading as drift against a recorded'6'. 39 unit tests, each half mutation-probed (deleting the6->tcprow fails 7 comparator tests including the headline case; revertingsgProtocolKeyfails the provider lookup test). Thedrift-revert-arraysfixture gains BOTH shapes: an inline numeric rule injected at the L1 (the L2addIngressRulecan only emit a name) and a standalone numeric rule on its OWN security group — deliberately not on the shared one, since a standalone rule materializes a member into the parent's liveIpPermissionswhile the parent's template still declares fewer, which is real drift on the parent (the #1498 sibling-materialization class) and would have failed the fixture for an unrelated reason. Known residual on the very path the comparator half exists for: a security group carryingTagsstill phantom-drifts on theproperties-fallback baseline, becausereadSecurityGroupCurrentStatenever reads tags back whileAWS::EC2::SecurityGroup— unlike every sibling standalone type — is not ingetDriftUnknownPaths. Filed as issue #1649; the fixture's step 6b drops theTagskey with an in-code pointer so it fences the protocol comparison only. The issue's third item is also settled here rather than deferred, now that PR #1641 has merged and released both files:.claude/rules/providers.md/docs/provider-development.mdwarned that a "lossless" coercion needs no warn arm, which a reader could take as "number -> string is the bar" — the measured6->'6'-> AWStcpcase is exactly that trap, so both now state the real bar ("matches what AWS HOLDS") and name the TYPE-coercion vs SERVICE-value-mapping split that decides whether the fix belongs on the send or the readback side. - ✅ Pre-flight rejection of mutually exclusive properties (issue #1634, the remainder of #1591 / #1566) —
src/provisioning/mutually-exclusive-properties.ts(new),src/provisioning/provider-registry.ts,tests/unit/provisioning/{mutually-exclusive-properties,provider-registry-mutually-exclusive}.test.ts,docs/troubleshooting.md,.claude/rules/providers.md. Before:EC2Provider.createRouterefused a template declaring more than one ofDestinationCidrBlock/DestinationIpv6CidrBlock/DestinationPrefixListId(#1566), but only on a template-borne CREATE. For a stack that ALREADY had the route, #1591's both-sides canonicalization made the deploy diff classifyNO_CHANGE, so the provider was never called and the refusal was unreachable — an invalid template could sit in a repo indefinitely, deploying green while AWS held only the first destination. Now a general, reusable pre-flight rule (MUTUALLY_EXCLUSIVE_PROPERTIES+findMutuallyExclusiveViolations) runs insideProviderRegistry.validateResourcePropertieson every deploy, aggregating every offending resource into ONE error ahead of the silent-drop routing lines. The message names the key that WOULD reach AWS, which turns the remedy into a provably safe edit: the service was never sent the others, so deleting them cannot change the deployed resource. The unresolved-intrinsic carve-out is what makes it safe — pre-flight runs BEFORE intrinsic resolution, so a key whose value is{Fn::If: [...]}/{Ref: ...}counts as UNKNOWN rather than declared (anFn::Ifarm resolving toAWS::NoValueis the canonical way to declare both keys conditionally, and that template is VALID); only two or more UNCONDITIONALLY present keys are refused, and the truthiness predicate mirrorsnarrowRouteDestinations'Boolean(...)narrowing so pre-flight cannot refuse a bag the provider itself would accept. There is deliberately no--allow-*escape hatch (unlike--allow-unsupported-types/--allow-unsupported-properties, which exist for gaps in cdkd — this defect is in the TEMPLATE, and CloudFormation rejects it too), so the table is seeded with only the ONE combination this repo has verified end to end, with a unit test pinning every rule property against the type's CFn schema fixture so a rename cannot leave a silently dead rule.cdkd diffis deliberately NOT wired: #1591's canonicalization warning inDiffCalculator.comparePropertiesalready fires for exactly this shape, so a second surface would be duplicate noise. Two bounds worth knowing: the message DROPS its "only X would reach AWS" sentence when a HIGHER-precedence key sits behind an unresolved intrinsic (that key may resolve to a real value and win the provider's||chain, so naming a winner would be a confident falsehood — the remedy is unaffected); and a NESTED-STACK child is validated by its ownDeployEngineinsideNestedStackProvider, so an invalid child resource is refused mid-deploy after the parent has created resources rather than at the parent's pre-flight — the same shapevalidateResourceTypeshas always had, not a regression introduced here. Live-verified viacdkd deploy --dry-runon a scratch CDK app — the two-literal route is refused with the full message, and theFn::Ifpair plans 4 creates without complaint. 27 unit tests, both behaviors mutation-probed (removing the carve-out fails 5; removing the wiring fails 3). - ✅ EC2:
SecurityGroupIngress.IpProtocolno longer produces permanent phantom drift (issue #1633) —src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/ec2-sg-ingress-ip-protocol-effective.test.ts. Before: the provider sent anIpProtocolthat could differ from the one the template declared, and cdkd recorded the DECLARED value — the identical shape #1591 fixed one method over. Two arms produced it.createSecurityGroupIngresswarn-substitutes the-1default for a MALFORMEDIpProtocol(''/{}/true/ an explicitnull) on the replay-reachable paths and sends the default; and an unquoted YAMLIpProtocol: -1is a NUMBER, accepted by design since #1513 and STRINGIFIED before it is sent. Either wayreadSecurityGroupIngressCurrentStatereturns what AWS holds ('-1') and could never match the record, so everycdkd driftreported the difference anddrift --revert"repaired" it by callingupdate()again — which revokes, re-authorizes and re-emits the same warning, forever. Now both halves of the #1591 remedy are in place. A new sharednarrowIngressIpProtocolhelper resolves the protocol ONCE for the create arm, the update arm's re-create and the diff-side canonicalizer, so state and template can never be normalized to different values;create()returnseffectiveProperties(from the success arm AND the idempotent "already exists" arm, which also writes state) andupdateSecurityGroupIngressforwards it, so state records what was SENT; andcanonicalizeDesiredPropertiesgains anAWS::EC2::SecurityGroupIngressarm so the template side is narrowed identically. The second half is not optional:IpProtocolis create-only on this type in the registry schema, so normalizing state alone would classify the template's original value as a changed IMMUTABLE property, and the resulting replacement create — which receives no context and so gets noonUnusabledowngrade — would turn a previously-green no-op deploy into a hard failure. Normalizing both sides is also what keeps records written BEFORE the fix (which still carry the raw value) comparing clean. Unchanged: a TEMPLATE-path create still REFUSES a malformed value outright, and the warn arm still warns — recording the substitution does not replace announcing it. The numeric arm does not warn, and does not need to:-1and'-1'name the same protocol, so nothing is lost, and recording the string additionally fixes the delete path, which forwards the state record tobuildIpPermissionand would otherwise hand the EC2 API a number. An ABSENTIpProtocoldeliberately stays absent rather than gaining the default — the drift comparator only descends into keys present in state, so materializing it would START comparing a key the template never declared. 20 unit tests, each half independently mutation-probed. - ✅ The replacement-collision refusals stop calling a cdkd-GENERATED physical name "user-supplied" (issue #1636, scope item 1) —
src/provisioning/resource-name.ts,src/deployment/deploy-engine.ts,tests/unit/deployment/deploy-engine-collision-name-origin.test.ts. Before: every create-first replacement collision reported that "the resource has a user-supplied physical name" and prescribed "rename the resource in your CDK code". For a resource the template never named BOTH halves are false, and the false half is the part the user is asked to act on:generateResourceNameproduces{stackName}-{logicalId}with no random component, so the create-first attempt lands on the name the old resource still holds exactly as it would for a user-named one — the collision is cdkd's own naming scheme, not the template — and "rename" there means renaming the CONSTRUCT (changing the logical id), a materially more disruptive action. A reader who finds no such name in their template cannot connect the message to their code at all. Observed live (us-east-1, 2026-08-12) on thelambda-durable-replacementfixture, whose function declares nofunctionNameand whose message namedCdkdLambdaDurableReplacementExample-DurableFn91E3F4D8. Now a new best-effortlooksLikeCdkdGeneratedName(physicalId, logicalId, stackName)runs the naming scheme backwards, and the engine's sharedreplacementNameOriginhelper emits a per-origin descriptor + remedy: a generated name is reported as generated, with "give the resource an explicit physical name, or rename the CONSTRUCT (note that this replaces the resource)" as the remedy; a template-supplied name keeps the pre-#1636 wording verbatim. Applied at all FOUR sites that carried the false assertion, not just the two the issue named — theNAMED_REPLACEMENT_COLLISIONthrow, theUpdateReplacePolicy: Retainrefusal beside it, the--replaceinfo line (now "collided with the existing resource's name" rather than "the custom-named resource"), and theNAMED_REPLACEMENT_IDEMPOTENT_CREATEarm — since fixing only the reported pair would leave siblings telling the same untruth. Classification is deliberately APPROXIMATE and biased to the safe answer: the per-typemaxLength/lowercase/allowedPatternoptions live in the providers, so the test compares the alphanumeric skeleton and accepts the truncated…-<8 hex>form (requiring BOTH the prefix and the hash, so a hash-suffixed user name is not claimed), and EVERY unresolved case — including no activewithStackNamescope — answersfalse, which merely keeps the old wording. Unchanged: the CloudFormation-parity framing, the--replaceescape hatch, and every control-flow decision — this PR changes message text only. Item 2 of the issue (should a generated name get a FRESH unique name on replacement, as CloudFormation does, so--replaceis not needed at all?) is deliberately NOT in scope: the deterministic name is relied on by state readback, orphan scans and fixture assertions, so it needs its own analysis. 13 unit tests; the classifier is mutation-probed in BOTH directions (always-false = pre-#1636 behavior fails 6 rows; always-true fails the 4 template-supplied rows). - ✅ DynamoDB: the GSI Create / Update arms are idempotent, so a post-GSI failure no longer wedges every later deploy (issue #1630) —
src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-gsi-idempotent.test.ts,.claude/rules/providers.md. Before:applyGsiUpdatesissued oneUpdateTableper GSI op, and everything after it inupdate()— PITR, TTL, ResourcePolicy, Kinesis streaming, Contributor Insights — could still throw. cdkd writes state only onceupdate()RETURNS, so any op that already succeeded was left unrecorded and the next deploy re-emitted it: a created index'sCreatewas re-sent and AWS rejected it (the index now exists), and a landed per-index throughputUpdatewas re-sent with the same value and AWS rejected it with "The provisioned throughput for the index X will not change. The requested value equals the current value." State never advanced, so every retry failed identically until the user rancdkd drift --accept— the same permanently-wedged class #1617 fixed for the Delete arm, on its two siblings. Now both arms consult the liveDescribeTablesnapshotupdate()already holds: aCreateis skipped (with a WARN, since state and AWS disagreeing is worth surfacing) for an index name AWS already has, and a throughputUpdateis skipped when the live index already carries the requested pair. The two arms are gated DIFFERENTLY and deliberately — an index's EXISTENCE is billing-mode-independent so the Create skip applies on either mode, while the capacity VALUES are meaningless under PAY_PER_REQUEST (DescribeTablereportsProvisionedThroughput: {0, 0}for every index of an on-demand table, the #1571 trap) so the Update skip is disabled there entirely, as it also is while THIS deploy is flipping to PROVISIONED (the flip itself delivers the capacity, whichgsiHandledByBillingFlipalready suppresses by a different route). The capacity comparison is member-by-member as numbers rather than structural, because aDescribeTablereadback carriesNumberOfDecreasesToday/LastIncreaseDateTimealongside the two capacities and adeepEqualagainst the two-member desired object could never match — the suppression would have been dead code that still looked safe. Every unresolvable shape (absent live entry, malformed value, unresolved intrinsic) fails OPEN and still issues the call: the worst case is the pre-fix behavior, whereas a false match would silently drop a capacity change the user asked for. 9 unit tests; the Create skip, the Update skip, the PAY_PER_REQUEST gate and the member-wise comparator are each independently mutation-probed.
Recently Implemented (2026-08-11):
- ✅ Drift: unordered comparison for OBJECT arrays, and
ElasticLoadBalancingV2::TargetGroup.Targetsbecomes visible tocdkd drift(issue #1620, split out of #1609 item 6) —src/analyzer/drift-normalize.ts,src/provisioning/providers/elbv2-provider.ts,tests/unit/analyzer/drift-normalize.test.ts,tests/unit/provisioning/{elbv2-provider-readcurrentstate,elbv2-lb-targetgroup-props}.test.ts,tests/integration/drift-revert-arrays/{lib,inject-drift.ts,verify.sh,README.md}. Before: the provider-declared opt-in passcanonicalizeUnorderedArraysAtPaths(viaResourceProvider.getDriftUnorderedPaths) handled PLAIN-STRING arrays only, so a type whose unordered list is an array of OBJECTS had no way to declare it.TargetGroup.Targetswas therefore parked ingetDriftUnknownPaths— meaningcdkd driftcould not see a console-side target registration AT ALL — even though the provider can read it back viaDescribeTargetHealth. Now the pass sorts object arrays too, at a declared path only, keyed on a private key-order-independentcanonicalJson: key order deliberately does not participate, because AWS's readback order for an object's own keys is no more guaranteed than its order for the list, so a rawJSON.stringifysort key would reintroduce the very phantom drift the pass removes. Each shape is sorted only when the array is HOMOGENEOUS in it — a mixed strings-and-objects list, anullelement, and a nested array inside a declared path are all left untouched, so a mis-declared path can never reorder a heterogeneous list. Array-of-arrays stays out by an explicit decision, not an inherited accident (no CFn shape in tree is one, and an inner list's order-significance is a separate question the single declared path cannot express).ELBv2Providerthen movesTargetsout of the TYPE-levelgetDriftUnknownPathsintogetDriftUnorderedPathsand reads it back in CFn shape. The unknown-path entry did not disappear, it became PER-RESOURCE (the issue #1602 seam): a target group fronting an ECS service or an ASG declares NOTargets— the SIBLING resource registers them and re-registers on every scale event — so comparing an undeclared list would report drift on an untouched stack and--revertwould deregister the tasks the service just placed (the #1498 class, whichundeclaredEmptyObservedKeysonly covers when the capture happened to be EMPTY — for a redeployed running service it is not).Targetsis therefore compared only when the template declares it; an explicitTargets: []IS a declaration and stays compared, and an absent properties bag falls back to comparing per the method's contract. The readback needed a second fix ordering alone does not cover: deregistration is asynchronous, soDescribeTargetHealthkeeps reporting a just-removed target asdrainingfor minutes — including one would freeze it into the deploy-timeobservedPropertiessnapshot and produce PERMANENT phantom drift once it finally disappeared. The provider excludes exactlydrainingand INCLUDES every other state (initial/unused/unhealthy/unavailableare all registered; health is not registration), drops theAvailabilityZone: 'all'AWS substitutes for an unscopediptarget, and leaves the key ABSENT (never empty) when the health read fails, so a missingelasticloadbalancing:DescribeTargetHealthpermission reports nothing rather than reporting every target as removed. Integ:drift-revert-arraysgains a standalonetargetType: 'ip'TargetGroup (no load balancer — free, and itsunusedtargets exercise the include-non-draining decision live) with three deliberately unsorted IP targets, a SECOND back-to-backcdkd driftassertion (two consecutive runs disagreeing is this class's signature — one run cannot detect it), and an out-of-bandRegisterTargetsof a fourth untemplated IP asserted live BEFORE the revert (so the post-revert assertion cannot pass vacuously) and asserted deregistered after, with the other three RETAINED. - ✅ S3: the remaining FIELD-level reads no longer hard-throw on a state replay (issue #1605) —
src/provisioning/providers/s3-bucket-provider.ts,tests/unit/provisioning/s3-bucket-field-read-replay.test.ts(new),tests/unit/provisioning/s3-bucket-provider-roundtrip.test.ts. Before:applyVersioning'sStatus,applyLoggingConfiguration's destination +TargetPrefix, andapplyInventoryConfigurations'ScheduleFrequency/Schedule.Frequencypair werereadConfigStringrefusals carrying noonUnusable, so they THREW on the replay-reachable paths —rollback-executor.ts's revert arm andcdkd drift --revert(which callupdate(..., previousState.properties, ...)) and its reverse-replacement arm (which callscreate(..., previousState.properties, REPLAYING_STATE_CREATE_CONTEXT)). The desired bag there is a cdkd STATE record, so a refusal left the resource un-rollbackable with a hand-edit ofstate.jsonas the only remedy. After: each downgrades, and the three answers DIFFER because the skip UNIT was the whole question — which is why #1595 split them out rather than repeating its per-item pattern. Versioning SKIPS the Put: there is no item to skip and no previous value to keep, and the alternative is theSuspendedfallback that #1471 measured turning versioning OFF on a live bucket; the skip's real cost (a rollback of a versioning change is not reverted) is the cheaper one and the warning says so. Logging SKIPS the Put with both reads probed together, sincePutBucketLoggingreplaces the whole status and the destination's blank fallback makes the clearing branch turn access logging OFF — the create-path GATE takes the same downgrade, because it reads the same field to decide whether to call at all and would otherwise throw before the applier's skip could run. Inventory takes a THIRD shape: a malformedScheduleFrequencyFALLS THROUGH toSchedule.Frequency, and the item is SKIPPED when there is nothing to fall back to — inventing aWeeklycadence for a live report is exactly the substitution this guard class refuses. "Nothing to fall back to" includes an ABSENT second source, and that is the case that matters (PR review blocker):ScheduleFrequencyis the only spelling the CFn schema declares —tests/fixtures/cfn-schemas/AWS-S3-Bucket.jsonhas noSchedulemember — so a record with a brokenScheduleFrequencyalmost never carries one, and treating the absent container as usable let the fall-through land onreadConfigString's ownWeeklydefault, making the whole downgrade inert for real input. A template-path create still REFUSES at every site, and the create/update asymmetry is now deliberate rather than an oversight (the roundtrip suite's "CREATE and UPDATE agree" test is renamed and rewritten to say so). Also threadsCreateContextintoapplyConfiguration, whose versioning read was reachable from the reverse-replacement create with no way to downgrade — the siblingapplyAllSubConfigsForCreatehad taken the context since #1463 for exactly this reason. Tests: 19 new cases across the three sites (replay create, update, template-create refusal, well-formed no-op, and the fall-through / skip split), plus the enable arm's!== 'Suspended'test pinned so an unrecognized status still reaches AWS instead of being silently swallowed; all verified to FAIL with the guards neutered. - ✅ DynamoDB: removing a GSI in the same deploy as a BillingMode flip now converges (issue #1617) —
src/provisioning/providers/dynamodb-table-provider.ts,tests/unit/provisioning/dynamodb-table-provider-billing-flip-gsi.test.ts,tests/integration/dynamodb-ondemand/{lib/dynamodb-ondemand-stack.ts,verify.sh}. Before: a deploy that dropped aGlobalSecondaryIndexes[]entry AND flippedAWS::DynamoDB::TablefromPAY_PER_REQUESTtoPROVISIONEDcould not succeed by any template-side edit. The dropped index is still LIVE when the flip runs, so AWS demands per-indexProvisionedThroughputfor it (ValidationException: ProvisionedThroughput must be specified for index: <name>, measured 2026-08-11) while the template no longer declares any — and cdkd'sDeleteop lived inapplyGsiUpdates, which runs AFTER the flip and was therefore never reached. Every deploy failed identically; #1588 (which made the flip possible at all for an indexed table) deliberately deferred the reordering and cdkd warned with a two-deploy workaround. After: the removal is applied FIRST — oneUpdateTableper dropped index with a table-and-indexes ACTIVE wait between each (AWS's one-GSI-op-per-call budget, and the wait is what returns the table to a state that accepts the flip), then the flip, whose per-index capacity list is built from the live indexes MINUS the ones just deleted. This is what CloudFormation does on this shape. Only the removal moves: creates and capacity updates stay after the flip, where the index they describe exists, and the reorder is scoped to the flip TOPROVISIONED(the other direction needs no per-index capacity). Guards, all four found or confirmed by the 3-axis PR review. The pre-flip delete is REFUSED whenever the flip is already doomed for a reason visible here — an index that will STILL be live declaring no usable per-index capacity, OR an absent / unusable TABLE-levelProvisionedThroughput(the flip block deliberately leaves that one to AWS for CFn parity, which is fine for the flip but is exactly the deterministic failure a delete must not run ahead of). Only an index cdkd's own PREVIOUS record knows about is removable, so a live index created out of band is named in the warning rather than silently deleted. A present-but-NON-ARRAY desiredGlobalSecondaryIndexes(an unresolved intrinsic) reads as an empty desired set, which would have classified every live index as removed and deleted them all — that shape was non-destructive before, so the removal is refused and the pre-existing loud downstream failure is preserved. And theDeletearm is now IDEMPOTENT against AWS's live index list, which is what actually makes the residual recoverable: the first version claimed "the next deploy retries the flip and the GSI diff is already satisfied", and the review showed that was WRONG — cdkd writes state only afterupdate()RETURNS, so a mid-update failure leaves the deleted index still in the previous side and the next deploy would emit aDeletefor an index AWS no longer has, failing withResourceNotFoundExceptionon every deploy forever and re-creating the very unconvergeable class this change removes. Skipping aDeletefor a name that is not live closes that, and subsumes the narrower already-deleted set it replaced (it also covers an index removed out of band or by an earlier interrupted run). Tests: 16 unit cases across the two GSI suites (order, per-index-list exclusion, no duplicate delete, one call per dropped index, the idempotent skip for an index AWS no longer has, all four refusal arms, a CREATE-after-flip case proving only the removal moves, and both scope negatives), each verified to FAIL without the fix; two pre-existing tests whose mocks returned aDescribeTablewith no GSI list while removing a GSI — a response real DynamoDB never sends — were corrected to list the index live; thedynamodb-ondemandfixture'sBillingRemovalTablegrew a second baseline GSI that the flip deploy removes, asserted present before the flip and gone after. - ✅ AppSync
DataSource/Resolveropted into the nested-key critic, and the two nested silent drops it found are fixed (issue #1597) —src/provisioning/providers/appsync-provider.ts,scripts/gen-nested-key-coverage.ts(two new targets + the sharedAPPSYNC_WRITE_FLOORS),tests/fixtures/cfn-schemas/AWS-AppSync-{DataSource,Resolver,ApiKey,GraphQLSchema}.json(re-captured), newtests/unit/provisioning/appsync-datasource-nested-configs.test.ts,tests/integration/appsync/{lib,verify.sh,README.md}, coverage matrices regenerated. Before: the #609 AppSync backfill closed both types' TOP-LEVEL silent-drop maps but could not opt them intoNESTED_KEY_TARGETS— their schema fixtures pre-dated thedefinitionShapes/nestedPropertyPathscaptures the generator hard-errors without — so nothing audited one level down, where a handled top-level property hides its own dropped members. Now both are targets (Resolvermeasured CLEAN on the first run at 9 audited paths;DataSourcemeasured 10 findings at 27 paths, all fixed here), and the whole tree is back to 0no-write-evidence. The two families the opt-in exposed:HttpConfig.AuthorizationConfig(+AuthorizationType/AwsIamConfig.SigningRegion/.SigningServiceName), which the issue named and which is security-relevant rather than cosmetic — an IAM-signed HTTP data source reached AWS UNSIGNED, so every request to the endpoint went out without SigV4; andDynamoDBConfig.DeltaSyncConfig(+BaseTableTTL/DeltaSyncTableName/DeltaSyncTableTTL) plusDynamoDBConfig.Versioned, which the issue did NOT name — the critic found them, which is the point of opting in. Both are wired on create AND update (one sharedapplyDataSourceConfigmapper) with matchingreadCurrentStatereverse maps, since a forwarded-but-unreadable member becomes permanent phantom drift. The delta-sync TTLs are the one CFn->SDK TYPE divergence in the batch: CFn (and CDK's L1) declare both as STRINGS while the SDK models them as longs, so the write side converts and refuses a non-numeric value loudly rather than sendingNaN, and the read side converts BACK to strings — emitting AWS's number would have differed from the template baseline under the stringify-based drift comparator on every run. The three AppSync targets now share oneAPPSYNC_WRITE_FLOORSconstant because both collector outputs are per-FILE, not per-type;minWrittenMembersmoved 100 -> 105 in the process, since the file grew past the point where the old value still sat inside the hygiene band. Integ: theappsyncfixture gains an IAM-signed HTTP data source and a versioned DynamoDB data source (with a second table as the delta-sync store) and asserts both nested blobs across all three phases — created, changed on update (SigningRegionand both TTLs), and cleared on removal with a retained sibling INSIDE each block (HttpConfig.Endpoint,DynamoDBConfig.Versioned) so a reset that wiped the whole block cannot pass. - ✅ A Lambda replacement is performable again: the name-collision matcher missed AWS's SINGULAR spelling (issue #1625) —
src/deployment/retryable-errors.ts,tests/unit/deployment/retryable-errors.test.ts, newtests/integration/lambda-durable-replacement/. Before:isNameCollisionErrormatched/already exists/i, andCreateFunctionraisesResourceConflictException: Function already exist: <name>— SINGULAR — so NOAWS::Lambda::Functioncould take the collision path. The consequence was not cosmetic: a property-driven replacement (droppingDurableConfig, changing the create-onlyTenancyConfig) create-firsts into its own still-live name, and the unmatched collision meant the raw SDK exception escaped instead of cdkd's actionableNAMED_REPLACEMENT_COLLISIONerror ANDcdkd deploy --replace's delete-first fallback never fired — the replacement was unperformable by any flag. Verified against real AWS by creating one function name twice (us-east-1, 2026-08-12). Now the matcher acceptsalready exist/already existsas one word-bounded signature (a participle such as "already existed as a draft" is still refused, since crediting it at a create-first site would trigger the destructive delete-first fallback), so the refusal is actionable and--replaceworks. New fixturelambda-durable-replacementis the live coverage issue #1625 asked for, and its first run corrected the issue's own premise twice: an UNNAMED Lambda collides exactly like a pinned one (cdkd generates{stackName}-{logicalId}deterministically, so the create-first attempt lands on the name the old function still holds) and the physical id is IDENTICAL on both sides — so "assert the physical id changed" cannot prove the replacement. The fixture proves it from the END STATES instead: phase 2 dropsDurableConfigWITHOUT--replaceand asserts the deploy refuses actionably while the live function keeps its old properties, phase 3 re-runs WITH--replaceand asserts the delete-first fallback left a function with NO durable config and the new description, phase 4 destroys clean. The follow-up the run surfaced — both collision messages call a cdkd-GENERATED name "user-supplied" — is filed as issue #1636. - ✅
AWS::ECS::Service: the last 9 silent-drop properties wired, type CLOSED (issue #609 ECS Service batch) —src/provisioning/providers/ecs-provider.ts,scripts/gen-nested-key-coverage.ts(2 allow-list entries + re-measured floors), newtests/unit/provisioning/ecs-service-config-props.test.ts,tests/integration/ecs-service-update-props/{lib,verify.sh,README.md},tests/integration/ecs-fargate/{lib,verify.sh,README.md},tests/fixtures/cfn-schemas/_todo-backfill.json(ECS Service block + stalePlacementStrategybogus-tolerance removed), coverage matrices regenerated. Before:AvailabilityZoneRebalancing/DeploymentController/ForceNewDeployment/Monitoring/Role/ServiceConnectConfiguration/VolumeConfigurations/VpcLatticeConfigurationswere silent-drops, so any Service template carrying one flipped to the #614 Cloud Control fallback route (PlacementStrategieswas already wired since #613 — its backfill entry was simply stale). Now all are wired throughCreateService/UpdateServiceandPROPERTY_COVERAGE_BY_TYPEreports an EMPTYsilentDropfor the type, so every ECS Service is SDK-routed — theecs-fargatefixture (ServiceConnect + managed EBS volume) is the live route-flip proof (provisionedBy=sdkpinned + deployment-level read-backs). The config blobs go through the shared recursive PascalCase->camelCase converter as whole-blob hand-offs (ManagedEBSVolume->managedEBSVolumeandSizeInGiB->sizeInGiBare exact first-char-only flips, pinned by unit tests;ServiceConnectConfiguration.LogConfiguration.Optionsis a free-form log-driver map copied verbatim).ForceNewDeploymentis the one CFn-only shape: an object{EnableForceNewDeployment, ForceNewDeploymentNonce}with NO per-member SDK counterpart —resolveForceNewDeploymenttranslates Enable=true OR a nonce change into UpdateService's plainforceNewDeployment: true(create-path no-op; two nested-key allow-list entries document the collapse).Rolemoved OUT ofunhandledByDesign(it is a real create-only registry property): passed through on create, and a change classifies as REPLACEMENT via the create-only schema fallback (no hand rule needed; pinned against the fixture'screateOnlyPropertiesthrough the real DiffCalculator). Update semantics: every deployment-triggering blob is change-gated (an unchanged value is never re-sent, so a no-op update cannot start a spurious rollout); removals — ServiceConnect resets to the documented disable shape{enabled: false}, VolumeConfigurations / VpcLatticeConfigurations reset to[](both UNVERIFIED-LIVE, flagged in code), AvailabilityZoneRebalancing and Monitoring are DEFERRED with in-code rationale (the AZ-rebalancing defaults genuinely diverge: CreateService defaults ENABLED while a never-set service reads as DISABLED, so no single reset value is defensible without a live probe — the DeploymentConfiguration deferral precedent), DeploymentController sends nothing on removal by design (absent = default ECS controller). Read side:AvailabilityZoneRebalancingis surfaced verbatim andDeploymentControlleralways (with a{Type: ECS}fallback because DescribeServices documents omitting the field for the default controller); the unreadable members (ServiceConnectConfiguration/VolumeConfigurations/VpcLatticeConfigurations/Monitoring/ForceNewDeployment/Role) are declared via the provider's newgetDriftUnknownPathsso they cannot become permanent phantom drift —Roledeliberately so, since AWS reports the service-linkedAWSServiceRoleForECSARN for templates that never set it. Integ:ecs-service-update-propslive-covers AvailabilityZoneRebalancing (describe-services + observedProperties), the explicitDeploymentController: {Type: ECS}(observedProperties fallback), Monitoring CreateService ACCEPTANCE (no read-back API exists; a mis-flipped required member would fail the deploy), a drift run asserting no phantom drift on any #609 member, and a newforce-noncephase where ONLY the nonce changes anddeployments[0].idmust change. PlacementStrategies (EC2 launch type), VpcLatticeConfigurations,CODE_DEPLOY,Role(classic ELB) and the Monitoring read-back stay unit-only with the rationale recorded in the fixture README. - ✅ ELBv2: a removed BOOLEAN LoadBalancer / Listener attribute no longer fails the whole deploy (issue #1609 item 1) —
src/provisioning/providers/elbv2-provider.ts,tests/unit/provisioning/elbv2-lb-targetgroup-props.test.ts,tests/integration/alb/{lib/alb-stack.ts,verify.sh}. Before: theLoadBalancerAttributesandListenerAttributesdiff arms pushed EVERY removed key back asValue: '', and neither removal had ever been exercised by an integ — the #609 batch shipped them on the assumption that both APIs accept the empty string as "clear the override", an assumption its own mocked unit tests could only agree with. The live A/B settled it per VALUE KIND, and the assumption was half wrong:ModifyLoadBalancerAttributesaccepts''for the numericidle_timeout.timeout_seconds(and genuinely resets it to 60), but REJECTS it for the booleandeletion_protection.enabled— "The value of 'deletion_protection.enabled' must be 'true' or 'false', but was ''" — andModifyListenerAttributesrejects it identically forrouting.http.response.server.enabled. Because a rejection fails the ENTIRE Modify* call, one removed boolean took the whole deploy down, and then took its automatic rollback down with it (the rollback re-runs the same diff with the sides SWAPPED, so a key the two template sides did not share became a removal in the OTHER direction and hit the same refusal on a SECOND key the forward pass never touched — a user could not deploy their way out of it). Now each arm resolves a removed key through a documented-defaults table (LOAD_BALANCER_ATTRIBUTE_DEFAULTS/LISTENER_ATTRIBUTE_DEFAULTS) and falls back to''for anything not in it. That fallback is the deliberate DIVERGENCE from theTARGET_GROUP_ATTRIBUTE_DEFAULTSsibling, whose unknown-key arm warns and retains: ModifyTargetGroupAttributes rejects''for every key, so there the default is the only possible reset, whereas here''is valid for the majority of keys and falling back to it preserves working behavior instead of silently retaining a value the template asked to drop. Keys whose default depends on the LOAD BALANCER are deliberately absent, since cdkd knows neither the type nor the scheme at diff time:load_balancing.cross_zone.enabled(always-on and unconfigurable on an ALB, default false on an NLB / GWLB) andipv6.deny_all_igw_traffic("false for internet-facing load balancers and true for internal load balancers") — the second was caught by review AFTER being wrongly listed, and it is the sharper lesson: a valid-but-wrong boolean is ACCEPTED by AWS, so the entry would have silently un-blocked internet-gateway access on every internal load balancer instead of failing loudly.dns_record.client_routing_policyis out for a different reason (the model documents no default at all). And a key already AT its default is SKIPPED entirely, which is a safety property rather than an optimization:cdkd drift --revertpasses the FULLreadCurrentStatesnapshot as the previous side, so against a state record with noobservedPropertiesevery untemplated attribute looks removed — writing defaults there would silently reset deletion protection, access / connection logs, HTTP/2, WAF fail-open and zonal shift on a live load balancer. Before the table existed that call was accidentally safe (''fails validation, so the revert aborted and changed nothing), so shipping the table without the skip would have converted a loud refusal into a silent destructive write. Integ: thealbfixture now templates aLoadBalancerAttributesentry (idle_timeout120 -> 180 -> dropped) and drops the Listener'sListenerAttributesin the removal phase, asserting both reset to AWS's documented defaults (60 /true).deletion_protection.enabledis restated in EVERY phase and is doing two jobs: it is the retained sibling the removal-testing convention requires for a collection-valued property, and it pins the CDK-L2 trap the fixture's first run walked into — the L2ApplicationLoadBalanceremits its owndeletion_protection.enabledentry, so a removal phase that merely DROPPED the property override let that default reappear, making the phase a value CHANGE plus an unintended second removal rather than the clean single-key removal it read as (thefeedback_cdk_l2_emits_explicit_defaults_in_removal_fixturesshape; synthesizing both phases and diffing the rendered attribute lists is what catches it). Also in this batch: the TargetGroupTargetsupdate test now pins the register-BEFORE-deregister call ORDER rather than only the payloads (the order is the behavior — a target whose Port changed is one backend under two spellings, so deregistering first would open a window with zero registrations), andreadCurrentState's two best-effort reads added by the #609 batch gained coverage for BOTH catch arms, which encode different decisions: a NotFound means the resource is gone and the whole read must returnundefined, while a permission error must leave only that key absent and the rest of the snapshot intact, since emitting a wrong value there would fire false drift on every run. Items 2 and 6 of #1609 are split out to their own issues — the NLB flag OMISSION semantics on a combined removal-plus-change deploy needs a dualstack / PrivateLink fixture that does not exist yet, and the unordered object-array drift comparison that would letTargetGroup.TargetsleavegetDriftUnknownPathsis a drift-layer feature well past ELBv2's blast radius. - ✅
AWS::AppSync::Resolver+AWS::AppSync::DataSource: the last 12 silent-drop properties wired, both types CLOSED, plus theGraphQLSchema.DefinitionS3Locationno-op sibling (issue #609 appsync batch 2) —src/provisioning/providers/appsync-provider.ts, newtests/unit/provisioning/appsync-resolver-datasource-props.test.ts,tests/unit/provisioning/appsync-provider-readcurrentstate.test.ts,tests/integration/appsync/{lib/appsync-stack.ts,lib/templates/*.vtl,verify.sh,README.md},tests/fixtures/cfn-schemas/_todo-backfill.json(Resolver + DataSource + the stale GraphQLApi block PR #1569 forgot), coverage matrices regenerated. Before: Resolver templates usingCachingConfig/CodeS3Location/MaxBatchSize/MetricsConfig/RequestMappingTemplateS3Location/ResponseMappingTemplateS3Location/SyncConfig, and DataSource templates usingElasticsearchConfig/EventBridgeConfig/MetricsConfig/OpenSearchServiceConfig/RelationalDatabaseConfig, were rejected by the #608 pre-flight — and with--allow-unsupported-properties, silently dropped (ElasticsearchConfigsat inunhandledByDesignas a "deprecated alias", but it is a live SDK member the AMAZON_ELASTICSEARCH data-source type still requires, so it is now wired rather than excused). Now every member rides shared create/update mappers (applyResolverConfig/applyDataSourceConfig, theapplyGraphQLApiConfigpattern, so create and update cannot diverge), hand-mapped member-by-member with the casing traps pinned by tests (dbClusterIdentifierfor CFn'sDbClusterIdentifier,awsSecretStoreArn,openSearchServiceConfig,lambdaConflictHandlerConfig.lambdaConflictHandlerArn;MetricsConfigis a scalar enum on both types, not a block), with matchingreadCurrentStatereverse maps gated on the response's own Type/Kind discriminator so nothing becomes permanent phantom drift. The three*S3Locationproperties have NO SDK member at all — CloudFormation fetches the S3 object and passes its BODY as the inline member, and cdkd now mirrors that: ans3://bucket/keyURL (the form CDK's asset bindings emit; anything else is refused with a clear error naming the property, never coerced) is fetched viaGetObjectand passed ascode/requestMappingTemplate/responseMappingTemplate, the same helper also replacing theGraphQLSchema.DefinitionS3Locationwarn-and-drop no-op; inline wins over its S3 sibling with a loud warning (the StepFunctionsDefinitionS3Locationprecedent), an empty S3 body is refused, and all four properties are declared drift-unknown (getDriftUnknownPaths) because AWS returns only the resolved body, never the URL. On update, a changed URL re-fetches; an unchanged URL is a no-op even if the object behind it changed (CFn parity — CDK assets bake the content hash into the key). Removal semantics differ from the GraphQLApi batch by design:UpdateResolver/UpdateDataSourceare FULL-REPLACE writes, so "absent in template ⇒ omitted from the update input ⇒ cleared by AppSync" and no reset sentinels are needed — pinned in unit tests (the removal FIRES the call and the input OMITS the member) and live by the integ's REMOVAL phase, which drops the resolver'sMetricsConfig, asserts AWS cleared it toDISABLED, and keeps the EventBridge data source's ownMetricsConfigas the retained sibling so a blanket wipe cannot pass. Integ: theappsyncfixture gains anAMAZON_EVENTBRIDGEdata source (realAWS::Events::EventBus+events:PutEventsrole,EventBridgeConfig+MetricsConfig+ description update), resolver-levelMetricsConfigon the existing resolver (the API'sEnhancedMetricsConfigis alreadyPER_RESOLVER_METRICS), and a second resolver whose mapping templates come from S3 viaaws-s3-assetsAssets — verify.sh asserts the live template equals the local asset file byte-for-byte and that the UPDATE phase's v2 asset (new key) was re-fetched, extends theprovisionedBy == sdk#614 routing guard to every Resolver/DataSource row, and gone-probes the event bus on destroy.CachingConfig(needs a billed ApiCache),SyncConfig(VERSIONED delta-sync),MaxBatchSize(Lambda BATCH_INVOKE),CodeS3Location(same fetch helper the template S3Locations prove live),OpenSearchServiceConfig/ElasticsearchConfig(real OpenSearch domain) andRelationalDatabaseConfig(Aurora Data API) stay unit-only, recorded in the fixture README. Deliberately NOT done, recorded here: noReplacementRulesRegistryentries (unlikeApiType/Visibilitylast batch, the registry schema for both types DOES carrycreateOnlyProperties— ApiId/TypeName/FieldName and ApiId/Name — so the DescribeType fallback classifies them, with the provider's existing immutability refusals as defense-in-depth), and noNESTED_KEY_TARGETSopt-in for the two types: their schema fixtures pre-date the #1378/#1464definitionShapes/nestedPropertyPathscaptures and re-capturing requires livecloudformation:DescribeType, out of this change's no-AWS budget — follow-up in issue #1597: refresh the two fixtures, opt both types in with measured floors, and fix the pre-existingHttpConfig.AuthorizationConfignested silent drop found while scoping. - ✅
AWS::EC2::Route's multi-destination warn arm records the NARROWED bag, ending the permanent phantom drift (issue #1591) —src/types/resource.ts,src/deployment/deploy-engine.ts,src/provisioning/providers/ec2-provider.ts, newtests/unit/provisioning/ec2-route-effective-properties.test.ts+tests/unit/deployment/deploy-engine-effective-properties.test.ts. The residue #1590 (issue #1566) knowingly left behind. Before: the multi-destination refusal downgrades to a WARNING on the state-borne paths — includingupdateRoute, which deletes the route before re-creating it, so a throw would strand it — and the update then SUCCEEDS, so the engine recorded the DESIRED bag carrying every declared destination key.readRouteCurrentStatecan only ever return the ONE key AWS holds, so the losers were PERMANENT phantom drift: reported by everycdkd drift, anddrift --revert"repaired" it by callingupdate()again, which delete-and-recreated the route and re-emitted the same warning, forever. That is the #1552 junk-state class one provider over. Now: a provider may returneffectiveProperties(a new optional field onResourceCreateResult/ResourceUpdateResultvia the sharedEffectivePropertiesResult) carrying the bag it ACTUALLY delivered, and the engine records that in place of the desired one at all three state-write sites — plus the replacement-result literal, which rebuilds the update result and would otherwise drop the field on the floor.createRoute's warn arm strips only the LOSING destination keys (everything else is sent verbatim, so narrowing further would silently disable the #1160 removal derivation for those properties) andupdateRouteforwards it from the re-create. Gated on??, not truthiness: an empty bag is a legitimate answer and only an ABSENT field means "record the desired properties", so no existing provider changes behavior. The create-path refusal is untouched — this is the replay/update answer, not a licence to accept the CFn-invalid shape from a template. Deliberately NOT the mechanism for AWS-side defaults or computed values, which belong inobservedProperties; the rules file records the three conditions under which returning it is right. The recording is only half the fix, and shipping it alone would have been worse than shipping nothing — found by the PR review, confirmed against the registry schema. With state narrowed and the template still declaring both keys, the next diff reads the dropped key as a user-made ADD; everyAWS::EC2::Routedestination key is create-only, so that classifies as a REPLACEMENT, and the engine's replacement create passes no context — socreateRoutehits the #1566 refusal and a previously-green no-op deploy starts FAILING. Without create-only knowledge (noDescribeType) it classifies in-place instead and delete-and-recreates a live route on EVERY deploy. So BOTH comparison sides are narrowed identically: a new optionalResourceProvider.canonicalizeDesiredProperties(resourceType, properties)(pure, synchronous) thatDiffCalculatorapplies to the resolved desired properties via a newcanonicalizeDesiredargument — injected as a function so the analyzer layer keeps no dependency on the provisioning layer. Both sides share ONE helper,narrowRouteDestinations, because re-deriving the rule would let state and template narrow to different keys, which is the original bug wearing a new hat. This is the same "normalize BOTH comparison sides" ruledrift-normalize.tsalready records for ordering. 24 new unit tests across three files; mutating the engine helper to MERGE instead of replace fails 4, gating it on a non-empty object instead of??fails the empty-bag row, and reverting either of the two engine sites the review found UNCOVERED (the property-driven replacement inside UPDATE, and the update-failure replacement fallback literal) now fails one each. Three further review rounds found three more real defects in the fix itself, each now fenced: normalizing only the DESIRED side left the issue's own population (a state record written before the narrowing, carrying every key) reading the loser as a REMOVAL — same create-only replacement, same #1566 refusal, same broken deploy, reached from the other side, so BOTH sides are normalized and the record self-heals on the next write;cdkd diffwas never wired, so the preview forecast a REPLACEMENT the deploy would never perform — the phantom moved one command over rather than being fixed — sodiff.tsnow builds the SAME normalizer through the sharedmakeCanonicalizePropertiesFnand threads it throughbuildDiffTree/computeStackDiffinto every nested child; and the diff test re-implemented the provider's gate in a local closure, so guttingEC2Provider.canonicalizeDesiredPropertiestoreturn propertiesproduced a ZERO-diff full suite — it now calls the real method through the real builder. The engine->diff wiring itself is pinned too (replacing the argument withundefinedwas previously undetectable). - ✅
AWS::ApiGatewayV2::Integration: the last 10 silent-drop properties wired, the type CLOSED (issue #609 apigatewayv2 batch) —src/provisioning/providers/apigatewayv2-provider.ts,scripts/gen-nested-key-coverage.ts, newtests/unit/provisioning/apigatewayv2-integration-props.test.ts,tests/integration/apigatewayv2-update-removal/{lib/apigatewayv2-update-removal-stack.ts,verify.sh,README.md}, coverage matrices regenerated. Before:ConnectionId/ConnectionType/ContentHandlingStrategy/CredentialsArn/IntegrationSubtype/PassthroughBehavior/RequestTemplates/ResponseParameters/TemplateSelectionExpression/TlsConfigwere declared nowhere and delivered nowhere, so every HTTP API service integration (IntegrationSubtype+CredentialsArn), every WebSocket MOCK integration (RequestTemplates+TemplateSelectionExpression) and every VPC-Link integration was rejected by the deploy-time pre-flight — and silently dropped under--allow-unsupported-properties. Now all ten rideCreateIntegration/UpdateIntegrationwith the matching reverse map inreadCurrentState;PROPERTY_COVERAGE_BY_TYPEreports an emptysilentDropfor the type, which closes the last of the fiveAWS::ApiGatewayV2::*types. Nine are exact-spelling pass-throughs;ResponseParametersis not, and forwarding it verbatim would have been a silent drop one level down. CloudFormation models it as{"<status>": {ResponseParameters: [{Destination, Source}]}}while the API takes the FLATTENED{"<status>": {"<Destination>": "<Source>"}}—Destinationbecomes a map KEY — so the SDK serializer, which drops members it cannot model, would have delivered an empty map per status code with a green deploy.toSdkResponseParametersperforms the fold (passing a non-object through untouched so an unresolved intrinsic still produces AWS's own validation error, and accepting an already-flat block for hand-written templates), andtoCfnResponseParametersperforms the inverse inreadCurrentState— without which the AWS-current side would carry the flat map against a CFn-shaped baseline and everycdkd driftrun would report permanent phantom drift on an untouched integration. Because a fold through a COMPUTED key is exactly what the nested-key critic's write-evidence pass cannot credit, the two audited paths carryNESTED_KEY_ALLOW_LISTentries rationale'd as a shape difference (the same shape as the existingAWS::AppSync::GraphQLApiTags.Keyentry), and the target'sminNestedKeysfloor moves 0 -> 4 now that the type audits its first nested blobs. Absent-field REMOVAL is deliberately NOT part of this batch, and that is a scope decision rather than an oversight — each field would need its own live CloudFormation A/B to establish whether CFn resets or retains it and which sentinel the API accepts as "unset", and the sibling Stage/Route batch is precisely why: its first draft guessed, and the live probe contradicted it. Guessing a reset here would turn a silent drop into a silent OVERWRITE of a live integration, which is strictly worse; the group stays with the #1160 umbrella and a unit test pins the no-op so a later change to it is visible in the diff. Integ: theapigatewayv2-update-removalfixture now carries THREE integrations, because the properties are scoped to different integration types — an HTTPHTTP_PROXYone (TlsConfig+ResponseParameters), an HTTPAWS_PROXYservice integration invoking EventBridge under its own IAM role (IntegrationSubtype+CredentialsArn), and a WebSocketMOCKone (RequestTemplates/TemplateSelectionExpression/PassthroughBehavior/ContentHandlingStrategy, which the previous batch's fixture comment had recorded as unreachable while they were still silent-drop).verify.shtherefore resolves integration ids byIntegrationTypeinstead of takingItems[0], and theResponseParametersassertion reads the values back under theirDestinationKEYS — which only passes if the fold reached AWS. All ten change VALUE across phases 1 and 2 rather than being removed, matching the scope decision above.ConnectionIdstays unit-test-only (a live VPC Link costs a VPC + subnets + SG per run for a plain pass-through string), recorded in the fixture README. The 3-axis review found one blocker and it is worth recording:Sourcewas skipped unless it was a STRING, but CFn typesResponseParametersas free-formobjectand coerces scalars, so an unquotedSource: 403under the canonicaloverwrite:statuscodedestination — the exact shape a YAML author writes — was dropped with a green deploy, re-introducing the very class the fold exists to close.Sourceis now coerced from a number / boolean; a non-stringDestination(it becomes a map KEY, so a scalar shorthand makes no sense there) is still skipped but no longer SILENTLY — it warns. The fixture writes the status code unquoted so the coercion is live-covered, and the review also moved three smaller things: the re-sentIntegrationSubtypenow falls back to the PREVIOUS side (the constraint is about the LIVE integration, which stays a service integration even when the template drops the subtype),toCfnResponseParameterssorts its pairs byDestination(it turns an unordered SDK map into an array, and the drift comparator compares arrays positionally), and all ten update gates use!= nullso an explicitnullcannot produce a field-lessUpdateIntegrationon every deploy. The fixture gained aprovisionedByassertion pinning all three integrations to the SDK route — a backfill that left one property unhandled would flip the type to Cloud Control, which forwards the full map and would still deploy green with none of this code on the path. A third finding came from the fixture growing: with three integrations across two APIs, two consecutive runs failed on DIFFERENT resources with API Gateway v2'sUnable to complete operation due to concurrent modification. Please try again later.— the service serializes mutations per API while cdkd deploys siblings of oneApiIdin parallel by design, andsrc/deployment/retryable-errors.tscarried no pattern for it, so a whole deploy failed on an error whose own message asks for a retry. The string now sits on the EXPONENTIAL half of the table (it is load-shaped contention, so the dense IAM-propagation cadence would make it worse), which made the run deterministic; issue (#1607) records the scope questions left open (a code-based match would be sturdier than a message one, and the DELETE path is unexamined). - ✅ ELBv2 LoadBalancer + TargetGroup silent-drop batch — closes both types on issue #609 —
src/provisioning/providers/elbv2-provider.ts,tests/unit/provisioning/elbv2-lb-targetgroup-props.test.ts,tests/integration/alb/. Nine previously-dropped properties are wired. LoadBalancer:EnablePrefixForIpv6SourceNat(CreateLoadBalancer + SetSubnets on update),Ipv4IpamPoolId(nested into the SDK'sIpamPoolswrapper on create;ModifyIpPoolson update withRemoveIpamPools: ['ipv4']on removal),EnforceSecurityGroupInboundRulesOnPrivateLinkTraffic(no CreateLoadBalancer member — applied via post-create / updateSetSecurityGroups),MinimumLoadBalancerCapacity(post-create / updateModifyCapacityReservation; removal issuesResetCapacityReservationbecause a retained reservation keeps billing), andEnableCapacityReservationProvisionStabilize(CFn-only orchestration flag with NO SDK member — cdkd pollsDescribeCapacityReservationuntil every zone isprovisioned, bounded ~10 min, timeout warns-and-continues, afailedzone errors; skipped under--no-wait; declared ingetDriftUnknownPathssince it has no AWS readback). TargetGroup:IpAddressType+TargetControlPorton CreateTargetGroup (both immutable — IpAddressType is schema createOnly, TargetControlPort has no modify API AND is missing from the schema's createOnlyProperties, so the update path rejects a change withResourceUpdateNotSupportedErrorand the engine falls back to replacement),TargetGroupAttributes(post-create ModifyTargetGroupAttributes + key-diff on update via the new shareddiffAttributeshelper — also now used by the LB / Listener attribute diffs; the removal arm sends the documented default fromTARGET_GROUP_ATTRIBUTE_DEFAULTSbecause ModifyTargetGroupAttributes REJECTS the empty-string reset — "A target group attribute value must be specified", live-caught by the alb integ's removal phase after the mocked unit tests had agreed with the wrong wire assumption; a removed key with no documented default warns and retains. NOTE this entry originally added "…the empty-string reset the LB / Listener attribute APIs accept"; that was only half true and the later issue #1609 item 1 A/B corrected it — those two APIs accept''for numeric / free-form-string keys but reject it for BOOLEAN / ENUM ones, see the #1609 entry below), andTargets(post-create RegisterTargets + full-tuple diff on update: register-first, then DeregisterTargets; deliberately NOT read back byreadCurrentState— declared ingetDriftUnknownPathsbecause the readback is an unordered object list the positional drift comparator would phantom-flag, and deregistration is async). TargetGroup create gained the same best-effort-delete-then-rethrow partial-create cleanup as the LB create path (TG names are unique, so a stranded TG would collide on the next deploy).readCurrentStatereads back the new LB fields (flatteningIpamPoolsto the CFn spelling, capacity viaDescribeCapacityReservationemitted only when units > 0) and the TG fields (full sortedTargetGroupAttributesset, same model as LB/Listener attributes). Live-verified end-to-end by the extendedalbinteg (baseline attrs/targets/IpAddressType → update-phase attribute diff + target swap → removal-phasetraffic-portreset + attribute 300 reset → destroy clean). The capacity-reservation pair is unit-only: the integ account lacks the LCU entitlement — a liveModifyCapacityReservationattempt was rejected with "This AWS account does not support configuring minimum load balancer capacity reservation" (the request shape reached the API; the refusal is account-level, not payload-level), and that failed update also live-exercised the automatic rollback (the TargetGroup's just-applied attribute + target changes were restored from state cleanly, with no update-refusal firing on the state-borne replay). ELBv2 is deliberately NOT added toNESTED_KEY_TARGETSin this PR (the critic script is contested by two in-flight lanes; tracked by issue #1393's target-expansion item). - ✅ The four S3 per-item / per-rule STRING refusals warn-and-SKIP on a state replay instead of hard-throwing (issue #1595) —
src/provisioning/config-shape.ts,src/provisioning/providers/s3-bucket-provider.ts, newtests/unit/provisioning/s3-bucket-provider-per-item-string-replay.test.ts. The path-split half of #1581, on the reads that issue's audit relied on to conclude the per-item containers of three appliers need no dedicated guard — that conclusion stands (they DO refuse a malformed item); what was unresolved is that they refuse on the REPLAY path too. Before: four reads — the lifecycle per-ruleStatus, the intelligent-tiering per-itemStatus, the inventory per-itemIncludedObjectVersions, and the replication per-ruleStatus(the fourth found while scoping, sitting in the same function as the container guard #1581 added, which makes it the one most likely to be mistaken for already-handled) — went through areadConfigStringcarrying noonUnusable.rollback-executor.ts's revert arm andcdkd drift --revertboth callupdate(..., previousState.properties, ...), so the desired bag can be a historical cdkd STATE record with no template-side remedy; a record written by an older binary with a malformed value made the bucket not merely un-updatable but UN-ROLLBACKABLE. Now: each site probes first and SKIPS on the replay paths. The downgrade is deliberately NOTreadConfigString's own warn-and-DEFAULT: every default here (Status->Enabled,IncludedObjectVersions->All) is applied to a LIVE resource, so defaulting would START an expiration rule deleting objects, an intelligent-tiering transition moving objects to archive tiers, or a replication rule copying objects OUT of the bucket — for a rule the template had DISABLED. The skip UNIT matches the API and the sibling container guard in the same applier: the WHOLE Put for lifecycle / replication (each replaces every rule, so applying the valid siblings alone would DELETE the malformed one from AWS) and the single configuration ITEM for intelligent tiering / inventory (those Puts are per-Id). A newconfigStringRefusal(container, key, fallback, containerPath, options?)inconfig-shape.tsexports the predicate WITHOUT the action clause — it returns the refusal sentence orundefined— andrequireConfigStringis re-expressed on the same internal predicate so the probe and the read cannot diverge; a test enumerates both over every value shape (a hand-writtentypeoftwin disagrees on exactly the blank string, the explicitnulland the coerced number). The CREATE path never probes, so its refusal is byte-identical to before, and the warning names the skip that actually happened rather than inheriting a "using the default" clause this path never makes good on. Deliberately out of scope, per the issue: the FIELD-level reads on the same replay-reachable paths (versioningStatus, the logging reads, the inventoryScheduleFrequency/Schedule.Frequencypair) — each is a separate per-site decision. 85 new unit tests (79 provider-side + 6 onconfigStringRefusal); neutering the skip fails 53 of them, and the 26 that survive are the create-path regression fences, which pass on the unfixed tree by construction. The PR review's test pass found three mutations the FIRST version of the suite did not catch, and each is now fenced: flippingcontinue->returnon the two per-Id appliers was invisible because both "valid sibling still applies" rows ordered the items['good','bad'], so an early return still emitted thegoodPut (the rows now put the malformed item FIRST); mutating the probe'sfallbackfrom'Enabled'to''was invisible because only one malformed shape was exercised per site, and a blank value is only refused when the fallback is non-blank (the replay / update rows are now parametrized over every shape); and swapping one applier's skip CLAUSE for a sibling's was invisible, so a log could claim an item skip where the whole Put had been left alone (the clause is now asserted per applier). - ✅ A
BillingModeflip to PROVISIONED now carries per-GSIProvisionedThroughput, so a table WITH an index can be flipped at all (issue #1588) —src/provisioning/providers/dynamodb-table-provider.ts, newtests/unit/provisioning/dynamodb-table-provider-billing-flip-gsi.test.ts,tests/integration/dynamodb-ondemand/{lib,verify.sh}. Surfaced by the review of PR (#1586), which shipped the real PAY_PER_REQUEST -> PROVISIONED flip and recorded in-code that per-index capacity was "a separate (deferred) concern" leaving "a silent gap". It was neither separate nor silent. The A/B was measured against real AWS on 2026-08-11 rather than assumed, and both halves matter: the flip carrying only table-levelProvisionedThroughput— exactly what the provider sent — is rejected outright withValidationException: One or more parameter values were invalid: ProvisionedThroughput must be specified for index: gsi1, applying NOTHING; the same call carryingGlobalSecondaryIndexUpdates[].Update.ProvisionedThroughputis accepted, and the readback shows the table at its declared capacity and the index at its own. So the flip was not degraded for a table with a GSI, it was impossible — the issue's own "very likely fails" is now measured fact. Now:update()buildsGlobalSecondaryIndexUpdatesin the SAMEUpdateTableas the flip, mirroring whatdynamodb-globaltable-provider.tsdoes for (#1387). Three scoping decisions, each with a reason a reviewer can challenge: the gate is the FLIP specifically (live PAY_PER_REQUEST -> desired PROVISIONEDwith at least one live index), because AWS rejects anUpdateTablethat re-asserts an index's current capacity and a plain capacity bump on an already-PROVISIONED table must keep working; the index list comes from the LIVEDescribeTable, not the template, because AWS's refusal names live indexes and a template-only list would miss exactly the index it complains about (an index that still exists but is no longer declared); and an index the template declares no capacity for is left OUT with a WARNING rather than pre-refused or given a guessed capacity — AWS names it a moment later, which is the CFn-handler outcome and a better message than anything cdkd could invent. 8 unit tests pin the positive path (single index, multiple indexes, string-typed capacities CFn emits, the live-vs-template list) and the scope fences (no index updates on a capacity bump, none on a flip to PAY_PER_REQUEST, the index-free #1553 shape untouched, the warn-and-omit case); suppressing the assignment fails exactly the 4 positive tests while the 4 fences correctly stay green. Thedynamodb-ondemandfixture's removal table gains a GSI — the very index its predecessor's comment says was left out BECAUSE of this gap — with capacity declared only on the update side, andverify.shwaits the index to ACTIVE (DynamoDB reports the TABLE active while an index still updates) before asserting the per-index capacity actually landed. - ✅ Four
AWS::Lambda::Functionsilent-drop properties wired:CodeSigningConfigArn/RuntimeManagementConfig/DurableConfig/TenancyConfig(issue #609) —src/provisioning/providers/lambda-function-provider.ts,src/analyzer/replacement-rules.ts,tests/integration/lambda-config-field-removal/,tests/unit/provisioning/lambda-function-props-609.test.ts,tests/unit/analyzer/replacement-rules-lambda-609-props.test.ts. Pre-PR behavior: all seven of the type'ssilentDropentries were rejected by the deploy-time pre-flight, so a template using any of them either failed to deploy or (with--allow-unsupported-properties) deployed with the value silently dropped. Now: four are delivered —CodeSigningConfigArnandDurableConfig/TenancyConfigonCreateFunction,RuntimeManagementConfigvia a post-createPutRuntimeManagementConfigunder the same delete-on-failure atomicity contractRecursiveLoop/ReservedConcurrentExecutionsalready used (the three now share one extractedapplyPostCreateConfighelper instead of three copies of the same 30-line block). Each also reads back inreadCurrentState, so none becomes phantom drift. The update path is where the AWS semantics stopped being guessable, and every rule below is a live probe (us-east-1, 2026-08-11), not a docs reading:CodeSigningConfigArnremoval maps toDeleteFunctionCodeSigningConfig(leaving it attached would keep enforcing a security control the template dropped — the #1160 class);RuntimeManagementConfighas no delete counterpart, so removal re-sends the AWS defaultUpdateRuntimeOn: Auto; andDurableConfigis the one field in theUpdateFunctionConfigurationblock that deliberately does NOT go throughclearOnUpdateRemoval, because AWS REJECTS adding a durable config to a function created without one ("You cannot add a durable configuration to a function that was originally created with no durable configuration") and omitting it on update KEEPS the live value with no reset payload available — so both presence toggles are routed to REPLACEMENT by a newconditionalReplacementspredicate while a both-sides-present edit stays in place.TenancyConfigis create-only in the CFn registry schema AND the SDK, so it is an unconditionalreplacementPropertiesentry. Deliberately NOT wired:CapacityProviderConfig+FunctionScalingConfig, which are one coupled feature —PutFunctionScalingConfigfails withAccessDeniedException: The function provided by the arn does not contain a capacity provider configurationunless the function already carries a capacity provider, which must be provisioned separately (issue #1616); andPublishToLatestPublished, now declaredunhandledByDesign— it is a CloudFormation version-publishing directive with no member on ANY@aws-sdk/client-lambdarequest shape (0 hits across the wholedist-typestree, while the other six all resolve). Tests: 30 new unit cases across the two files, plus thelambda-config-field-removalinteg extended to assert all four properties reached AWS, both removal spellings took effect, andprovisionedBy=sdkon both functions (a silent-drop re-route to Cloud Control would fail the run). Three existing test fixtures that usedRuntimeManagementConfigas their canonical Lambda silent-drop example were moved toFunctionScalingConfig, which cannot be backfilled on its own and so stays silent-drop. - ✅ Lambda
ImageConfigkept-partial is whole-object REPLACE — measured, pinned, no behavior change (issue #1225 item 3) —src/provisioning/providers/lambda-function-provider.ts(comment only),tests/unit/provisioning/lambda-function-provider.test.ts,docs/provider-development.md§2a. Why it was open: #1227's first pass classified every object-typed reset field shipped by the #1160 batches, andImageConfigwas one of the three left UNPROBED — ifUpdateFunctionConfigurationMERGED its sub-shape, a template that keepsImageConfigwhile droppingCommandwould silently retain the live value (the #1160 class one level down), and the provider's verbatim pass-through would have been a divergence. The measurement (live A/B, us-east-1, 2026-08-11, a container Lambda on a throwaway ECR image, both probes torn down): the SDK REPLACES — a create carrying{EntryPoint, Command, WorkingDirectory}followed byUpdateFunctionConfigurationwith{EntryPoint}alone left ONLYEntryPointlive; and CloudFormation reaches the same end state, anupdate-stackthat dropsCommand+WorkingDirectoryfrom a keptImageConfigblock reading back{EntryPoint}. Both halves were measured rather than one inferred from the other — "does CFn reset it" and "what does the API do with a partial object" are separate questions, and only the second says whether pass-through is already right. The{}clear value the provider has sent since #1157 is verified by the same run (SDKImageConfig: {}and a CFn template dropping the whole block both leaveImageConfigResponseabsent). What shipped: no behavior change — the classification is recorded at theclearOnUpdateRemovalsite and in §2a next to theLoggingConfigwhole-replace precedent, plus a unit test pinning that a kept-but-partial block goes out verbatim with no sub-field synthesis (a mutation that merges the previous sub-fields fails it). Remaining #1225 scope is now the two probes this lane deliberately did not take: ECSDeploymentConfiguration(its file is owned by the in-flight #609 ECS lane) and ASGCapacityReservationSpecification(needs a billed Capacity Reservation). - ✅
AWS::DynamoDB::TableBillingModeremoval is a RESET to PROVISIONED, and no longer emits an empty UpdateTable (issue #1553) —src/provisioning/providers/dynamodb-table-provider.ts, newtests/unit/provisioning/dynamodb-table-provider-billing-mode-removal.test.ts,tests/integration/dynamodb-ondemand/{lib/dynamodb-ondemand-stack.ts,verify.sh}. Before:create()substituted the CFn type defaultPROVISIONEDfor an absentBillingMode, butupdate()left itundefined. Against a recorded previous ofPAY_PER_REQUESTthat read as a change, and theupdateInput.BillingMode = …assignment was gated on the value being truthy — soUpdateTable({TableName})went out with NO mutable field, DynamoDB rejected it, and the deploy failed with a confusing error on every attempt (the #1160 absent-field class, surfaced by the review on the #1545 PR, which preserved the behavior byte-identically because its scope was the malformed-value guard). The measurement, not an inference from the type default (live CloudFormation A/B, us-east-1, 2026-08-11, stackCdkdIssue1553BillingModeAb): a table deployed withBillingMode: PAY_PER_REQUESTand then UPDATE'd with the property REMOVED and noProvisionedThroughputfails the stack —UPDATE_FAILED … Property ProvisionedThroughput cannot be empty->UPDATE_ROLLBACK_COMPLETE— while the same removal WITHProvisionedThroughput: {3, 4}reachesUPDATE_COMPLETEand reads backPROVISIONEDat 3/4. So CFn RESETS on removal; it does not retain, which is the opposite of what the destroy-parity folklore would have predicted and why the #1545 PR deliberately left it unmeasured. Now an ABSENT value on EITHER side normalizes toPROVISIONED, so the update path agrees withcreate(), the call always carries the resolved mode, and — because BOTH sides normalize — a table that never declared the property does not acquire a spurious change on every deploy (the regression a one-sided normalization would have introduced). A flip to PROVISIONED with noProvisionedThroughputin the template is deliberately left to fail at AWS rather than pre-refused: that is CFn parity, DynamoDB's own error names the missing member, and a pre-flight throw on the UPDATE path would fire on a rollback /drift --revertreplay of a state record the user cannot edit. The GlobalTable sibling was checked and is clean —AWS::DynamoDB::GlobalTable's own CFn default isPAY_PER_REQUESTand itsupdate()already resolves an absent value throughrequireConfigString(…, 'PAY_PER_REQUEST', …), sonewBillingis neverundefinedand the empty-UpdateTable shape cannot occur there. Integ: thedynamodb-ondemandfixture gained an unconditional hand-written L1BillingRemovalTable(an L2dynamodb.Tablealways emitsbillingMode, and the property has to be genuinely absent) whoseCDKD_TEST_UPDATE=truephase removesBillingModeand declares 3/4 instead, plus a phase-1.6 readback assertingPROVISIONEDat 3/4. - ✅ GlobalTable GSI recovery baseline now carries live VALUES, so a corrected template stops lagging a deploy (issue #1571) —
src/provisioning/providers/dynamodb-globaltable-provider.ts, newtests/unit/provisioning/dynamodb-globaltable-provider-gsi-live-baseline.test.ts,tests/integration/dynamodb-globaltable/{lib/dynamodb-globaltable-stack.ts,verify.sh}. Before: when a cdkd state record'sGlobalSecondaryIndexeswas present-but-unusable (which the provider's own warn path records whenever a malformed desired block is deployed), PR #1562 seeded the diff baseline from the LIVE table's index NAMES only —desiredSdkIndexes.filter(gsi => liveIndexNames.has(gsi.IndexName)). That stopped the permanent index loss it was written for, but every carried entry was a byte-copy of its desired counterpart, so the diff could produce nothing but ADDs: a per-index capacity edit compared no values at all, and the #1160 on-demand ceiling RESET — which is derived from the PREVIOUS side — could never fire, leaving a droppedMaxReadRequestUnitslive in AWS while cdkd reported success. NowbuildLiveRecoveryGsiBaselinecarries the live values that are safe to compare, with each exclusion tied to one of the three MEASURED failure modes of the first value-carrying attempt rather than to a guess:ProvisionedThroughputis gated on the LIVEBillingModeSummary(DescribeTable reports{0, 0}for every index of an on-demand table, and re-sending that is an AWS rejection), an index whose template declares*CapacityAutoScalingSettingskeeps an identity-only capacity (the live number belongs to Application Auto Scaling while the desired side isMinCapacity, so comparing them issues a scale-down nobody asked for — detected from the TEMPLATE by the newcollectAutoScaledGsiNames), andKeySchema/Projection/WarmThroughputare always copied from the desired side so they can never differ (the first two are immutable on an existing index and AWS does not guaranteeNonKeyAttributesreadback order; warm throughput reads back with aStatusmember the translated shape lacks and is increase-only). The whole entry is built by SPREADING the desired one so key ORDER is preserved —deepEqualisJSON.stringify, which is the second measured failure mode. A billing flip in play, or a live mode that disagrees with the desired one, falls back to the identity-only baseline. REMOVES are still not derived, and the decision is now recorded with the correct remedy: an index live but absent from the template stays in neither side, because a junk state record cannot distinguish "cdkd created this and the template dropped it" from "somebody added it out of band" and an index delete is irreversible — but the old warning promised that a later deploy would clear it, which CANNOT happen (once this deploy records a valid block the index is in neither side of every subsequent diff, so it survives indefinitely). The warning now says so and points atcdkd drift --accept+ redeploy, which does put the index back on the previous side. Integ: thedynamodb-globaltablefixture gained an unconditionalGsiRecoveryTable(presence unconditional, configuration mode-keyed — a mode-gated RESOURCE would be deleted by every later deploy whose mode list omits the token) plus a two-phase sequence no mocked client can reach:gsi-state-junkrendersGlobalSecondaryIndexesas an unfoldedFn::Joinover the region pseudo-parameter (Token.asAnyis what gets it past the generated L1 validator) so cdkd resolves it to a STRING and RECORDS the junk block, thengsi-state-recoverycorrects the template while changing one ceiling (40 -> 90) and dropping the other (50 -> absent). Pre-fix both read back unchanged. - ✅
AWS::ApiGatewayV2::Stage+::Route: the remaining silent-drop properties wired, both types CLOSED (issue #609 apigatewayv2 batch) —src/provisioning/providers/apigatewayv2-provider.ts, newtests/unit/provisioning/apigatewayv2-stage-route-props.test.ts,tests/unit/provisioning/apigatewayv2-provider-readcurrentstate.test.ts,tests/integration/apigatewayv2-update-removal/{lib/apigatewayv2-update-removal-stack.ts,verify.sh}, coverage matrices regenerated. Before: Stage droppedAccessLogSettings/ClientCertificateId/DeploymentId/RouteSettingsand Route droppedApiKeyRequired/ModelSelectionExpression/RequestModels/RequestParameters/RouteResponseSelectionExpression, so a template using access logging, per-route throttling or any WebSocket route feature was rejected by the deploy-time pre-flight (and silently dropped under--allow-unsupported-properties). Now all nine rideCreateStage/UpdateStage/CreateRoute/UpdateRouteas exact-spelling pass-throughs, with the matching reverse map inreadCurrentState;PROPERTY_COVERAGE_BY_TYPEreports an emptysilentDropfor both types. Removal semantics were MEASURED, and the first draft got them wrong. A raw-SDK probe against real AWS (2026-08-11, us-east-1) settled all five open questions at once, and the review that demanded it is why:UpdateStageMERGES, so a stage keeps itsAccessLogSettingsverbatim through an update that omits the member — the pass-through the first draft shipped would have left access logging (and its billing) running forever. AWS ships dedicated delete APIs for exactly these, soupdate()now issuesDeleteAccessLogSettingson removal,DeleteRouteSettingsper DROPPED route key, andDeleteRouteRequestParameterper dropped parameter key — the same out-of-band shapeDeleteCorsConfigurationalready had on::Api, and each fires even when noUpdate*-able field changed. The probe also CONFIRMED the two sentinels the review flagged as unverified:UpdateStageacceptsClientCertificateId: ''and clears it, andUpdateRouteaccepts an empty-stringRequestModelsvalue and clears the model, soclearableUpdate/mapWithRemovalsare right for those.ApiKeyRequiredresets to the CFn defaultfalse. OnlyDeploymentIdand the two selection expressions stay pass-through — no delete API, no documented sentinel. Integ: theapigatewayv2-update-removalfixture gained a WebSocket API, because all five Route properties are documented WebSocket-only — an HTTP API cannot exercise them at all. Two WS routes, not one: AWS rejectsRequestParametersanywhere but$connect(Request parameters are only supported for the $connect route in WEBSOCKET APIs, hit live on this fixture's first run), while the selection expressions belong on a body-carrying route. The WS stage carriesAccessLogSettings(against a real log group) plusRouteSettings, whose members are written in PascalCase on purpose:CfnStage.routeSettingsis typedany, so CDK passes the map through verbatim and a camelCase key would be dropped by the SDK serializer with a green deploy — the silent-drop class this backfill closes, one level down. A third phase (CDKD_TEST_REMOVAL) drops the three delete-API-only members and asserts each is gone on AWS while a RETAINEDRouteSettingssibling key proves the removal deleted one key rather than the block; it builds on the update phase rather than reverting to the baseline, so the only delta it introduces is the removal itself. Run 2026-08-11: PASS 105s, destroy 0 errors, 0 orphans.ClientCertificateId(needs a WebSocket client certificate),DeploymentId(meaningful only withAutoDeployoff, andAWS::ApiGatewayV2::Deploymentis not a registered cdkd type) andRequestModels(needsAWS::ApiGatewayV2::Model, also unregistered) stay unit-test-only, recorded in the fixture README.AWS::ApiGatewayV2::ApiFailOnWarningsis NOT part of this batch — it was alreadyunhandledByDesign(OpenAPI-import-only), and::Integration's 10 remain open. - ✅ A malformed non-object CONTAINER is refused (create) or warned-and-skipped (state replay) instead of silently emptying the block (issue #1581) —
src/provisioning/config-shape.ts,src/provisioning/providers/s3-bucket-provider.ts, newtests/unit/provisioning/s3-bucket-provider-container-shape.test.ts. The parent-container sibling of #1579, on the containers whose members are probed for PRESENCE rather than read as a string — so neitherreadConfigString(which needs a string read to reach its rule 2) norrequireConfigArray(which needs a list) ever fired on them. Before: a present-but-non-OBJECT lifecycleFilter(Filter: 'logs/', an array, an unresolved intrinsic) indexed EVERY member probe ingatherScope—Prefix/TagFilters/ObjectSizeGreaterThan/ObjectSizeLessThan— toundefined, so the rule kept NO location scope and fell through to the empty-prefix V2Filter, applying to the WHOLE bucket; for an expiration rule that deletes objects the rule was never meant to touch (the #1388 widened-scope hazard reached through the parent container instead of a mis-placed key). The same shape on analyticsStorageClassAnalysisindexed theDataExportprobe toundefinedand shippedStorageClassAnalysis: {}, which S3 ACCEPTS as "no export" — a silent drop with no error anywhere. Found by the PR #1580 review, filed rather than folded in. Now: a newrequireConfigObject(value, path, options?)inconfig-shape.ts— the object-shaped sibling ofrequireConfigArray, same overloadedonUnusablestate-replay downgrade, same division of labour (the caller keeps the ABSENT case, so{}and an omitted block both stay legitimate) — guards four containers: REFUSE on a template-path create; WARN and skip on the replay-reachable paths, skipping the whole lifecycle / replication Put (each replaces every rule, so applying the valid siblings alone would DELETE the malformed one from AWS) or the single analytics configuration item (that Put is per-Id). The PR review found a THIRD live instance of the class, with the widest blast radius of the three, and it is fixed here rather than deferred:applyReplicationConfiguration's per-ruleFilterhas the identical shape, and its fall-through arm emitsFilter: {}— the valid CFn form meaning "replicate EVERY object" — so a malformed container silently replicated the WHOLE bucket instead of the declared subset, sending data outside its intended scope at cross-region transfer cost. That site additionally sat behind a TRUTHINESS gate (if (filter)), the #1493 shape, so a falsyFilter: ''skipped the branch entirely and fell to the top-level-Prefixarm; the gate is now!= null.applyReplicationConfigurationgained theonUnusableparameter and both call sites thread it (update always warns, create passesreplayOnUnusable). TheStorageClassAnalysis.DataExportcontainer was previously refused only INDIRECTLY, by thereadConfigString(dataExport, 'OutputSchemaVersion', …)below it — which carries no downgrade, so a historical state record with a malformed block hard-threw and left the resource un-rollbackable; it now takes the same warn-and-skip contract as its siblings, with the create-path refusal unchanged. The lifecycle path'sisPlainPrefixOnlygate was hardened from!== undefinedto!= nullto match, so an explicitFilter: nullmeans "block omitted" throughout the function instead of silently forcing every rule in the configuration into V2 Filter form. Audited and deliberately NOT guarded (the issue asked for the one-level-up sweep before scoping): the per-ITEMconfigcontainers of the five appliers — intelligent tiering, inventory and the lifecycle rules already refuse a non-object item through an existingreadConfigString(config | rule, …), while metrics and analytics read onlyIdoff the item, whose absence makesPutBucket{Metrics,Analytics}Configurationreject the request outright. That is a LOUD failure rather than the silent scope-widening these guards exist for, so the decision is recorded in-code next to the loop rather than paid for with a replay-path behavior change. 27 new unit tests (21 provider-side + 6 onrequireConfigObjectitself) pin refusal / warn-skip / valid-and-absent-still-apply / the array-vs-object case a baretypeof === 'object'check would wave through; reverting either guard fails the suite. - ✅ A malformed non-array
TagFiltersis refused (create) or warned-and-skipped (state replay) instead of silently omitting the filter (issue #1579) —src/provisioning/config-shape.ts,src/provisioning/providers/s3-bucket-provider.ts, newtests/unit/provisioning/s3-bucket-provider-tagfilters-shape.test.ts. Before: all three S3 per-item filter builders (metrics / analytics / intelligent-tiering) readTagFilterswith a blind cast, so a present-but-non-array value (a single tag OBJECT, an unresolved intrinsic, a string) read as ZERO predicates via?.lengthand theFilterblock was silently omitted — the configuration deployed with a WIDER scope (all objects) than the template declared; on the lifecycle path the same shape read as "no tags" and an expiration rule applied to the WHOLE bucket (the #1388 widened-scope hazard from a malformed container). Found by the PR #1576 review (recorded on the then-closed umbrella #1493, re-filed standalone). Now:requireConfigArrayaccepts the sameonUnusablestate-replay downgrade #1556 gavereadConfigString(overloaded so existing throw-only callers keep their non-undefinedreturn type), and all four S3 sites validate the container: REFUSE on a template-path create; WARN and skip on the replay-reachable paths (replayingStatecreate, the keyed update sync) — skipping the whole configuration item (metrics / analytics / IT) or the whole lifecycle Put (it replaces every rule, so skipping one rule would silently DELETE it from AWS), never applying the item without its tag predicate. Container-level validation only, matchingrequireConfigArray's existing contract (element garbage inside a real array still fails loudly at S3). 24 new unit tests (21 provider-side + 3 onrequireConfigArrayitself) pin refusal / warn-skip / valid-sibling-still-applied / absent-still-applies; neutering the guard fails 19 of them. The PR review additionally hardened the lifecycle path's two strict=== undefinedcompares to== null— an explicitTagFilters: nullmeans "no entries" per the list-block contract and used to crash.lengthwith a raw TypeError; the sibling malformed-Filter-CONTAINER class (one level up) is follow-up issue (#1581). - ✅ A committed raw NUL made
efs-provider.tsinvisible to every grep/rg audit (issue #1587) —src/provisioning/providers/efs-provider.ts, newscripts/check-source-control-bytes.ts+tests/unit/scripts/source-control-bytes.test.ts. Before: thecreationTokenhash separator at line 435 was written as a LITERAL U+0000 byte rather than the\0escape. Identical at runtime, so typecheck / lint / format / tests all passed — butgrepandrgclassify a NUL-carrying file as BINARY and skip its contents, and the systemgrephere is ugrep, which omits GNU grep's "Binary file matches" notice entirely. Sogrep -rn '<term>' src/provisioning/providers/returned zero hits from this provider, with no warning and exit 0. That is not hypothetical: a/work-issuespass audited the #1160 SUSPECT row for this file, got zero hits forThroughputMode, and concluded the efs removal batch was unshipped when PR #1535 had shipped it on 2026-08-10 — the row read as open work purely because the audit tool could not see the file.git grep/git diffwere UNAFFECTED (git's binary heuristic inspects only the first 8000 bytes, and the NUL sat at 17230), so GitHub review rendered the file normally and nothing in the PR flow could have surfaced it. Now: the separator is the\0escape — byte-identical, so thecdkd-${logicalId}-${tokenHash}creation token is unchanged and no live file system's identity moves (verified by computing the digest both ways) — and a new classifier rejects every C0 control byte plus DEL except TAB / LF (CR included; the repo is LF-only) across every tracked non-binary file, exempting genuinely binary files by EXTENSION so a new asset needs no allow-list edit. Scope was MEASURED rather than predicted: after the one fix, a tree-wide sweep of all tracked files found this was the ONLY affected source file (assets/cdk-vs-cdkd.gifis the only other NUL carrier and is legitimately binary), so the checker starts from a green tree at zero findings. 19 unit tests cover the classifier shapes, the extension exemption, the message content and the tree-wide sweep; per the repo's checker rule the gate is probed against REAL code — re-introducing the actual NUL intoefs-provider.tsfails the sweep namingefs-provider.ts:435 ... offset 17230, reproducing the original defect exactly. - ✅
AWS::EC2::Routerefuses a multi-destination template instead of silently narrowing it (issue #1566) —src/provisioning/providers/ec2-provider.ts, newtests/unit/provisioning/ec2-route-multi-destination.test.ts. Before:createRoutepicked the destination viaDestinationCidrBlock || DestinationIpv6CidrBlock || DestinationPrefixListId, so a (CFn-invalid) template carrying MORE than one destination key deployed with only the highest-precedence one and SILENTLY DROPPED the rest, while CloudFormation and EC2 reject the combination withInvalidParameterCombination. Surfaced by the code review on the #609 EC2::Route backfill (PR #1568) and deferred there because the provider file was already integ-verified. Now: the create path throws aProvisioningErrornaming every declared destination key, pre-flight — nothing reaches the wire. The refusal is TEMPLATE-path only: both STATE-borne paths downgrade to a warning and keep the pre-fix precedence, per the "an UPDATE-path refusal is a replay refusal too" rule — the rollback executor's reverse-replacement create (detected viaCreateContext.replayingState, mirroring theSecurityGroupIngressidiom in the samecreate()switch) andupdate()'s delete-and-recreate, whichcdkd rollback/drift --revertalso drive. The update path is load-bearing rather than cautious: it deletes the route BEFORE re-creating, so throwing there would strand a deleted route with no template-side remedy. An empty-string sibling is not counted as a second destination, matching the||chain the guard sits in front of. The existing #609 precedence pin (which asserted the silent narrowing on the template path — the contract this issue deliberately redefines) is retargeted to the state-replay path, the only place precedence still decides anything. 9 new unit tests plus the retargeted pin. - ✅ S3 analytics / intelligent-tiering / metrics filters count PREDICATES, not predicate kinds (issue #1573) —
src/provisioning/providers/s3-bucket-provider.ts, newtests/unit/provisioning/s3-bucket-provider-filter-predicates.test.ts. Before:applyAnalyticsConfigurations/applyIntelligentTieringConfigurationschose the wire shape from the COUNT OF PREDICATE KINDS (prefix present? + tags present?), soTagFilters: [A, B]with noPrefixcounted as one kind, fell into the single-Tagarm, and sentFilter = { Tag: A }— every tag after the first SILENTLY DROPPED (found by the PR #1574 review while spot-checking theAnalyticsConfigurations.TagFiltersterminal rename against its write site).applyMetricsConfigurationshad the same class in a different arm:Prefixwas tested FIRST in an else-if chain, soPrefix+TagFilters(orPrefix+AccessPointArn) sentFilter = { Prefix }alone and dropped the other predicate(s); it also sentAnd: { Tags: [] }for a bare emptyTagFilters: []. Now: all three builders count PREDICATES — more than one routes through theAndoperator, exactly one keeps its dedicated single-predicate shape, and an empty tag array is no predicate at all. Every newly-reachable wire shape was live-probed on 2026-08-11 and ACCEPTED with faithful readback:And{Tags[2]}on PutBucketAnalyticsConfiguration + PutBucketIntelligentTieringConfiguration,And{Prefix,Tags[2]}andAnd{Prefix,AccessPointArn}(real access point) on PutBucketMetricsConfiguration. ThereadCurrentStatereverse maps already read theAndmembers individually, so the round-trip is drift-clean without changes. 11 unit tests pin the regression shapes (the exact{ Tag: A }/ bare-{ Prefix }drops), the preserved single-predicate shapes, and the empty-array edge; reverting the fix fails 5 of them. - ✅ CloudTrail
KMSKeyIdremoval reset + the two S3 replication SSE-KMS members, both unblocked by disproving a shared premise (issues #1533 / #1523) —src/provisioning/providers/cloudtrail-provider.ts,tests/unit/provisioning/cloudtrail-trail-removal-resets.test.ts,tests/integration/cloudtrail-trail/{lib,verify.sh,README.md},tests/integration/s3-replication-and-filter/{lib,verify.sh}. The premise both issues rested on was wrong. Each said its measurement was blocked because a customer-managed KMS key's 7-day minimum deletion window would leave aPendingDeletionorphan behind every run, conflicting with the repo's never-end-a-run-with-orphans rule — and each therefore proposed sourcing a long-lived, alias-referenced key plus a bootstrap story as its preferred option. But a KMS key cannot be deleted synchronously at all, soPendingDeletionIS the terminal state of a deleted key rather than a leak, and three fixtures already assert exactly that as their expected post-destroy outcome (loggroup-kms-associatesince #958, pluspropagation-races-2ands3-vectors). Both issues are therefore closed with an ordinary per-run key and aPendingDeletionassertion — no bootstrap, no account-level prerequisite, and no fixture that fails on a fresh account.KMSKeyId(#1533): the live CFn A/B ran on 2026-08-11 and measured BOTH halves separately, because "CFn resets it" does not tell you the wire shape — a real CloudFormation removal UPDATE read backKmsKeyId: null(CFn RESETS),UpdateTrailwith the field ABSENT left the live key attached (so cdkd's pass-through was a silent #1160 divergence: the deploy succeeded, state dropped the field, and the trail stayed encrypted under a key the template no longer named), andKmsKeyId: ''was ACCEPTED and cleared it (so''is the payload).update()now routes the field through the sameclearOnUpdateRemovalshape asSnsTopicName. The probe also disproved the in-code note claimingUpdateTrailrejects an empty string withKmsKeyId is not in valid ARN format— the last field that note covered, after #1160 had already disproven it forSnsTopicName/CloudWatchLogsLogGroupArn.IsOrganizationTrailis the one field of the umbrella's SUSPECT row still unmeasured, and it is unmeasurable HERE rather than merely deferred: the integ account is not in an organization at all (AWSOrganizationsNotInUseException, probed the same day), so closing it needs a different AWS account, not more fixture work — it stays pass-through, pinned by a unit test that says so. Unit tests: the full #1157 trio plus both''-placeholder polarities forKMSKeyId, and the aggregate one-call reset. Integ: thecloudtrail-trailfixture gains a per-run key whose reference (not the key itself) is dropped in theCDKD_TEST_REMOVALphase, so phase 2 is a property removal on a LIVE trail; phase 1 captures the liveKmsKeyIdand guards it non-empty, which is what stops "null afterwards" from passing vacuously against a trail that never got a key. The two S3 members (#1523):Destination.EncryptionConfiguration.ReplicaKmsKeyIDand its required partnerSourceSelectionCriteria.SseKmsEncryptedObjects— wired by #1495 but unit-only — are now read back off the live bucket ins3-replication-and-filter. Neither bucket's default encryption is switched to SSE-KMS:SseKmsEncryptedObjectsis a source-object FILTER and needs no bucket-level change, while addingBucketEncryptionwould widen the drift comparison surface (the calculator compares arrays WHOLESALE, so an AWS-defaultedBucketKeyEnabledmember would read as drift) for no extra coverage. DeclaringSseKmsEncryptedObjectsalso retires that fixture's own phantom-drift hypothesis —assert_drift_cleanwas written around it as the canonical example of a sub-block AWS returns that the template never declared, and the template now declares it, so the check covers the opposite direction too.Destination.AccessControlTranslation.Ownerstays unit-only and is NOT a follow-up:Owner: Destinationis an ownership OVERRIDE that is only meaningful across accounts, so a same-account fixture could assert nothing real however it is written. - ✅
AWS::AppSync::GraphQLApi: the last 13 silent-drop properties wired, type CLOSED (issue #609 appsync batch) —src/provisioning/providers/appsync-provider.ts,src/analyzer/replacement-rules.ts,scripts/gen-nested-key-coverage.ts, newtests/unit/provisioning/appsync-graphqlapi-config-props.test.ts,tests/integration/appsync/{lib/appsync-stack.ts,verify.sh,README.md}, schema fixture re-captured, coverage matrices regenerated. Before:handledPropertieslisted onlyName/AuthenticationType/XrayEnabled/LogConfig/Tags, so a template using any auth-provider block, API-level limit or environment variable was rejected by the #608 pre-flight — and with--allow-unsupported-properties, silently dropped. Now all 13 are wired throughCreateGraphqlApi/UpdateGraphqlApi(AdditionalAuthenticationProviders,ApiType,EnhancedMetricsConfig,IntrospectionConfig,LambdaAuthorizerConfig,MergedApiExecutionRoleArn,OpenIDConnectConfig,OwnerContact,QueryDepthLimit,ResolverCountLimit,UserPoolConfig,Visibility) plusEnvironmentVariablesthrough its ownPutGraphqlApiEnvironmentVariablesAPI pair, with a matching reverse map inreadCurrentStateso the new properties cannot become permanent phantom drift.PROPERTY_COVERAGE_BY_TYPEnow reports an EMPTYsilentDropfor the type. Three things the wiring had to get right, each pinned by a test: (1) the SDK member spellings are irregular —openIDConnectConfig/iatTTL/authTTL, not the mechanical first-letter flipopenIdConnectConfig/iatTtl/authTtl— and the AWS SDK v3 serializer DROPS unknown members, so a near miss is a silent config loss on a green deploy; the type is therefore opted intoNESTED_KEY_TARGETS(freshObjectMapper: true,keyStyle: 'lower-first', measured at 33 audited paths / 0 findings, withTags.Key/Tags.Valueallow-listed because AppSync models tags as a flatRecord<string, string>rather than CFn's[{Key, Value}]list). (2) The nestedUserPoolConfigunder an ADDITIONAL auth provider is a DIFFERENT AWS shape (CognitoUserPoolConfig, nodefaultAction) from the top-level one, so the two have separate mappers. (3)ApiType/Visibilityare create-only on AWS (UpdateGraphqlApihas no member for either) while the registry schema ships an EMPTYcreateOnlyPropertieslist — so thecreate-only-properties.tsfallback classifies NOTHING and the change would have been routed to an in-place UPDATE that silently drops it; both are now hand-authoredreplacementPropertiesinReplacementRulesRegistry, with a defense-in-depthResourceUpdateNotSupportedErrorin the provider. Removal semantics were measured, not assumed: AppSync treats an OMITTEDUpdateGraphqlApimember as "no change" (the #1160 absent-field class), so the members with a documented AWS reset sentinel are actively reset on removal (IntrospectionConfig->ENABLED,QueryDepthLimit/ResolverCountLimit->0,OwnerContact->'',AdditionalAuthenticationProviders->[],EnvironmentVariables->{}via a whole-map re-PUT) while the three auth-config blocks andMergedApiExecutionRoleArn, which have none, WARN with a destroy-and-redeploy pointer instead of no-op-ing silently. One pre-existing bug the fixture surfaced live: everyupdate()path in this provider returnedattributes: {}, and the deploy engine resolvesresult.attributes ?? currentResource.attributes— so a PRESENT-but-empty object wiped the create-timeApiId/Arn/GraphQLUrland the stack'sGraphQLApiUrloutput broke on the first in-place update (Cannot resolve Fn::GetAtt … attributes are not enriched for this resource type). All eight update returns now OMIT the field; the integ asserts the output still resolves after the update. Integ: theappsyncfixture gained averify.shwith baseline /CDKD_TEST_UPDATE/CDKD_TEST_REMOVAL/ destroy phases that read every value back offGetGraphqlApi+GetGraphqlApiEnvironmentVariables(a green deploy proves nothing here), assertsprovisionedBy == sdkas a #614 routing guard, and backs the two nested auth blobs with a real Cognito user pool + Lambda authorizer. Run 2026-08-11: PASS in 74s, destroy 0 errors, 0 orphans. Top-levelUserPoolConfig(needs Cognito as the PRIMARY auth mode),MergedApiExecutionRoleArn(needsApiType: MERGED) andVisibility: PRIVATE(needs a VPC endpoint) stay unit-test-only, recorded in the fixture README. The 3-axis review then found four defects, all fixed here:EnvironmentVariablesrides a SEPARATE API that can only run afterCreateGraphqlApisucceeds, so a failure there threw beforecreate()returned the physicalId — the deploy engine never learned the API existed, orphaning it and colliding by name on the next attempt (now best-effort deleted in the catch, mirroring the Cognito / DynamoDB post-create rollback pattern); a present-but-non-object config block read as "absent" and deployed green with no auth configured, which is the very silent-drop class this batch closes (each nested container now refuses a malformed value, andAdditionalAuthenticationProvidersgoes through the sharedrequireConfigArray); a malformedEnvironmentVariableswas the destructive case of that same bug — the PUT is a WHOLE-MAP replace, so reading it as absent would have WIPED every live variable, and the update path now warns and leaves AWS untouched rather than throwing, since the rollback executor replaysupdate()with a state record the user cannot edit; and a non-string variable value would have reached AWS as the literal"[object Object]". The drift-side env-var read was also raised from debug to WARN, because silently dropping the key makes the read asymmetric with the recorded baseline and manufactures a phantom removal that--revertwould act on. Two review points were deliberately NOT taken and are recorded in code:UserPoolConfig.DefaultActionis NOT defaulted (it decides whether an unauthenticated request is allowed, so inventing a value would be a security decision made on the user's behalf, and CFn's own default here is not A/B-verified — an omitted required member reaches AWS as a loud validation error, not a silent drop), and the five sentinel-less members stay in the update-diff list so their removal still produces the "cannot clear X" warning, at the cost of one no-opUpdateGraphqlApion a removal-only redeploy. - ✅
AWS::CloudTrail::TrailEventSelectorsremoval is a RESET, not a no-op (issue #1549) —src/provisioning/providers/cloudtrail-provider.ts,tests/unit/provisioning/cloudtrail-trail-removal-resets.test.ts,tests/integration/cloudtrail-trail/**. The gap: the #1160 cloudtrail batch swept the fields that rideUpdateTrail;EventSelectorshas its ownPutEventSelectorscall and its branch fired the Put onlyif (newEventSelectors && newEventSelectors.length > 0), so removing the property from the template skipped the call entirely and the live trail kept its selectors while cdkd reported success — the #1160 absent-field silent-drop shape, ~15 lines from anInsightSelectorsbranch that already did the right thing. The batch left it alone deliberately: the repo rule is to A/B before mirroring a sibling by analogy, and this one had not been measured. The measurement (live CFn A/B, us-east-1, 2026-08-11): a trail deployed withEventSelectors: [{ReadWriteType: WriteOnly, IncludeManagementEvents: true}]and then UPDATE'd through real CloudFormation with the property REMOVED reads back asReadWriteType: All— CFn CLEARS the custom selector to the default a selector-less trail has. And the wire shape is NOT the sibling's:PutEventSelectorswithEventSelectors: []is rejected (InvalidEventSelectorsException: Specify a valid number of selectors (1 to 5) for your trail), while sending the explicit default selector reproduces the post-removal read byte for byte — so the two branches agree on POLICY (fire the Put whenever the diff fires) and legitimately differ in PAYLOAD. Also fixed, from the same review: a removedIsLogging(desiredundefined, previousfalse) compared as a change and took theelsearm, silently issuingStartLogging— unreachable from a valid template (IsLoggingis CFn-required) but reachable from a rollback /drift --revertreplay of a partial state record, so the branch now requires a defined desired value. The integ fixture gains theWriteOnlybaseline (deliberately not the AWS default, so a reset is distinguishable from a retention AND from the pre-fix "no call at all"), the post-removal reset assertion, and the post-removalcdkd diff --failno-op assertion the review asked for as a phantom-drift guard. - ✅ Nested-key critic: the file-global literal rescue is now scope-verified (issue #1393 items 1+2) —
scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts, regenerateddocs/_generated/nested-key-coverage.{json,md}. Before: a CFn nested key with no same-spelled SDK member was classifiedprovider-handledwhenever its string literal appeared ANYWHERE in the provider file — so a literal named legitimately at one place (a reverse map, an unrelated top-level) vouched for every same-spelled occurrence with a broken write path (measured on the real pre-#1426 S3 provider:TagFilters/TransitionInDayswere unflaggable), and the rescue ran BEFORE the near-miss check, so a file-global literal also masked case-divergences (item 1). Now: for a write-evidence (freshObjectMapper) target — every current target — the literal is trusted only with scoped delivery proof: a genuine SDK member written at the RESOLVED parent chain whose case-folded name equals the audited key, or a declaredterminalRenamesentry that resolves on the write side; whole-blob hand-offs deliberately do NOT vouch (a verbatim forward carries the CFn spelling, which by premise matches no SDK member — the serializer drops it on the wire). The 45 entries the old heuristic had been rescuing were each verified against their provider's actual write site and declared: 42 terminal renames (S3 35, CloudFront 6 incl. the computed-key acronym renames that resolve through the #1475 spread-and-patch exclusions, ECS TaskDefinition 1; the lifecycle-singular child paths ride the pre-existing scoped segment renames), and 3 reviewedpasses: ['key']allow entries for conversions no walk can see (S3 presence-encodedEventBridgeEnabled, S3 lifecycle destructured-gatherScopeTagFilters, CloudFront legacyCustomOrigin.OriginSSLProtocols). Red-direction proofs added per the checker rules: stripping the analyticsFilter.Andwrite flipsAnalyticsConfigurations.TagFilterstono-sdk-memberwhile the sibling families stay clean (the exact discrimination #1430 recorded as impossible), each new allow entry is load-bearing (removing it flags the unregressed provider), and the stale-allow-list probe was rebuilt on the new rule (a floating literal no longer un-stales an entry — that rubber stamp is what this closes). Items 3 (new targets: EventBridge / Scheduler / Glue / EMR / …) and 5 (union-masking needs the shape-aware v2, #1378) stay tracked on #1393. - ✅ Distributed
cdkdskill v0.2.0: drift / gc / orphan / local coverage (follow-up to PR #1548) —plugins/cdkd-skills/skills/cdkd/SKILL.md, both manifests bumped to 0.2.0. The v0.1.0 skill covered the install→deploy→verify→destroy spine but four command areas were missing or guard-list-only: local (new "Run workloads locally" section — thecdkd local *family runs Lambda / API Gateway / ECS / ALB / CloudFront / AgentCore on Docker with no AWS credentials, so the skill now explicitly exempts these from the deployment-boundary ceremony; links docs/local-emulation.md), drift (new section: detect exits 1,--accept= state←AWS vs--revert= AWS←state, mutually exclusive, both honor--dry-run, revert flagged destructive), gc (new section: deletes only state-unreferenced assets, per-region, never touches CDK bootstrap storage,--dry-runfirst, keep the default--older-than 30dage guard; also added to the destructive-guard list), and orphan (ownership paragraph distinguishing per-resourcecdkd orphan <stack/ConstructPath>from whole-stackcdkd state orphan <stack>, both leaving AWS resources intact, paired with removing the construct from the app). Command reference gaineddrift+gc --dry-run. Verified: plugin loads as 0.2.0 via realclaude --plugin-dir(on-invoke ~4.4k tok), no Japanese, new doc link returns 200. - ✅ Distributable
cdkdskill for AI coding agents (follow-up to PR #1522) —plugins/cdkd-skills/skills/cdkd/SKILL.md,plugins/cdkd-skills/.claude-plugin/plugin.json,.claude-plugin/marketplace.json,skills/cdkd(symlink),.claude/skills/use-cdkd/SKILL.md,README.md. Before: the safe-usage guidance contributed in PR #1522 lived only in the project-scopeduse-cdkdskill, so end users needed a full repo clone plusclaude --add-direvery session, and non-Claude agents had no install path at all. Now: the end-user guidance ships as a standalonecdkdskill installable three ways — Claude Code plugin marketplace (/plugin marketplace add go-to-k/cdkd+/plugin install cdkd-skills@cdkd),gh skill install go-to-k/cdkd cdkd, andnpx skills add go-to-k/cdkd --skill cdkd(the latter two serve any SKILL.md-format agent, Codex and Cursor included). The repo layout mirrors the proven go-to-k/cdk-skills structure (plugin dir + root-levelskills/symlink). The repo-internaluse-cdkdskill is slimmed to the contributor concern (build the checkout, pnpm link) and defers to the distributed file for the shared safe-usage flow; both files carry a sync comment requiring same-PR updates plus manifest version bumps when CLI behavior changes. README's "Use with Claude Code" section became "Use with AI Coding Agents" with the three install channels;--add-dirremains documented as the contributor path. Distributed-skill links were converted to absolute GitHub URLs (all live-checked 200). Verified: realclaudeCLI end-to-end —plugin marketplace add(local path) +plugin install cdkd-skills@cdkd+ inventory check (1 skill, ~90 tok always-on) — and realnpx skills add --skill cdkd(installs exactly the distributed skill; the bare form lists all 14 repo skills, which is why the README pins the--skillform). npm-package contents are unaffected (filesallowlist excludesplugins/), andchore:commits do not trigger semantic-release. - ✅ The last top-level
??defaulting site: DynamoDB GlobalTableBillingMode(issue #1513) —src/provisioning/providers/dynamodb-globaltable-provider.ts,src/provisioning/config-shape.ts(header), newtests/unit/provisioning/dynamodb-globaltable-provider-billing-mode-shape.test.ts,.claude/rules/code-layout.md. The gap: PR #1524 decided every top-levelproperties['X'] ?? 'default'site per site and shipped all but one —dynamodb-globaltable-provider.tswas owned by a parallel lane at the time, soBillingMode ?? 'PAY_PER_REQUEST'kept substituting on a present-but-unusable value. That default is the consequential direction: aBillingMode: null(anFn::Ifthe resolver could not resolve, a hand-authored L1 typo) on a template that saysPROVISIONEDcreated an ON-DEMAND table, and everyWriteProvisionedThroughputSettings/ per-GSIProvisionedThroughputblock below was then dropped by the provider's own billing-mode gate with only a diagnostic to show for it. The change: the create-path read goes throughrequireConfigString(properties['BillingMode'], 'PAY_PER_REQUEST', …, replayWarn(this.logger, context))and theupdate()read through the same helper with an unconditionalonUnusablewarn. Three per-site decisions follow #1524's recorded precedent verbatim:BillingModeis ENUM-valued so it does NOT takecoerceNumber(a number there is a template bug, not the unquoted-YAML scalar CFn coerces); the create refusal downgrades to a warning underCreateContext.replayingState, which required adding the optionalcontextparametercreate()did not previously declare, becauserollback-executor.ts's reverse-replacement arm revives the old resource from a state record the user cannot edit from their CDK code; and the update path warns unconditionally, since a rollback replaysupdate(…, previousState.properties, …)and a refusal there would leave the table UN-ROLLBACKABLE. ThepreviousPropertiessibling stays unguarded for the same state-side reason #1493 recorded. One thing the review of the sibling guard surfaced: the read sits OUTSIDEcreate()'stry, so the refusal is wrapped in aProvisioningErrorexactly as theStreamSpecificationguard 70 lines below already is — without it an untypedErrorwould escape into the deploy engine's retry loop. 14 unit tests cover the five malformed shapes, the typed wrap, the absent-key default, aPROVISIONEDpass-through that positively asserts the provisioned block survived the billing gate, bothreplayingStatepolarities, and the update-path warn plus the unguarded previous side. - ✅
AWS::Route53::HostedZoneHostedZoneTags+QueryLoggingConfigremoval resets (issue #1160 route53 batch) —src/provisioning/providers/route53-provider.ts, newtests/unit/provisioning/route53-provider-removal-resets.test.ts,tests/integration/route53/{lib/route53-stack.ts,verify.sh}. The gap: both entries on the umbrella'sroute53-provider.tsSUSPECT row were REMOVAL paths the provider had no way to express.applyHostedZoneTagsonly ever sentAddTags, so a tag dropped from the template stayed on the zone forever — and itstags.length === 0early return made clearing ALL tags a silent no-op.applyQueryLoggingConfigreturned early on an absent block, so removingQueryLoggingConfigleft the live config in place: query logging kept writing to CloudWatch — and kept billing — while the template said otherwise andcdkd diffreported no changes, i.e. permanently invisible. Measured, not assumed: a live CloudFormation A/B on 2026-08-10 (a hosted zone with two tags plus a query-logging config, updated to drop one tag and the whole block) showed CFn UNTAGS the dropped key while keeping the other, and DELETES the config — so cdkd's pass-through was a real divergence in both directions, and the fix mirrors what CFn actually does rather than what the docs imply. The change:update()now threadspreviousPropertiesinto both helpers, which is what turns an add-only apply into a diff. Tags sendRemoveTagKeysfor the previous-minus-desired key set alongsideAddTagsin ONEChangeTagsForResourcecall (computing removals that way guarantees no key lands in both lists); query logging routes an absent desired block to the provider's existingdeleteQueryLoggingConfigForZone, gated on the PREVIOUS side having carried a config so an ordinary hosted-zone update does not pay aListQueryLoggingConfigsprobe per deploy. Both helpers keeppreviousPropertiesOPTIONAL, socreate()keeps its existing removal behavior — there is nothing to remove from a zone that did not exist a moment ago. The 3-axis review then found three defects in the destructive direction, all fixed here: a per-ELEMENT malformed tag ([{Key:{Ref:'X'}}]IS genuinely an array) was dropped by the reader, so its well-formed SIBLING was untagged — the guard is now LOSSY-READ based and validatesValueas well asKey, since Add and Remove share oneChangeTagsForResourcecall and one bad value failed the whole request and took the removals with it;QueryLoggingConfig: {}was classified malformed although that is exactly what this provider's ownreadHostedZoneemits for "no live config", whichcdkd drift --revertfeeds back as the DESIRED side, so every hosted-zone revert would have warned and told the user to omit a block they never wrote; and a failed REMOVAL was swallowed, which is NOT self-healing the way a failed ADD is (state is rewritten without the property, so the next deploy's previous side no longer carries it and the value survives forever withcdkd diffclean — the exact #1160 failure mode), so both removal paths now throw and the query-logging one re-lists to verify the delete landed.updateHostedZone's catch passes aCdkdErrorthrough rather than re-labelling it, which theupdate-wrap-coveragecritic flagged. 24 unit tests carry the #1157 trio per behavior plus the create-unchanged, malformed-shape and removal-failure pins; each guard was proven by MUTATION against the real provider (reverting thepreviousPropertiesarguments fails 3 tests, disabling the lossy-read guard 2, reverting the removal throws 2, re-classifying{}as malformed 1). Theroute53integ fixture gained aCDKD_TEST_REMOVALphase (baseline asserts both are live first, so the "it's gone" result cannot be vacuously true) plus a post-destroy sweep for the query-logging log group. - ✅
AWS::CodeBuild::ProjectBuildBatchConfigremoval resets (issue #1160 codebuild batch) —src/provisioning/providers/codebuild-provider.ts. The A/B result reshaped the batch, the same way it did for elbv2: the umbrella's SUSPECT row listed 14UpdateProjectfields, but a live CFn A/B (2026-08-10, NO_SOURCE project) showed CloudFormation itself RETAINSDescription/TimeoutInMinutes/QueuedTimeoutInMinutes/ConcurrentBuildLimit/AutoRetryLimit/Cache/LogsConfigthrough a real removal update — CFn's own handler omits them fromUpdateProjectand CodeBuild merges — so cdkd's pass-through was ALREADY parity for all seven and they are pinned by retention tests rather than "fixed" into a divergence. Each of them HAS a working clear sentinel ('', 60, 480,-1, 0,NO_CACHE, cloudWatchLogsENABLED, all probed alone), which is exactly what makes resetting them an easy and wrong change. The one real gap:BuildBatchConfigcame backnullafter the CFn removal, and the ONLY shape that clears it is an EMPTY object — an omitted field is the merge no-op this issue tracks — soupdate()now routes the property throughclearOnUpdateRemovalwith a{}clear value, reusing the shared mapper so create and update stay on one code path. The reset is update-only: a create with no batch config still sends nothing. Not A/B'd, recorded rather than guessed:VpcConfig/SecondarySources/SecondaryArtifacts/SecondarySourceVersions/FileSystemLocations/BadgeEnabledneed infra a NO_SOURCE probe project cannot carry (a VPC, EFS, a real source provider, a badge-capable source) and stay pass-through. Unit tests:tests/unit/provisioning/codebuild-project-buildbatchconfig-removal.test.ts(the #1157 trio + the create-path guard + the seven CFn-parity retention pins). Integ: newcodebuild-projectfixture with aCDKD_TEST_REMOVALphase asserting BOTH halves of the table plus a replacement guard (run 2026-08-10: reset + all seven retentions live-verified, destroy 3 deleted / 0 errors, 0 orphans). Fixture note worth keeping: the CDK L2 emitsCache: { Type: NO_CACHE }unconditionally, so simply omitting the prop is a value CHANGE, not a removal — the first run failed the cache retention assertion against entirely correct provider behavior, and the fixture now usesaddPropertyDeletionOverride('Cache')to make phase 2 a true removal. - ✅
AWS::CloudTrail::Trailremoval resets (issue #1160 cloudtrail batch) —src/provisioning/providers/cloudtrail-provider.ts,src/deployment/retryable-errors.ts. Before:UpdateTrailis a merge-semantics API — a live probe with onlyName+S3BucketNameleft every other value untouched — so a property removed from the template was a silent no-op: the deploy reported success, state dropped the field, and the nextcdkd diffsaid "No changes". Now: a live CFn A/B (2026-08-10, a real trail with every optional field set and then removed) split the umbrella's SUSPECT row, andupdate()mirrors the measured result exactly. FIVE fields CFn resets and cdkd now clears:S3KeyPrefix->'',SnsTopicName->'',IsMultiRegionTrail->false,EnableLogFileValidation->false,IncludeGlobalServiceEvents->false(each sentinel probed ALONE against the live trail). TWO are RETAINED by CFn —CloudWatchLogsLogGroupArn/CloudWatchLogsRoleArnkept their values through the removal update — so the pass-through is already parity and is pinned by retention tests rather than "fixed" into a divergence. A stale in-code note was disproven in passing: the provider claimedUpdateTrailrejects an empty string for ARN-shaped fields; the probe shows''is ACCEPTED forSnsTopicNameandCloudWatchLogsLogGroupArnand nulls them out, which is what makes the string resets possible. TheIncludeGlobalServiceEventsreset is GUARDED on the effectiveIsMultiRegionTrail: AWS rejects a multi-region trail that excludes global service events (hit live while building the fixture), and the A/B only ever removed both together — resetting it under a retainedIsMultiRegionTrail: truewould manufacture a hard failure on a combination CFn was never measured on.retryable-errors.tsgains the CloudTrailVerify in IAM that the role has adequate trust relationshipsIAM-propagation pattern:CreateTrailvalidates that CloudTrail can assume the CloudWatch Logs role, and cdkd's fast SDK path issues the create ~1s after the role's CREATE — the live CFn A/B passed with the same trust policy purely because CFn is slower, and the fixture failed until the retry landed. Unit tests:tests/unit/provisioning/cloudtrail-trail-removal-resets.test.ts(22 cases — the #1157 trio per reset field, the''-placeholder previous-side normalization, both polarities of the multi-region guard, and the retention pins) + the retryable-errors pattern pin. Integ: newcloudtrail-trailfixture with aCDKD_TEST_REMOVALphase asserting BOTH halves of the table (run 2026-08-10: five resets + both retentions live-verified, destroy 10 deleted / 0 errors, 0 orphans). Follow-up filed: #1533 —KMSKeyIdandIsOrganizationTrailcould NOT be A/B'd (a customer-managed KMS key's 7-day minimum deletion window would orphan aPendingDeletionkey every run; an org trail needs an Organizations management account), so both stay pass-through, recorded as unmeasured rather than guessed. - ✅
AWS::ElasticLoadBalancingV2::TargetGroupHealthCheckPortremoval resets totraffic-port(issue #1160 elbv2 batch) —src/provisioning/providers/elbv2-provider.ts. The A/B result reshaped the batch: the umbrella's SUSPECT row listed all 8 target-group health-check fields plus the listener'sSslPolicy, but a live CFn A/B (2026-08-10, HTTP target group + HTTPS listener) showed CloudFormation itself RETAINS 8 of the 9 on removal —HealthCheckProtocol/HealthCheckPath/HealthCheckEnabled/HealthCheckIntervalSeconds/HealthCheckTimeoutSeconds/HealthyThresholdCount/UnhealthyThresholdCountandSslPolicyall kept their customized values through a real CFn removal update — so cdkd's pass-through (absent ->undefined->ModifyTargetGroup/ModifyListenermerge) was ALREADY CFn parity for those, and they are now pinned by negative tests rather than "fixed" into a divergence. The one real gap:HealthCheckPortis the single field CFn resets, to its create defaulttraffic-port;update()now mirrors that viaclearOnUpdateRemoval. GENEVE target groups are deferred with in-code rationale (their create default is port 80, and CFn's GLB removal behavior is not A/B-verified — retain instead of guessing). Unit tests:tests/unit/provisioning/elbv2-targetgroup-healthcheckport-removal.test.ts(the removal trio + GENEVE deferral incl. previous-side protocol resolution + the CFn-parity retention pins for the other 7 fields). Integ: thealbfixture gained a baselineHealthCheckPort: 8080+ aCDKD_TEST_REMOVALphase (run 2026-08-10: reset totraffic-portlive-verified, destroy 16 deleted / 0 errors, 0 orphans). - ✅
AWS::EFS::FileSystemthroughput removal resets provisioned mode tobursting(issue #1160 efs batch) —src/provisioning/providers/efs-provider.ts. The A/B split the SUSPECT row in two: a live CFn A/B (2026-08-11) showed CloudFormation RETAINS aThroughputModeremoved alone from a non-provisioned file system (elastic stayed elastic through a real CFn removal update) — so cdkd's pass-through skip was ALREADY CFn parity there and is now pinned by a negative unit test — but RESETS a'provisioned'file system to the create default'bursting'whenThroughputMode+ProvisionedThroughputInMibpsare both removed (provisioned@1MiBps -> bursting, provisioned value cleared). Before:update()skippedUpdateFileSystementirely on removal (the API merges — absent = "no change"), so the file system silently stayed provisioned and KEPT BILLING for provisioned throughput. Now: the both-removed-from-provisioned shape sendsThroughputMode: 'bursting'explicitly. Removing ONLYProvisionedThroughputInMibpswhile keepingThroughputMode: 'provisioned'stays deferred with in-code rationale (the API requires a value in that mode; no wire shape can reset it). Unit tests:tests/unit/provisioning/efs-provider-throughput-removal.test.ts(the removal trio + the elastic/bursting retention pins + true-path polarity + both deferral pins). Integ:efs-standalonegained aprovisioned@1MiBpsbaseline + aCDKD_TEST_REMOVALphase asserting the live in-place reset tobursting. - ✅
AWS::SNS::TopicDeliveryStatusLoggingprotocol canonicalization: the whole HTTP family folds onto the one prefix AWS accepts (issue #1529) —src/provisioning/providers/sns-topic-provider.ts. Before:normalizeDeliveryStatusProtocolhad two pre-existing gaps that made the entire HTTP-family protocol unusable. (1) The canonical CFn / CDK L2 spelling'http/s'— whatsns.LoggingProtocol.HTTPand the CFn schema's allowed-value list both emit, so what real templates actually carry — hit the switch'sdefaultand threwunsupported DeliveryStatusLogging protocol "http/s"at create/update time. (2) The'https'spelling it did accept mapped to anHTTPSprefix, producing attribute names likeHTTPSSuccessFeedbackRoleArnthat do not exist —SetTopicAttributesrejects them withInvalidParameter: Invalid parameter: AttributeName— and the reverse map inreadCurrentStateprobed those same never-present names. So a template using HTTP delivery-status logging either failed fast on the canonical spelling or failed at the AWS call on the alias. Now:'http/s','http'and'https'all canonicalize toHTTP,HTTPSis dropped fromSNS_DELIVERY_STATUS_PROTOCOLS(which holds AWS attribute PREFIXES), and the fail-fast error lists the accepted TEMPLATE spellings via the newSNS_DELIVERY_STATUS_PROTOCOL_SPELLINGS(application, firehose, http/s, lambda, sqs) rather than the prefixes — listing prefixes would have told users to writeHTTP, which the CFn schema does not accept. The existingstateProtocolCaseMapmechanism carries the state-recorded spelling through the reverse map unchanged, so a topic recorded ashttp/sreads back ashttp/sand no phantom drift fires on the respelling. Folding the family made a same-prefix collision reachable for the first time (a template declaring bothhttp/sandhttps); it WARNS and lets the last entry win rather than throwing, because this function's throw side is also fed by the rollback executor'supdate()replay of a cdkd STATE record, where the user has no template-side remedy. Live A/B (2026-08-11), both halves: a CloudFormation stack carryingProtocol: http/sproducedHTTPSuccessFeedbackRoleArn/HTTPSuccessFeedbackSampleRate/HTTPFailureFeedbackRoleArnon the live topic;SetTopicAttributeswithHTTPSSuccessFeedbackRoleArnon that same topic was rejected. Tests: newtests/unit/provisioning/sns-topic-provider-protocol-canonicalization.test.ts(every HTTP-family spelling folds toHTTP; no input ever yieldsHTTPS; unknown protocols and non-strings still rejected; the error names spellings not prefixes; the collision warn and its no-collision control), plus the three pre-existing suites that had pinned theHTTPSprefix updated to the live-verified names — reverting the normalizer fails 11 tests. Assertions that the rejected names are absent list them exactly rather than testing astartsWith('HTTPS')prefix, sinceHTTPSuccessFeedbackRoleArnalso starts with those five characters — the collision that made this bug subtle. Integ:sns-sqs-event's delivery-status topic gains a second,http/sprotocol entry with Phase-1 assertions that it lands underHTTPwith noHTTPS*attribute set, and a Phase-2 assertion that the removal reset covers it too (the HTTP family had no integ coverage at all before). - ✅ Cloud Map namespace
Description/ SOA-TTL removal resets (issue #1160 servicediscovery batch) —src/provisioning/providers/servicediscovery-provider.ts. Before: the threeUpdate*Namespacechange objects MERGE (an absent field keeps the live value) and each update method gated onproperties['Description'] !== undefined/ a desired-side-only SOA extraction, so aDescription(all 3 namespace kinds) orProperties.DnsProperties.SOA.TTL(private + public DNS kinds) REMOVED from the template silently survived on AWS. Now: removal resets mirror CloudFormation (live CFn A/B 2026-08-11 on PublicDnsNamespace + HttpNamespace: a removed Description is cleared entirely; a removed SOA TTL goes from the customized value back to 60): Description resets via the sharedclearOnUpdateRemovalwith the''clear sentinel (raw-SDK probed:UpdateHttpNamespaceaccepts''andGetNamespacethen omits the field), and the newresolveSoaTtlChange()resets a removed TTL to the KIND's create default —PUBLIC_DNS_NAMESPACE_DEFAULT_SOA_TTL = 60/PRIVATE_DNS_NAMESPACE_DEFAULT_SOA_TTL = 15— both raw-SDK probed (and the private kind separately CFn A/B'd after a reviewer caught that a shared 60 contradicted the PR #201-era live observation of 15).AWS::ServiceDiscovery::ServiceDescriptionstays in the umbrella's UNCERTAIN bucket — deliberately untouched by this batch. Unit tests:tests/unit/provisioning/servicediscovery-provider-namespace-removal.test.ts(the removal trio per kind + true-path polarity). Integ:servicediscovery-namespacesgained baseline-Description assertions + aCDKD_TEST_REMOVALphase asserting both Descriptions cleared and the SOA TTL reset to 60 in place (no replacement). - ✅
AWS::SNS::TopicDeliveryStatusLoggingremoval resets (issue #1160 sns batch) —src/provisioning/providers/sns-topic-provider.ts,src/deployment/retryable-errors.ts. Before: SNSSetTopicAttributesis per-attribute merge, andupdate()iterated only the DESIREDDeliveryStatusLogginglist — so removing the whole property (or a sub-field / protocol entry) silently kept the live per-protocol feedback attributes, and aSuccessFeedbackSampleRateof0was truthiness-skipped on create and update. Now: the new sharedbuildDeliveryStatusAttributeMap()flattens both sides into<Protocol><Suffix>attribute maps and the update diffs them: kept/changed attributes are sent, attributes present only on the previous side are explicitly reset — RoleArns cleared via''andSuccessFeedbackSampleRatereset to'0', the exact shape a CloudFormation removal leaves (live CFn A/B 2026-08-10;''is REJECTED for the sample rate). Presence is!= null, so a0sample rate now reaches AWS as'0'. The desired side throws on a malformed container / unknown protocol; the previous side (cdkd state) is walked in skip mode per the guard-the-desired-side-only rule.retryable-errors.tsgains the SNSis not a valid role to allow SNSIAM-propagation pattern (a feedback role created ~1s earlier in the same stack is rejected until IAM propagates — live-probed: the same policy-less role is accepted seconds later). Unit tests:tests/unit/provisioning/sns-topic-provider-delivery-status-removal.test.ts(the #1157 trio + sub-field / protocol-entry removal, case-only respelling no-op, malformed-container polarity, create-path0sample rate) + the retryable-errors pattern pin. Integ:sns-sqs-eventgained a delivery-status topic + feedback role and aCDKD_TEST_REMOVALphase asserting the live reset (run 2026-08-10: removal reset verified, destroy clean, 0 orphans). Follow-up filed: #1529 (pre-existing: the canonical CDK L2'http/s'protocol value is rejected and'https'maps to the nonexistentHTTPSattribute prefix) — since RESOLVED, see the #1529 entry above. - ✅
AWS::S3Express::DirectoryBucketTagsbecomes a handled property (issue #609 batch) —src/provisioning/providers/s3-directory-bucket-provider.ts, new dep@aws-sdk/client-s3-control. Before:Tagswas in the type's silent-drop set, so a tagged directory bucket needed--allow-unsupported-propertiesand the tags never reached AWS — which also kept the destroy data guard's tag opt-in (aws-cdk:auto-delete-objects, #1344) out of reach for first-class templates. Now: create forwardsTagsonCreateBucketConfiguration.Tags(the only create-time tag write the s3express split supports;PutBucketTaggingis documented not-supported for directory buckets); update applies the tag diff via S3 ControlTagResource/UntagResource(additive-only semantics compensated — removed keys, including whole-property removal, are explicitly untagged);readCurrentStatesurfacesTagsviaListTagsForResourcefor drift. Both deferred items recorded on #609's 2026-08-02 note ship in the same change: (1) the standard-bucketdeleteBucketWithEmptyRetrybounded race loop is PORTED to the opted-in auto-empty delete path (the ~zero-opt-in rationale from PR #1347 no longer holds now the tag is mainstream); (2) thes3-directory-bucketinteg gains a SECOND, tagged, DATA-CARRYING bucket whose auto-empty is live-exercised in the same destroy the untagged sibling's guard refuses, plus create-time tag assertion and aCDKD_TEST_UPDATE=truetag-mutation phase (TagResource value change + UntagResource removal, asserted viaaws s3control list-tags-for-resource). Tests: new tags unit suite (create forwarding / empty-omit, update add/change/remove/full-removal/no-op/error-wrap, readCurrentState tags); delete suite reworked to the ported retry shape incl. the bounded re-empty race case;gen-update-wrap-coveragepinned no-aws set updated (the provider'supdate()now sends + wraps). - ✅ The residual state-replay refusal sites, and the junk state the warn path they produce records (issues #1551 / #1552) —
src/provisioning/providers/sns-topic-provider.ts,lambda-url-provider.ts,dynamodb-globaltable-provider.ts,dynamodb-table-provider.ts, newtests/unit/provisioning/sns-topic-provider-create-replay.test.ts+dynamodb-billing-mode-junk-previous.test.ts,.claude/rules/providers.md,docs/provider-development.md. The gap (#1551): the #1544 / #1538 downgrades left three sites strict, and each still stranded a replay.SNSTopicProvider.create()declared nocontextparameter at all, so theREPLAYING_STATE_CREATE_CONTEXTthe rollback executor's reverse-replacement arm passes as the 4th argument was silently ignored andbuildDeliveryStatusAttributeMap's'throw'mode fired on a bag that IS a cdkd state record — no type error, no warning, just a refusal on the one path with no template-side remedy. The Lambda URL update-pathAuthTypeand the GlobalTable update-pathStreamSpecification/GlobalSecondaryIndexesguards were left strict deliberately, because the obvious downgrade (warn, then use the CREATE DEFAULT) is worse than the refusal on a LIVE resource:'NONE'makes an IAM-guarded function URL PUBLIC,NEW_AND_OLD_IMAGESre-points a live stream, and an empty GSI list reads as "delete every index". After: the downgrade is per site — keep the PREVIOUSAuthType(omitting the field entirely when the previous side is unusable too, sinceUpdateFunctionUrlConfighas merge semantics and retains the live value), SKIP theStreamSpecificationblock, and SUPPRESS the GSI diff. The GSI helper'sonUnusableIndexeshook is now wired from BOTH update call sites, including the PREVIOUS-side translation, which had no hook at all and threw on a state-borne value — the guard-the-desired-side-only rule violated outright. The gap (#1552): a warn-and-continue update SUCCEEDS, so the engine records the unusable desired value as the new state; the next update then compares a corrected template againstBillingMode: null, reads a flip, and sends a same-modeUpdateTablethat DynamoDB rejects when no capacity change rides along — the deploy fails, state stays unchanged, and the rejection repeats forever. After: both DynamoDB providers seed the comparison baseline from the table's ACTUAL mode (already held by theDescribeTableeachupdate()issues) whenever the state-recorded previous is present-but-unusable; an ABSENT previous is deliberately NOT unusable, since seeding it would turn a no-op into a spurious change. Three pre-existing tests pinned the superseded contracts and were re-pointed at what they actually protect (the URL must not become public — now asserted on the value SENT). - ✅
readCurrentStatehid a console-side CloudWatch Logs enable onAWS::CloudTrail::Trail(issue #1565) —src/provisioning/providers/cloudtrail-provider.ts,tests/unit/provisioning/cloudtrail-provider-readcurrentstate.test.ts+cloudtrail-trail-removal-resets.test.ts,tests/integration/cloudtrail-trail/verify.sh,docs/provider-development.md. The gap: theCloudWatchLogsLogGroupArn/CloudWatchLogsRoleArnpair was emitted only when AWS reported BOTH fields, so on a trail with no CloudWatch Logs wiring the keys were ABSENT from the captured snapshot — and the drift comparator's top-level walk is baseline-keys-only, which made a console-side ENABLE invisible tocdkd driftforever. The guard rested on two claims and BOTH were false: that AWS rejects a''round-trip for these fields (disproven by the issue #1160 live probe —''is accepted and nulls the field out) and that "a console-side enable shows up as both fields appearing at once on the next read" (it cannot, for the baseline-keys-only reason above; that wording was corrected in the #1160 review, which is what this issue was filed on). After: the pair is emitted unconditionally, TOGETHER, with''placeholders. The all-or-nothing invariant moves to the WRITE side:update()decides both fields together and forwards them on PRESENCE, so''CLEARS (without whichdrift --revertof a console-side enable was a silent no-op that still reported success) while an ABSENT pair stays retained per the measured CFn parity — pinned by new tests, because that is what makes the always-emit safe rather than a source of spurious calls. The integ gains a phase that CLEARS the pair out-of-band, re-baselines viadrift --accept, re-enables it out-of-band, and requirescdkd driftto exit exactly 1 — the sequence is what makes the assertion fail against the pre-fix binary, since only a snapshot captured while UNWIRED lacks the keys.docs/provider-development.md's Class-1 guidance is corrected too: its "console-side ADD is impossible" clause holds only when the discriminator is an INDEPENDENT sibling, not when the field group's own presence IS the switch. The write path grew two refusals in review: a non-string pair (anullsurvives a JSON state round-trip) and a HALF-populated one (reachable from the always-emitted snapshot of a trail AWS reports with one half) both send NEITHER field and warn, because coercing them would either read as a CLEAR and disable a live trail's log delivery or pair a real ARN with an empty one.create()routes its optional strings throughemptyToUndefinedfor the same replay reason.
Recently Implemented (2026-08-10):
- ✅ The 20 nested S3 keys the provider never wrote at all (issue #1495) —
src/provisioning/providers/s3-bucket-provider.ts,scripts/gen-nested-key-coverage.ts(header reason (C)),tests/unit/scripts/gen-nested-key-coverage.test.ts, newtests/unit/provisioning/s3-bucket-provider-nested-write-drops.test.ts,.claude/rules/{code-layout,providers}.md. The gap: #1474's builder recognizer cleared the false positives out ofAWS::S3::Bucket's write-evidence residual, and splitting what remained by "is this member written ANYWHERE in the file?" isolated 20 paths that were not — each with a same-spelled member in@aws-sdk/client-s3, each declared by the CFn registry schema under a top-level property the provider lists inhandledProperties, and none ever assembled. Nothing was dropped by the serializer; the provider simply never built the field, so the deploy reported success with the setting missing. The change: every one is now written, per member rather than by forwarding the blob, so a future CFn-only member cannot ride through unnoticed —LoggingConfiguration.TargetObjectKeyFormat(a bucket asking for the partitioned server-access-log key format silently got the flat default; the emptySimplePrefix: {}is preserved, since its PRESENCE is the signal), the fourReplicationConfiguration.Rules.Destinationblocks via a newbuildReplicationDestinationhelper —AccessControlTranslation(cross-account replication kept SOURCE ownership),EncryptionConfiguration.ReplicaKmsKeyID(replicas were not encrypted with the declared key; CDK'saws-s3L2 emits this for areplicationRulesentry with a KMS key), andReplicationTime+Metrics(a template asking for S3 Replication Time Control — a billed SLA — got plain asynchronous replication) — plus rule-levelSourceSelectionCriteria(replica-modification / SSE-KMS-object selection),LifecycleConfiguration.TransitionDefaultMinimumObjectSize(it sits on the PutBucketLifecycleConfiguration REQUEST, not insideLifecycleConfiguration, which is why the rules-only mapper never reached it), andBucketEncryption…BlockedEncryptionTypes(a bucket kept accepting the SSE-C it declared it would refuse). The matchingreadCurrentStatereads land in the same change, because a field cdkd SENDS but does not READ becomes permanent phantom drift thatcdkd drift --revertthen re-applies forever;TransitionDefaultMinimumObjectSizeis read back UNCONDITIONALLY. The first cut filtered outvaries_by_storage_classas "the account default" and review caught the polarity inverted — AWS defaults buckets created after September 2024 toall_storage_classes_128K, so the filter suppressed the one value a template meaningfully declares and a bucket declaring it would have reported permanent phantom drift. It also made the live assertion vacuous (it asserted the value AWS returns by default, so it passed with the write removed); the fixture now declares the NON-default value. Measured: S3's write-evidence residual 98 -> 81 and the never-written count 20 -> 0, so the pinned test is INVERTED from "these 20 are missing" into a fence asserting the set is EMPTY, backed by a second test naming the 20 members as written. Only 13 of the 20 cleared the bucket; the other 7 are written and still unresolvable at the audited CHAIN for the same structural reasons as the surviving 81 (a renamed CFn segment, an SDK-only wrapper segment, the request-hoisted member). Deferred, with the reason measured rather than predicted: declaringAWS::S3::Bucket'ssegmentRenamesand opting the target into the write pass is issue #1520 — S3 stays out for a purely structural reason now, with no known silent drop left behind it. - ✅
gen-nested-key-coverage: the SPREAD-AND-PATCH forwarder is recognized, optingAWS::CloudFront::Distributioninto the write-evidence pass at 0 findings (issue #1475) —scripts/gen-nested-key-coverage.ts(the fourth recognizer + the CloudFront target opt-in + twoTags.*allow-list entries),tests/unit/scripts/gen-nested-key-coverage.test.ts,docs/_generated/nested-key-coverage.{json,md}regenerated. The shape:CloudFrontDistributionProvider.convertToSdkFormatseedsconst result = { ...config }off the tainted property-bag parameter and patches ~30 named members around it; the genericity test (correctly, by its own rule) rejects a member-naming callee, so all 162 of CloudFront's would-besame-spellingpaths were unmeasurable — recorded as reason (D) since #1445. The recognizer: a literal spreading a BAG-DERIVED seed registers its write path as a hand-off scope, BOUNDED by a per-scope exclusion set (ProviderWriteEvidence.handoffExclusions) — the first-segment keys the function subsequentlydeletes off the binding, resolved through theObject.entries(TABLE)rename-loop / literal-array shapes; an unresolvable delete key refuses the whole registration fail-closed, a wholly-reassigned binding is refused for the builder's reason, anddeliversWholeBlobnow refuses a deleted-from binding so the previously-full spread-only forward hands over to the bounded path. Measured: CloudFront 162 -> 0 (160 spread/scope-covered;Tags.Key/Tags.Valueallow-listed withpasses: ['write']— genuinely written bytoSdkTags, one SDK{ Items: Tag[] }wrapper level below the CFn transparent-array chain); every other target unmoved (the recognizer is monotone); tree residual 260 -> 98. Fences, proven on real code: deleting theIPV6Enabled -> IsIPV6Enabledrename entry still exits 1 (key pass,case-divergence); insertingdelete result['Aliases']exits 1 naming the path (the delete exclusion live); an unresolvabledelete result[dynamicKey]exits 1 (fail-closed refusal); replacing the seed with{}exits 1 via theminHandoffPointscollapse floor. Bounds recorded as (9) in the script header: an OVERWRITTEN member stays credited through the spread (the issue's "patches only rename / wrap" model), the exclusion is first-segment wholesale, and the spread delivers the seed's spelling verbatim. - ✅ GlobalTable: a serialized
UpdateTable/DeleteTableno longer races a still-transitioning INDEX (issue #1521) —src/provisioning/providers/dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-roundtrip.test.ts. The gap: the provider must serialize itsUpdateTablecalls, and it waited onTableStatusbetween them — but a table is ACTIVE while one of its indexes is stillCREATING/UPDATING/DELETING, and AWS then rejects the next call withAttempt to change a resource which is still in use: Index is being updated. ThePAY_PER_REQUEST -> PROVISIONEDflip that ALSO drops a GSI is the shape that hits it: AWS applies the flip to every index, and cdkd issued the index delete while the index was still absorbing it. Worse, the failed deploy's owncdkd destroythen hit the same rule fromDeleteTable(Cannot delete table while indexes are being created, updated, or deleted) and left the table BEHIND — a billed orphan the destroy reported as an error but could not clear. Found by the #1511 fixture work in this same session, and attributed properly rather than assumed: thegsi-billing-flipstep failed 3 runs out of 4 on 2026-08-10, and a run from a pristineorigin/mainworktree failed identically — so it is pre-existing and timing-sensitive, not a regression from the branch that surfaced it. The change:waitForTableActiveAfterUpdatenow also requires that no index is in a transitional state, read off the SAMEDescribeTableresponse it already fetched (no extra API traffic), anddelete()waits for the indexes to settle beforeDeleteTable— gated on what the pre-delete describe already reported, so a table with nothing in flight makes exactly the calls it made before. The predicate tests the three TRANSITIONAL statuses rather than!== 'ACTIVE': an absent or unrecognized status must not park a deploy for the full cap on a table that is fine. Both waits stay best-effort past their cap (a large index backfill legitimately outlives any timeout, and AWS's own error remains the backstop) while the table-status half keeps its hard error. 3 unit cases, each verified to fail against a reverse-patched tree — including the no-extra-call one, since the first draft added aDescribeTableper wait and broke 20 call-sequence assertions elsewhere in the suite, which is what surfaced the cheaper design. - ✅ An unresolvable PROVISIONED capacity on
AWS::DynamoDB::GlobalTablewarns instead of silently deploying the 5/5 default (issue #1511) —src/provisioning/providers/dynamodb-globaltable-provider.ts,tests/unit/provisioning/dynamodb-globaltable-provider-throughput-cluster.test.ts. The gap: #1444 item A moved the unresolved-value diagnostic into the translation layer, but only for the ON-DEMAND members. The PROVISIONED side has the same input class with a worse outcome:derive{Read,Write}CapacityUnitsrun every value throughtoFiniteNumber, so a present-but-unresolvable member (an unresolved{Ref: …}/Fn::If,'', an object) returnsundefinedand both the table-level and per-GSI translations fall through toDEFAULT_CAPACITY_UNITS— a table the template explicitly sized is deployed at 5/5 with no diagnostic anywhere, i.e. a throttling symptom days later rather than an error. It was recorded as a won't-do on #1503 because the correct behavior needed its own decision: the on-demand side can SUPPRESS (send nothing, keep the live ceiling), which a provisioned table cannot — AWS requiresProvisionedThroughputon the table and on every GSI of one. The change: warn-and-default. A DECLARED-but-unreadable member now produces aThroughputDiagnosticon the #1503 channel naming the member and the substituted default, from the three places the value is actually consumed —derivePerCallProvisionedThroughput(create, and thePAY_PER_REQUEST -> PROVISIONEDflip), the per-GSI PROVISIONED branch oftoSdkGlobalSecondaryIndexes, andtoSdkReplicaThroughputOverrides(whose message reports SUPPRESSION instead of a default, because an absentProvisionedThroughputOverridemeans "inherit the source table"). The blamed member is re-derived in the same order the derivation consults members, so a flip namesSeedCapacitywhile a create namesMinCapacity(#1435), and a block that is ITSELF unusable is blamed as a whole. Deliberately silent, each pinned by a test: an ABSENT block (the pre-existing default-to-5 contract for a template that never asked for a capacity — warning would shout on every deploy of a fine table); a member an explicit SDK-shapedProvisionedThroughputstands in for (that value IS what reaches AWS); the table-level value while the billing mode holds still (nothing sends a table-levelProvisionedThroughputoutside the flip, so no default is substituted); and the whole PREVIOUS side, which comes from cdkd STATE — the #1428 asymmetry, since a value an older binary recorded there is not editable from the template. Emitted BEFORE the first mutating call on both paths, so a detectable defect never gets reported after a real (billable) table exists. 15 unit cases; every positive one verified to FAIL against a reverse-patched tree, and the real-AWS half is a dedicateddynamodb-globaltablefixture table whose READ capacity is anFn::Joinover the region pseudo-parameter — it survives synth (an all-literal join is constant-folded and then rejected by aws-cdk-lib's L1 validator, and anaddPropertyOverrideof the block hits the same validator) and resolves at DEPLOY time tous-east-1-x, so verify.sh asserts the warning fires AND that the table lands on read=5 (cdkd's default) with write=1 (the template's valid sibling). Review fixes folded in before merge: the no-flip silence case was pinned by a test that called the GSI translator with a table-level block it never reads — vacuous for ANY implementation, now driven throughupdate()and verified to fail when half the gate is deleted; the per-GSI diagnostic on a billing flip now uses the'seed'source step 4 actually reads, so it stops namingMinCapacityfor a value the failing call never consulted; theThroughputDiagnosticJSDoc no longer claimsunresolved-memberalways means "nothing was sent"; andderivePerCallProvisionedThroughputgained theArray.isArrayguard its sibling translator already had, since it now routes a user-facing message. - ✅
cdkd driftno longer reports an IAM principal's ARN and itsAROA…unique id as drift (issue #1515) — newsrc/analyzer/drift-principal-normalize.ts,src/cli/commands/drift.ts,tests/unit/analyzer/drift-principal-normalize.test.ts,tests/unit/cli/drift.test.ts,.claude/rules/code-layout.md. The gap: AWS renders an IAM role / user principal inside a resource policy in two forms — the ARN, and the principal's unique id (AROA…/AIDA…) — substituting the unique id when the principal is transiently unresolvable at write time and re-rendering the ARN once it resolves. cdkd's deploy-timeobservedPropertiescapture stores whichever form the read happened to return, so a latercdkd driftcompared two spellings of ONE principal onAWS::S3::BucketPolicy'sPolicyDocument.Statement[].Principal.AWSand reported drift;--revertwrote the recorded form back, reported success, AWS re-canonicalized on write, and the NEXT run reported the identical difference — permanent phantom drift, the #1096 / #1498 class on a different property. It is a RACE, not a deterministic bug: the CDKautoDeleteObjectscustom-resource role is created concurrently with the bucket policy referencing it, which is why two back-to-backdrift-revertfixture runs on 2026-08-10 (09:26 clean, ~12:5x FAILing) disagreed with no relevant code change in between. The change: the issue's direction 1 — canonicalize the principal on BOTH comparison sides, in a new pass alongsidedrift-normalize.ts. Unlike the order-normalization passes there this one CANNOT be pure, since the mapping between the two forms is not derivable from the string: the shape detection is pure (walking onlyPrincipal/NotPrincipalpositions, where the forms are interchangeable — a unique id in aConditionvalue is left alone, where they are not) and oneiam:GetRole/GetUserlookup goes through an injected resolver thatdrift.tssupplies and caches per ARN for the whole command. Direction 2 (declaring the path ingetDriftUnknownPaths) was rejected for the reason the issue gives anddrift-normalize.ts's header already argues: it would trade a visible false positive for a silent false negative on real policy-principal changes. Bounded so it can only ever remove a PROVEN-equal difference: a pair is collapsed only when the lookup shows the ARN's unique id is the very one present on the other side, so a deleted role (exactly the case where AWS keeps the unique id forever), a cross-account principal, or a missingiam:GetRolepermission all leave both sides untouched and the drift REPORTED. Both sides are rewritten because the race is symmetric, and the pass short-circuits with NO AWS call unless a unique id AND a candidate ARN are both present — so a policy without one costs nothing. Known bound recorded in the module header: a policy carried as a JSON STRING rather than a parsed object is not walked, since re-serializing it could manufacture a different phantom drift. The one way this pass could still have hidden a real change, found in review and closed before merge:GetRole/GetUserare ACCOUNT-LOCAL and take a NAME, not an ARN, soarn:aws:iam::<other-acct>:role/Fooandarn:aws:iam::<self>:role/prod/Fooboth resolve to this account's root-pathFoo— the pass would then have "proven" two DIFFERENT principals equal and collapsed genuine drift. The resolver now round-trips the response ARN against the requested one (zero extra API cost) and treats a mismatch as unresolved, i.e. drift reported. Review also added a one-shot WARN when the lookup is denied (otherwise a missingiam:GetRolesurfaces as unexplained permanent drift, visible only under--verbose), folded the two collection walks into one, and recorded that--acceptnow stores the canonicalized ARN form for a subtree that drifts for some other reason. 26 unit cases across the pure pass and the wired command — including the resolves-to-a-different-entity guard, theGetUser/AIDAbranch,parseIamPrincipalArn's IAM-path and non-commercial-partition handling, and the one-lookup-for-two-resources cache — plus the real-AWS fixture steps, all verified to FAIL against a reverse-patched tree. - ✅ The TOP-LEVEL
properties['X'] ?? 'default'defaulting sites (issue #1513) —src/provisioning/config-shape.ts,src/provisioning/providers/{apigateway,ec2,iam-access-key,lambda-event-invoke-config,rds-dbproxy-targetgroup}-provider.ts,tests/unit/provisioning/config-shape.test.ts, newtests/unit/provisioning/toplevel-config-defaults.test.ts,.claude/rules/{providers,code-layout}.md. The gap: #1493 rolledreadConfigStringacross the NESTED-container??sites and deliberately left the top-level reads alone, because the container there is the provider's own property bag — rule 2 cannot fire, only rules 3/4 apply, and those turn a present-but-non-string value into a hard refusal. What still bit: anull/ mis-nested / unresolved-intrinsic VALUE silently became a default the template never asked for, and atAWS::ApiGateway::Method AuthorizationTypethat default isNONE, i.e. a PUBLIC method. The change:requireConfigStringtakes an optionalConfigStringOptions, and each site was decided on its own rather than swept.coerceNumberstringifies a finite number where an unquoted YAML scalar is a legitimate shape (IpProtocol: -1,Qualifier: 1— both deploy fine today, so refusing them would break working templates) and stays OFF at the enum-valued sites (InstanceType: 5is still refused).onUnusablewarns and defaults instead of throwing, used at UPDATE-path sites ONLY:rollback-executor.tsreplays a rollback viaprovider.update(..., previousState.properties, ...), so the desired bag there can be a historical cdkd STATE record and a refusal would make the resource UN-ROLLBACKABLE with no template-side remedy (theupdate-refusal-breaks-rollback-replaylesson, applied pre-emptively rather than caught in review). Guarded on create: API GatewayAuthorizationType, EC2InstanceType/ EIPDomain/ SecurityGroupIngressIpProtocol, IAM access-keyStatus, Lambda event-invokeQualifier, RDS DB-proxyTargetGroupName; the latter three also warn on update. Left unguarded, with the reason in-code rather than invisible:EC2Provider.buildIpPermission'sIpProtocol— textually a top-level read, but the helper is also reached fromdeleteSecurityGroupIngressand from the REVOKE half of the inline-rule update diff, both carrying STATE-borne rules, so a guard there would break destroy and rollback; the create path is guarded at its own call site instead, which refuses before the helper is ever reached (pinned by a test that deletes a rule whose recordedIpProtocolisnull). Everydelete()/readCurrentStateread stays unguarded for the same state-side reason. Left open by this PR and closed by the follow-up below: the GlobalTableBillingModesite, whose file was owned by a parallel lane. - ✅
drift --revertpreserves AWS-service-authored tags (issue #1501) —src/cli/commands/drift.ts,tests/unit/cli/drift.test.ts,docs/cli-reference.md,.claude/rules/code-layout.md. The gap:buildRevertNewPropertiesoverwrites each drifted top-level key wholesale, so forTagsAWS ends up with exactly what cdkd state recorded. ECS attachesAmazonECSManagedto an ASG when a capacity provider binds it, and that tag is REQUIRED for managed scaling — post-revert the ASG kept the capacity provider with its managed scaling silently broken (verified live 2026-08-10 on the #1498 verify stack). Neither existing carve-out covered it:Tagsis template-DECLARED (any CDK ASG carries aNametag) so #1498's undeclared-and-captured-empty rule correctly does not apply, and the tag list is an ARRAY, which #1478's dropped-AWS-key walk compares wholesale and never descends into. The change: on a drifted top-level tag list (shape-detected as a non-empty array whose every element has a stringKey), an AWS-side entry the baseline lacks is kept IFF its key is service-managed —AmazonECSManagedor anyaws:-reserved prefix, which cdkd can never have authored since AWS rejects a write of one. Everything else is unchanged: a baseline tag AWS lost is re-added, a changed value is reset, and an ordinary console-added tag is still REMOVED. The--revertplan names each preserved key before the confirmation prompt. Why option 2 and not the issue's option 1, settled by a live test rather than by taste: option 1 (diff the WHOLE tag list, so ANY out-of-band add survives) was implemented first and FAILED thedrift-revertinteg at its final assertion — that fixture injects anIntegInjectedtag and requires--revertto strip it, i.e. "revert removes a console-added tag" is an established contract with a test behind it. Redefining revert semantics for a whole property class is exactly the design pass the issue said option 1 needed, so the narrow safeguard shipped instead. 11 unit cases, the refusal ones verified to FAIL against a reverse-patched tree, including the bound itself (an ordinary console-added tag is still stripped) and controls that a non-tag list and an EMPTY baseline still revert wholesale. - ✅ The
??spelling of the malformed-container defaulting class, plus the twoAuthTypesites the #1471 sweep's grep missed (issue #1493 item 1) —src/provisioning/providers/{codebuild,dynamodb-globaltable,route53,cloudfront-oai,ecs,lambda-url}-provider.ts,src/provisioning/config-shape.ts(header only),.claude/rules/providers.md, unit tests per provider plus a newtests/unit/provisioning/dynamodb-globaltable-provider-stream-spec-shape.test.ts. The gap: #1471 / #1490 rolledreadConfigStringacross(cfg['K'] as string) || 'default';?? 'default'fires on the sameundefineda malformed container indexes to — and on an explicitnullbesides. Measuring it is where this goes wrong, which is the reusable part: the issue's own suggested grep\] \?\? 'finds ZERO real sites (the cast sits inside the parens), and the cast-word formas [A-Za-z]+\) \?\? '(20 hits inproviders/) is blind to everyas string | undefined/ quoted-union / line-wrapped site — four of which this change rolls. The change: the 9 sites that INDEX A NESTED CONTAINER now go throughreadConfigString— CodeBuildSource/SecondarySources[]/Artifacts/SecondaryArtifacts[]/Environment.{Type,ComputeType}(a stringSource: 'GITHUB'built a NO_SOURCE project; a stringEnvironmentsilently downsized the compute type), DynamoDB GlobalTableStreamSpecification(a string container invented aNEW_AND_OLD_IMAGESstream), Route 53HostedZoneConfigon BOTH paths (create dropped the comment, update WIPED the live one —UpdateHostedZoneCommentis a whole-value write), CloudFront OAI config on both paths, and ECSDeploymentController(a string container claimed the ECS rolling-update controller, under which cdkd sends parameters AWS rejects for a CODE_DEPLOY / EXTERNAL service).mapSource/mapArtifactstake acontainerPathso a refusal names the right block. TwoAWS::Lambda::UrlAuthTypesites are the #1471 sweep's OWN residual — its grep keyed onas stringand that site casts toFunctionUrlAuthType, so a blank / null AuthType kept defaulting to'NONE', i.e. a PUBLIC function URL — and now userequireConfigString. Defects the 3-axis review and its fix-back re-review caught before merge, all now fenced by tests: the guards behind a TRUTHINESS gate (if (!source),if (streamSpecInput)) were skipped entirely by a FALSY malformed container, soSource: ''still built a NO_SOURCE project — the gates are!= nullnow, which is what.claude/rules/providers.mdalready required; and four sites threw the helper's plainErroroutside any wrapping catch (CloudFront OAI create + update, ECS update, GlobalTable create), escaping untyped into the deploy engine's retry loop instead of surfacing as aProvisioningError. A re-review of the fix-back itself then found the same class TWICE MORE, one level up: CodeBuild'sSecondarySources/SecondaryArtifactsARRAY containers were still truthiness-gated (a blank string was silently dropped; a truthy non-array died with a rawTypeError: .map is not a function), now guarded by a newrequireConfigArray; and the GlobalTableStreamSpecificationread on the UPDATE path was still unguarded while create refused the identical template, sendingStreamViewType: undefined— the exact create/update asymmetry the rule exists to prevent. Not rolled, recorded inconfig-shape.ts's header rather than left invisible: the TOP-LEVELproperties['X'] ?? 'default'reads (rule 2 cannot fire on the provider's own bag, and refusing a present non-string is a stricter-value decision with its own regression surface — an unquoted YAMLIpProtocol: -1is a NUMBER today; issue #1513); nested reads whose value is an identity key or a warning label and never reaches AWS; EC2's twoVpcId ?? ''attribute-cache reads (a guard would throw AFTER a successful create and orphan the security group); and every previous-side / state-side read. Items 2 and 3 of #1493 stay open — both live ins3-bucket-provider.ts, which issue #1495's lane owns. - ✅
gen-nested-key-coverage: the write-scope index can see the BUILDER idiom, unblocking the CloudWatch AnomalyDetector opt-in (issue #1474) —scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,docs/_generated/nested-key-coverage.{json,md}regenerated,.claude/rules/providers.md,.claude/rules/code-layout.md. Nosrc/**change — pure tooling / docs. The gap: the write-evidence pass indexes members written inside an object LITERAL, and a provider that assembles a sub-blob by MUTATION defeats that —const mapped: AnomalyDetectorConfiguration = {}; mapped.MetricTimezone = …; mapped.ExcludedTimeRanges = ranges.map(…); params.Configuration = mapped;names every member it delivers, per member, on the forward path, butresolveLiteralsresolvesmappedto the EMPTY seed and stops, soConfigurationscoped to nothing and all three of its children reportedno-write-evidence. That was a FALSE POSITIVE, and it is what keptAWS::CloudWatch::AnomalyDetectorout of the pass;S3BucketProvideruses the same idiom inapplyWebsiteConfiguration/applyObjectLockConfiguration/applyReplicationConfiguration. The change:collectWriteEvidencegainedresolveBuilders+walkBuilderAt, crediting a builder's assigned members at the SAME path a literal's own members get, at full depth (out.Rule.DefaultRetention = { Mode }opens the intermediate scopes rather than flattening onto the builder). A BUILDER is a local binding whose INITIALIZER is an object literal (empty or partial), populated afterwards byout.Foo = …/out['Foo'] = …assignments onto THAT BINDING, and reaching a write. Three things keep it from becoming a rubber stamp: the literal initializer (the object's identity has to be this file's —const out = makeThing(),let out;seeded later, and a binding REASSIGNED as a whole are all refused); DECLARATION IDENTITY rather than the bare name for every assignment (known bound (3) deliberately not inherited); and the credit bounded to the BUILDER, never the enclosing scope — the trap #1445's review proved load-bearing when whole-scope credit would have hidden ECS'sContainerPortRange. Declaration identity required fixingdeclarationOfitself, and until the review caught it the claim was false in the recognizer's own code: that helper searched the nearest FUNCTION scope, descended fully into nested functions and returned the FIRST textual match, so bound (3)'s bare-name weakness reached INSIDE a single function — two same-namedconst cfgbuilders in differentifarms collapsed onto one declaration and merged their member sets (each vouching for the other's blob, the false-CLEAR direction), and aconst cfgin a nested arrow declared textually first captured the enclosing function's owncfg, inverting both verdicts at once.const/letare BLOCK-scoped, sodeclarationOfnow resolves outward through block scopes; it cannot under-resolve a valid binding, since a reference outside the declaring block is a compile error, and the tree measures identically before and after. The sibling-METHOD case had a test from the start and always passed — only the intra-function shapes were broken, which is why the existing test was not evidence. Delivery stays the CALLER's question: the builder walk runs only fromrecordAt, i.e. only where a write takes the value, and those sites are already filtered byfeedsOnlyComparison/isComparisonOnlyLiteral, so a builder never handed to a write — or handed only to a diff — is never reached. Measured, not predicted: the recognizer is MONOTONE (it only adds scoped members) so no target gained a finding;AWS::CloudWatch::AnomalyDetectorwent 3 -> 0 and is now opted in (minWrittenMembers: 12/minWriteScopes: 2/minHandoffPoints: 1, measured against 17 / 4 / 4, the last deliberately below its yield for the reason recorded on the API Gateway v2 floors),AWS::S3::Bucket125 -> 98, tree total 290 -> 260. S3 stayed OUT, with the reason re-measured rather than predicted: its 98 split into 78 written-somewhere-but-not-at-the-audited-chain (CFn->SDK segment renames likeWebsiteConfiguration.RoutingRules.RedirectRule-> the SDK'sRedirect, plus plural-vs-singular per-item PUT APIs —segmentRenameswork) and 20 never written anywhere in the file, i.e. confirmed-looking write-side silent drops: theLoggingConfiguration.TargetObjectKeyFormatfamily, the fourReplicationConfiguration.Rules.Destinationblocks (AccessControlTranslation/EncryptionConfiguration/Metrics/ReplicationTime),Rules.SourceSelectionCriteria,LifecycleConfiguration.TransitionDefaultMinimumObjectSizeandBucketEncryption…BlockedEncryptionTypes— filed as issue #1495. No allow-list entry was added for any of it; an entry reading "delivered by a builder" would make the bucket stop meaning anything. Bound (4) is WIDENED by the recognizer, and recorded rather than left to be discovered: a reverse SDK->CFn helper NOT namedreadCurrentState*that uses the builder idiom previously contributed only its empty seed and now contributes a fully populated SCOPE (s3-bucket-provider.ts'sreadLifecycle, whoseconst out = {}is filled with CFn-spelledout['Id']/out['Status'];ecs-provider.ts'svolumesToCfn). Inert today — S3 is not opted in and ECS islower-first, so a CFn-spelled terminal misses the exact compare — but S3 isexact-style, where a CFn-spelled reverse write vouches for the forward mapper verbatim, so wideningREVERSE_MAP_FUNCTION_PREFIXESto a suffix match belongs to the S3 opt-in (#1495) where its effect on the LITERAL set can be measured on the target it affects.minWriteScopesstays 2 against a yield of 4, MEASURED rather than rounded: all three states were counted on this provider — recognizer working 4, recognizer collapsed 2, recognizer working with one real write deleted 2. A collapse and a genuine provider regression are the SAME number here (the recognizer's whole contribution is one assignment, which opens two scopes), so a floor of 3 fires on both and would turn "the provider stopped writingExcludedTimeRanges" into "parser regression?" — the miscalibrationminHandoffPointsalready records for API Gateway v2. A recognizer collapse is not silent at 2: it re-surfaces the three paths asno-write-evidence, failing the shipped--check, the opt-in-table test and both real-code probes. Tests: 24 new cases — one per BUILDER shape the recognizer claims (empty seed, partial seed,out['X'] =element access, spread-into-return, early-return arms,?:arms, multi-segment assignment chains), one per shape it REFUSES (never delivered, diff-only, same-named binding in another METHOD and in the SAME function, nested-arrow shadowing, enclosing-scope sibling, computed key, nestedreadCurrentState*helper with a non-reverse-named control that proves the branch load-bearing), the five bound-(8) pins (non-literal initializer, late-seededlet, whole-binding reassignment,Object.definePropertyonto a builder, flow-insensitivity) each paired with its ACCEPTED twin so an empty credit cannot pass on a broken harness, the S3 reason-(C) never-written pin, and FOUR REAL-CODE probes through the--providers-dir=seam against a scratch copy — two at the library level asserting the finding set and two spawned through the shipped CLI asserting the process EXIT CODE (deletingmapped.ExcludedTimeRanges = …exits 1 naming all three paths; deleting the hand-namedEndTime: toDate(r['EndTime'])inside the value that assignment carries exits 1 naming ONLYConfiguration.ExcludedTimeRanges.EndTime, the precision half proving the builder credit does not blanket the value it delivers). - ✅
cdkd drift: template-undeclared keys captured EMPTY in the observed baseline are no longer compared, so sibling-populated parent properties stop phantom-drifting and--revertstops stripping them (issue #1498) —src/analyzer/drift-calculator.ts(undeclaredEmptyObservedKeys),src/cli/commands/drift.ts,tests/unit/analyzer/drift-calculator.test.ts,tests/unit/cli/drift.test.ts,docs/cli-reference.md,docs/state-management.md. The bug:observedPropertiesis captured per-resource right after THAT resource settles — structurally BEFORE dependent sibling resources run — so a parent key a sibling resource type materializes later (AWS::ECS::ClusterCapacityProviderAssociations->Cluster.CapacityProviders, a standaloneAWS::AutoScaling::LifecycleHook-> the ASG'sLifecycleHookSpecificationList, standalone SG ingress/egress rules) was captured empty and later populated: permanent phantom drift on a fresh, untouched stack, anddrift --revertthen live-verifiedly DETACHED the capacity provider, deleted ALL lifecycle hooks (including the cdkd-managed sibling still in state), and stripped ECS's requiredAmazonECSManagedtag while printing✓ reverted. CFn drift only compares template-declared properties, so the class was cdkd-only. The fix skips top-level observed-baseline keys that are BOTH template-undeclared AND captured empty ([]/{}/null) by feeding them into the comparator's existingignorePaths; an undeclared key captured with a REAL value (an AWS-side default) stays compared, preserving the observed baseline's extra detection power over CFn. Live A/B on the issue's ECS capacity-provider repro (us-east-1): old binarydriftexit 1 with 2 phantom resources; fixed binary reports only the CFn-parityTagsdelta, and--revert's blast radius shrank to that declared key — capacity provider attached, hooks intact. The declared-Tagsresidual (revert strips the service-authoredAmazonECSManagedentry) is split out as issue #1501. - ✅
AWS::DynamoDB::GlobalTable: the throughput-translation cluster — the table-level READ ceiling is wired at all, explicit SDK-shaped blocks are merged instead of forwarded, the unresolved-value diagnostic reaches every case, andreadCurrentStatestops emitting SDK-shaped GSIs (issues #1436 / #1428 / #1444 / #1420 / #1434 / #1421) —src/provisioning/providers/dynamodb-globaltable-provider.ts+tests/unit/provisioning/dynamodb-globaltable-provider-throughput-cluster.test.ts+tests/integration/dynamodb-globaltable/. Five issues in one PR because they all land in the same translation layer and several only make sense against each other. #1436 (drop on the way IN, the widest of the five):AWS::DynamoDB::GlobalTablecarries the table's on-demand READ ceiling on the LOCAL REPLICA (Replicas[?Region==<deploy region>].ReadOnDemandThroughputSettings) — the mirror of the per-GSI split #1387 established — and the provider read only the top-level WRITE half, so the plain CDK spellingBilling.onDemand({maxReadRequestUnits: 100, maxWriteRequestUnits: 200})deployed a table with NO read ceiling and reported success. The symptom is a surprise bill or an unthrottled read spike, never an error, and the repo's own verbatim-cdk synthfixture already carried the value with no assertion over it. Both halves now go into oneOnDemandThroughputonCreateTable/UpdateTable, non-local replicas get{Provisioned,OnDemand}ThroughputOverrideon their{Create,Update}ReplicationGroupMemberAction, andreadCurrentStatereverse-maps the read half back onto the local replica entry. The comparison baseline is AWS's OBSERVED ceiling rather than the state record — the same AWS-aware diff theDeletionProtectionEnabledblock uses, and for a sharper reason: a table deployed before this fix has the value in cdkd state (state stores template intent) while AWS never received it, so a state-vs-state comparison reads “unchanged” and the drop would never be repaired. Reading the live value converges such a table on the FIRST post-fix deploy. A PROVISIONED -> PAY_PER_REQUEST flip DEFERS the declared ceilings to their ownUpdateTableafter step 4's flip (review catch on this PR: step 3 runs first, so sending them there hit a still-provisioned table and 400'd, making the flip template repeatedly undeployable — the per-GSI path already had the post-flip shape for free via step 6), andreadCurrentStatesynthesizes the LOCALReplicasentry whenDescribeTableomits the member (a single-region GlobalTable returns none, but the CFn schema requires the local entry — without it every local-replica-homed member, the new read ceilings AND the pre-existing per-replica PITR / Tags reverse maps, had no drift home for the common case). #1434's read half rides along: removing the ceiling now resets it with the same live-verified-1sentinel, per member, so dropping the read ceiling leaves the write sibling untouched. The REPLICA-level overrides still have NO reset arm and that stays a probe-informed decision rather than an omission —-1is stored literally there and the empty-block form wedged a table inUPDATINGfor over an hour — so a dropped replica override now WARNS that the old value is still in effect instead of guessing a third payload. #1428 (three defects, fixing any subset leaves a live one): an explicitly SDK-shapedProvisionedThroughput/OnDemandThroughput/*ThroughputOverridewas forwarded VERBATIM, which skipped the ONE coercion path (a stringly-typed CFn"5"reached the SDK unnormalized), let a PARTIAL explicit block SUPPRESS valid derived siblings (an explicit{MaxReadRequestUnits: 41}discarded a perfectly goodWriteOnDemandThroughputSettingsfrom the same template), and fired regardless of billing mode (a provisioned block on a PAY_PER_REQUEST table, which AWS rejects).mergeExplicitThroughputBlocknow coerces with a tightenednumber | stringpredicate (bareNumber()maps[]→0,true→1, and[]additionally flipped the presence gate ON), merges PER MEMBER over the derived value, and the caller gates on billing mode. It WARNS AND FALLS BACK, never throws: the issue records three earlier designs and each was broken by the same lever — the helper also runs onpreviousProperties, so a garbage value already in STATE made every subsequentupdate()throw including the deploy that would have removed it, and a throw from the replica path fired AFTERCreateTablehad committed a real table (whichDeletionProtectionEnabled: truethen refuses to delete, orphaning it). Diagnostics are collected by the pure translators and reported by the caller, which is what makes the required desired/previous ASYMMETRY expressible — only desired-side call sites pass a collector — and they are emitted BEFORE the first mutating call, including a pure pre-flight pass over the cross-region replica blocks.collectRawOnDemandDeclarationsbecame the UNION of the explicit and derived sources to match, since an explicit block no longer wins outright. #1444 item A: the unresolved-value diagnostic lived in the per-GSI reset gate, which runs only for an index the diff already classifiedmodified, only when a previous ceiling happened to exist, and only when the billing mode held still — so the SAME template defect was silent on a FIRST-TIME set (both translated sides lack the member, so the index is notmodifiedat all) and across a BILLING FLIP. It now fires fromtoSdkGlobalSecondaryIndexes, where the value is actually lost, and the gate's own warning is removed rather than duplicated. Item D was already folded into PR #1443. #1420:readCurrentStatewrote the raw SDKGlobalSecondaryIndexDescription[]into the CFn-shaped state key, socdkd driftcompared a CFn baseline against an SDK snapshot and reported PERMANENT phantom drift on any GlobalTable with a GSI — SDK-only members (IndexArn/IndexStatus/ItemCount/IndexSizeBytes) plus a completely different throughput model. The issue offered a stopgap (declare the subtree ingetDriftUnknownPaths) precisely because a correct map needed the per-index auto-scaling read, which was blocked on the registration gap; that gap shipped in #1419, so the correct map is built instead andgetDriftUnknownPathsstays EMPTY — the stopgap would have turned drift OFF for the entire GSI subtree.readAutoScalingSettingsis generalized to thedynamodb:index:*dimensions through the sharedautoScalingResourceId, so the read cannot spell theResourceIddifferently from the register / deregister calls. #1421 (integ): thePAY_PER_REQUEST -> PROVISIONEDflip on a table that HAS GSIs was asserted only against a mocked DynamoDB client — the two GSI fixtures are never mutated and the table the UPDATE flow does flip has no GSI — so if AWS rejected “GlobalSecondaryIndexUpdatesalongsideBillingModein oneUpdateTable” the unit suite stayed green and every such deploy failed in the field. A third fixture table plus agsi-billing-flipmode covers it, and drops one of its two indexes in the same phase to cover the second unverified sub-path (an index this deploy REMOVES is still live at flip time, since itsDeleteis issued later, so it too must carry throughput in the flip call); the surviving index and the table are pinned onto theirSeedCapacityrather thanMinCapacity, the one context AWS documents the seed for (#1435). Tests: 29 new units across the five issues plus new integ steps 4b/4d/13d/13e. Three PRE-EXISTING units were INVERTED rather than deleted, because they pinned the behavior this PR changes — whole-block replacement by an explicit block, an explicitProvisionedThroughputforwarded onto a PAY_PER_REQUEST table, and a present-but-unresolvable value forwarded raw (Number({Ref: …})isNaN) instead of suppressed-and-reported; each carries the old rationale and why it inverted. - ✅
gen-nested-key-coverage: per-PATH fixture capture, so two same-named members inside ONE top-level stop vouching for each other (issue #1464) —scripts/refresh-cfn-schemas.mjs(+ its.d.mts),scripts/gen-nested-key-coverage.ts, 11 re-captured fixtures undertests/fixtures/cfn-schemas/,tests/unit/scripts/gen-nested-key-coverage.test.ts,.claude/rules/providers.md,.claude/rules/code-layout.md,docs/_generated/nested-key-coverage.{json,md}regenerated. Nosrc/**change — pure tooling / docs / fixtures. The gap: #1448 made the write-evidence pass path-scoped, but the FIXTURE stopped it one level short.nestedPropertiesis a flattened transitive closure per top-level property, soEnvironment.TypeandEnvironment.EnvironmentVariables[].Typewere literally the SAME audited path — measured then, deleting either one's write from a scratch copy of the realcodebuild-provider.tsexited 0, cleared by its cousin, and both were genuine silent drops. #1445 had since opted five more targets into the pass, which is what made the re-capture worth its cost. The fix, both sides:refresh-cfn-schemas.mjsgainedextractNestedPropertyPaths, emitting anestedPropertyPathscapture ALONGSIDE the flattened one — fullTop.A.Bchains,$ref-resolved, cycle-guarded per BRANCH rather than per top-level (a definition reached down two sibling branches must contribute to both, which the flattened capture's sharedseenRefsset collapses), arrays transparent soEnvironmentVariables.Namematches the write side'senvs.map((v) => ({ name: … })). Per-branch ancestry is combinatorial where the flattened walk was linear, so the capture is CAPPED (MAX_NESTED_PATHS_PER_PROPERTY5000 /MAX_NESTED_PATH_DEPTH12) with a NAMED throw: a diamond-shaped definition graph measured 786,430 paths at k=18, andprocessTyperuns the walk for every refreshed type.nestedKeyPathsForTargetreads the new capture;collectWriteEvidencekeys its scope index by the write PATH and descends, soenvironmentandenvironment.environmentVariablescarry differenttypefacts; and a WHOLE-BLOB HAND-OFF is recorded as a wildcard PATH credited by prefix (isHandoffCovered), replacing #1445's fold through the SDK model — strictly tighter, and free of that fold's bare-name union where anItemshand-off reached 217 unrelated CloudFront members.reachableSdkMemberNamessurvives only as theminHandoffPointsparser floor. Acceptance, spawned through the--providers-dir=seam against a scratch COPY of the real tree (neversrc/): deletingtype: ((environment?.['Type'] …))exits 1 namingAWS::CodeBuild::Project: Environment.Type [no-write-evidence]withEnvironment.EnvironmentVariables.Typeclean; deletingtype: (v.Type ?? 'PLAINTEXT')exits 1 namingEnvironment.EnvironmentVariables.TypewithEnvironment.Typeclean. Both exited 0 before. Segment SPELLING needs two mechanisms, and they are not interchangeable. A CASE difference on an intermediate segment is absorbed — the parent chain is matched case-insensitively (the terminal member never is: it is the only thing that proves delivery, in the scope test AND in the SELF case of the hand-off test), because CFnEFSVolumeConfigurationis SDKefsVolumeConfigurationand an exact parent match reported 16 membersecs-provider.tsdemonstrably writes. The fold is applied one LEVEL at a time while descending the index, never as a global lowercase union, which would merge the member sets of the 80 unrelatedname/Name-style scope pairs the same file carries. A genuine RENAME is out of the fold's reach: CFnProxyConfiguration.ProxyConfigurationPropertiesis the SDK'sProxyConfiguration.properties, which would have re-broken the ECS opt-in PR #1484 had just landed, so the target declares asegmentRenamesmap applied to NON-TERMINAL segments only. It is STALENESS-FENCED likeNESTED_KEY_ALLOW_LIST(findStaleSegmentRenames):--checkfails when the un-renamed chain starts resolving (the SDK renamed the member back) or the CFn segment disappears — but deliberately NOT when the provider merely stops writing the member, so the map can never mask the divergence it exists to expose. Allow-listing was refused; the entry would have silenced a CI-blocking bucket for a reason unrelated to delivery. Bound (2) —record()keying scopes by NAME — is half closed and half restated: a lexically nested write no longer opens a root-level scope (logsConfig: { cloudWatchLogs: { … } }stops producing a top-level-lookingcloudWatchLogs), and the suppression follows a.map(v => ({ … }))callback becauseresolveLiteralsdoes, while refusing to cross an opaqueJSON.stringify({ … })because nothing resolves that in the other direction. The UNION across write SITES at one path stays, because it is intrinsic — a key is cleared when ANY site covers it. Two known bounds were INVERTED into fences rather than deleted, so the closure cannot silently regress. Re-measured, not predicted: the audited unit grew 587 -> 703 paths across the 11 targets (CloudFront 121 -> 173, S3 155 -> 190, ECS TaskDefinition 121 -> 142, CodeBuild 93 -> 98, ECS Service 54 -> 56, CloudWatch AnomalyDetector 30 -> 31; the five API GW v2 targets are flat and did not move), so everyminNestedKeysfloor was re-calibrated — including two that sat exactly AT their measurement, the "every legitimate change is a false alarm" shape the band now forbids — as was ECS'sminWriteScopes(34 -> 58 non-empty scopes; floor 25 -> 40) and CodeBuild's (23 -> 32; floor 15 -> 20). The opt-in table's before/after re-measures to CodeBuild 0/95 -> 0/95, API GW v2 13/13 -> 0/13, ECS TaskDefinition 30/136 -> 0/136, ECS Service 45/56 -> 0/56, CloudWatch AnomalyDetector 30/30 -> 3/30, S3 130/152 -> 125/152, CloudFront 162/162 -> 162/162 (410 -> 290 total). Zero CI-blocking findings on the real tree —--checkstays at 0 divergences across all three passes with all EIGHT fresh-object targets opted in, and no allow-list entry was added. Eleven fixtures re-captured from the live CFn registry (cloudformation:DescribeType, targeted per type rather than the full ~135-type sweep); the only non-additive delta AWS itself introduced isAWS::ECS::Service'sDeploymentLifecycleHook.HookDetailsmovingscalar->mixedindefinitionShapes. Tests: 199 runtime cases, including the two inverted bound fences, theextractNestedPropertyPathswalker (chain, transparency, per-branch cycle guard, sibling-branch re-visit, the four container shapes the walk claims, the diamond cap, a one-directional consistency fence between the two committed captures, and an assertion that EVERY audited fixture carries the new one), the depth-separated scope index, the prefix-bounded hand-off credit with its verbatim-terminal half, the case-fold's positive AND negative direction, the rename map's four states (bridges / never touches the terminal / goes unused when renamed back / stays used when the provider breaks), and spawned--checkprobes for the two acceptance directions, the stale-rename exit and the missing-capture throw. - ✅
ResourceProvider.creategains an optionalCreateContext, so a provider pre-flight can tell a rollback STATE REPLAY apart from a fresh template provision (issue #1463) —src/types/resource.ts,src/provisioning/region-check.ts,src/deployment/rollback-executor.ts,src/provisioning/providers/glue-provider.ts,tests/unit/deployment/{rollback-executor,deploy-engine-replace,deploy-engine-named-replacement-collision,deploy-engine-recreate-via-sdk-provider}.test.ts,tests/unit/provisioning/glue-provider-roundtrip.test.ts,docs/{provider-development,supported-resources,troubleshooting,architecture}.md,.claude/rules/{providers,code-layout}.md,CLAUDE.md. The bug:replayRollback's reverse-replacement arm (issue #1199) revives the OLD physical resource by callingcreate()withpreviousState.properties— a cdkd STATE record, not a template — so a provider pre-flight refusal fired there and that rollback operation failed with the old resource never restored.replayRollbackcatches per-op so the rollback was not wedged, but the resource stayed gone and, unlike the update case, the user had NO template-side remedy: the offending value lives instate.json, editable only by hand. #1454 had already movedAWS::Glue::Table'sIcebergTableInputrefusal from create+update to create-only for exactly this reason on the update path (rollback-executor.tscallsprovider.update(..., previousState.properties)); the review of that fix found the create arm is a replay path too, and filed it rather than weakening the refusal. The fix (maintainer-decided approach: option 1 of the three the issue listed): an optional 4th parametercontext?: CreateContextonResourceProvider.create, mirroring theDeleteContextthatdelete()has taken since the region-check work — a shape the codebase already establishes rather than a new idea. It is defined insrc/types/resource.tsnext to theResourceProviderinterface that consumes it, NOT besideDeleteContextinregion-check.ts: that type earns its home there because itsexpectedRegionfeedsassertRegionMatch, whereasCreateContexthas no region-checking role, so symmetry alone would have put an unrelated type in a module named for something else (a review finding). A one-line pointer sits next toDeleteContextso a reader looking for one still finds the other. It carries one field,replayingState: true, and the executor sets it at BOTH reverse-replacement create arms (the create-first attempt AND the delete-new-first retry after a name collision) via a single sharedREPLAYING_STATE_CREATE_CONTEXTconstant so the two cannot drift. What the flag licenses is bounded on purpose and the contract says so at the type: a provider may conclude that the user has no template-side remedy and must therefore downgrade a PRE-FLIGHT REFUSAL to a warning; it may NOT conclude anything about the properties' content (they are neither more nor less trustworthy than template properties), treat the call as a dry run, relax a data-safety guard, or skip validation that protects the AWS call itself (a missing required field stays a hard error — warning past it would just move the failure later).GlueProvider.enforceIcebergTableInputAbsentis the only consumer today and warns on replay, reusing the existing shared message builder — a thirdcreate-replaylead clause, with the probe evidence and the working shape identical to the create refusal and the update warning, so the three cannot drift. The warning is deliberately HONEST about the outcome rather than promising a clean restore: unlike the update path (whoseUpdateTableCommandInputhas noOpenTableFormatInputmember at all, so nothing is forwarded), the create path DOES forward the blob, so the re-created table is degraded exactly as the original was — under the CFn spellingIcebergTableInputthe AWS SDK v3 serializer drops the unknown member (#1390's silent drop, which is what made these state records exist in the first place) and the table comes back without its Iceberg metadata; under the SDK spellingCreateIcebergTableInputthe value IS sent and Glue rejects the call, failing that one operation. Either way the message namescdkd deploywith the working shape as the fix-forward. That is strictly better than refusing, which guaranteed the resource was not restored. Every create call site was audited, not just the two changed. The fivedeploy-engine.tssites (CREATE, the property-driven replacement, the--recreate-via-*destroy-then-create, the--replacedelete-first fallback, the update-failure replacement) all pass freshly resolved TEMPLATE properties and deliberately pass no context — the refusal must stand where the user can edit the input. The five providers that re-create inside their ownupdate()(ACM certificate, IAM managed policy, IAM role, Lambda permission, SNS subscription) are a DIFFERENT case, and getting the reason right matters because it is the rule the next provider author reads: they forward the update'spropertiesargument, which IS a state record during a rollback replay (rollback-executor.ts'srevertarm, anddrift --revert), so they are NOT template-driven. The first draft of this entry and the accompanying JSDoc said they were — factually wrong, caught independently by two reviewers. The real constraint is the reviewer's framing, now recorded at the type, inrollback-executor.ts, indocs/provider-development.md§1a, and in.claude/rules/providers.md: those internal re-creates CANNOT receive a context, so a provider with a create-side pre-flight refusal must not re-create insideupdate(). There is no live gap — reviewers verified that all five (plusiam-access-keyandfsx-filesystem) validate required fields only, whichCreateContext's contract explicitly keeps as hard errors — so this is a constraint on the next provider, not a description of today's tree. Implementer audit: all 85implements ResourceProviderclasses walked. 84 declare the plain 3-parametercreate(4 of them with_-prefixed unused names) and are valid against an optional 4th;GlueProvideris the one that gained it. No overloads, no rest params, no class-propertycreate, noParameters<ResourceProvider['create']>extraction, no.length/arguments.lengthread, and no wrapper that forwardscreatewith a fixed argument list (withRetrytakes a zero-arg thunk, so it cannot drop arguments). The four TS-Compiler-API checkers were checked too:gen-handled-property-wiringandgen-nested-key-coverageseed their taint walks by parameter NAME (PROPERTY_BAG_PARAM_NAMES/HANDOFF_BAG_PARAM_NAMES), never by position, so a trailingcontextparam contributes no evidence and both matrices are byte-identical;gen-update-wrap-coverageandgen-sdk-attr-coveragenever inspect parameters. Tests: 8 added, 1 updated. The load-bearing one is that a reverse-replacement create of a Glue table whose STATE carriesIcebergTableInputnow SUCCEEDS with a warning and forwards the blob verbatim toCreateTableCommand— it throws without the fix, which is the whole point, and pinning the forwarded payload stops a future "just strip the key on replay" edit from silently changing what AWS receives. A 3-case table proves the template path still REFUSES with no context, with{ replayingState: false }, and with a context carrying no flag. On the executor side, a new test drives the collision path so BOTH create arms run and assertscall[3]on every one — the delete-new-first arm is the one a fix could plausibly miss, and missing it would leave the bug alive for exactly the collision case. One deliberate consequence of the shared-body assertion: thecreate-replaylead cites #1454 as well as #1463, so the existingexpectIcebergMessageBodyhelper keeps fencing all three modes. Three review findings hardened the rest. (1) The INVERSE fence was missing entirely, and it is the direction that matters most: a reviewer injected{ replayingState: true }at each deploy-engine replacement create site and all 9270 tests stayed green, so the paths this change's own JSDoc promises still refuse could have been silently downgraded. Three arity fences now pincreateto exactly 3 arguments on the--replacedelete-first fallback (both its collided create-first and its post-delete re-create), the--recreate-via-*destroy-then-create, and the update-failure replacement — assertingcall.length === 3rather than a value, so ANY 4th argument fails, including a future context carrying some other field. Verified by re-injecting the probe: exactly those three fences fail and nothing else does. (2) The replay warning's own CONTENT was unpinned — the test asserted only the shared body plus the phrase "REPLAYING a historical cdkd STATE record", and a reviewer rewrote the whole lead to one that falsely promises a clean restore ("...and the old table is restored") with the suite still passing 56/56. Since the entire point of warning instead of refusing is that the outcome is DEGRADED, the assertions now pin the substance clause by clause:#1390,without its Iceberg metadata,Glue rejects, and thecdkd deployfix-forward. (3) The replay path covered only the CFn spelling; it is now parameterized over both, matching the template-path coverage. Also:assertIcebergTableInputAbsentis renamedenforceIcebergTableInputAbsent(it can now warn and return, soassertcontradicted the behavior), thecreateTablecomment claiming the divergent member "is refused pre-flight just above" is corrected to describe what a replay actually forwards, and the helper's unusedphysicalIdparameter — dead since #1454, never passed by its single caller — is removed. - ✅ Route 53: destroy no longer fails while a hosted zone's AcceleratedRecovery transition is in flight (issue #1467) —
src/provisioning/providers/route53-provider.ts,src/deployment/retryable-errors.ts,tests/unit/provisioning/route53-provider.test.ts,tests/unit/deployment/retryable-errors.test.ts. While a zone'sHostedZoneFeatures.AcceleratedRecoveryStatusis transitioning (ENABLING / DISABLING /*_HOSTED_ZONE_LOCKED), Route 53 rejects EVERY mutation withHostedZone <id> is marked disabled for mutation. Record sets are deleted BEFORE the zone in the destroy DAG, so the zone-side pre-delete guard never ran first and acdkd destroyissued during the window exited with per-record errors plus the knock-on zone-delete failure ("contains non-required resource record sets"), state preserved.deleteRecordSetnow catches the mutation-disabled rejection, polls until the status settles to a MUTABLE state (ENABLED / DISABLED / zone gone — deliberately NOT disabling the feature, a record delete must not flip a zone-level setting;*_FAILEDand timeout surface as operator-recovery errors; same env-overridable poll knobs as the zone guard), then retries the change once. The message is also inRETRYABLE_ERROR_MESSAGE_PATTERNS(generic exponential cadence, not the dense IAM one) as the net for the create/update paths and sub-budget windows. Theroute53integ fixture deliberately keeps racing the ENABLING window (deploy assertsENABLING|ENABLED, then immediately destroys) — with this fix that race IS the regression coverage for the destroy path. - ✅ ECS: two fresh-object-mapper silent drops closed —
PortMappings[].ContainerPortRange(issue #1472) andLoadBalancers[].AdvancedConfiguration(issue #1473) —src/provisioning/providers/ecs-provider.ts,tests/unit/provisioning/ecs-provider.test.ts,tests/integration/ecs-fargate/(fixture + verify.sh now pin ContainerPortRange). Found mechanically by the #1445 whole-blob hand-off walk.convertPortMappingsbuilt a fresh SDKPortMappingnaming five members and never wrotecontainerPortRange, so a dynamic host-port range registered with the range silently dropped;convertLoadBalancersnever convertedAdvancedConfiguration, so an ECS blue/green service was created with its alternate target group / listener rules / role silently dropped. The port-range member is now written explicitly and the blue/green block rides the sharedpascalToCamelCaseKeyshand-off (future CFn additions inherit). With the #1445 walk merged, BOTH ECS targets opt into the write-evidence pass in the same change (freshObjectMapper: truewith measured floors — written 233 / scopes 34 / expanding hand-offs 21), the script-header table records TaskDefinition 25->0 and Service 44->0, and the pre-fix six-drops pinning test becomes a pair of real-code probes: stripping either fix from a scratch providers tree re-flags the exact pre-fix findings by name. - ✅
gen-nested-key-coverage: the write-evidence pass follows a WHOLE-BLOB HAND-OFF into a generic key converter, so five more targets can opt in (issue #1445) —scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,.claude/rules/providers.md,.claude/rules/code-layout.md,docs/_generated/nested-key-coverage.{json,md}regenerated. Nosrc/**change — pure tooling / docs. The gap: the write-evidence pass (#1432, path-scoped by #1448) required a per-member WRITE, and a provider that hands a whole sub-blob to a GENERIC key converter has none to find.ECSProvider.convertLinuxParametersis the clean demonstration —return pascalToCamelCaseKeys(config)deliversCapabilities/Devices/Tmpfs/Swappiness/MaxSwap/SharedMemorySize/InitProcessEnabledand everything beneath them, all correctly wired and all invisible. That is why onlyAWS::CodeBuild::Projectopted in, with 327 would-besame-spellingpaths across the other targets sitting UNMEASURABLE. The fix teachescollectWriteEvidencethe three-step walkgen-handled-property-wiring(#1404) does one level up: SEED — a value read off the DESIRED property bag (HANDOFF_BAG_PARAM_NAMES, DECLARATION-scoped taint); PROPAGATE — the seed handed WHOLE through?:/??/||arms,constbindings, spread-only literals, andthis.f(…)/ free-function / SIBLING-MODULE calls (pascalToCamelCaseKeyslives inagentcore-case-convert.ts, soloadReportsupplies a same-directoryresolveImportSource); DELIVERY — the callee RETURNS the blob and the value is written, withfeedsOnlyComparisonstill refusing a diff-only literal. The collector gainshandoffPoints, and the newexpandGenericHandoffScopesfolds each point into its scope by expanding it through the SDK model's own reference graph (reachableSdkMemberNames;collectSdkInterfacesnow captures the ARRAY ELEMENT ref too, sinceLinuxParameters.tmpfsisTmpfs[]). Getting the discrimination right was the whole job. A callee counts as GENERIC only when it names NO member — anywhere in its body OR in any callee it can reach — sopascalToCamelCaseKeys(whose only write is the computedresult[camelKey]) qualifies whileconvertContainerDefinitions(45 named members),convertLoadBalancers(4) and CloudFront's spread-and-patchconvertToSdkFormat(30+) are all rejected. The TRANSITIVE part is not tidiness: a body-local test accepts the DELEGATING GUARDconvertLog(cfg) { if (!cfg) return cfg; return this.buildLog(cfg); }, becauseconvertLognames nothing and the delivery test is existential — thereturn cfgarm satisfies it whilebuildLogdoes the member-naming work. Tightening delivery to.everyis not the alternative fix (pascalToCamelCaseKeysreturnsresult, a binding to an empty literal). The test means exactly "names no member" and NOT "can only emit keys it read": a FILTERING / RENAME-MAP / PICK converter names nothing and is still credited, which is recorded as known bound (5) and pinned by a test asserting today's behaviour, withglue-provider.ts'srenameRecordKeysnamed as the shape that is one opt-in away. The credit is bounded to the BLOB rather than the enclosing scope:ContainerDefinitionscarries both theLinuxParametershand-off andconvertPortMappings, so a scope-wide wildcard would have hidden a real drop. And the property-bag taint root is load-bearing rather than theoretical — an earlier revision without it treated CloudFront's disable-then-deleteDistributionConfig: config(a config read straight offGetDistributionConfigand re-sent) as a hand-off and cleared 108 of its 110 findings, the exact rubber stamp the issue warns about. Measured before/after (would-besame-spellingpaths with no scoped write evidence): CodeBuild 0/90 -> 0/90, API GW v2 13/13 -> 0/13, ECS TaskDefinition 25/115 -> 1/115, ECS Service 44/54 -> 5/54, CloudWatch AnomalyDetector 29/29 -> 3/29, S3 106/125 -> 104/125, CloudFront 110/112 -> 110/112 — 327 -> 223. The fiveAWS::ApiGatewayV2::*targets opt in at 0 findings (measured 46 written names / 1 non-empty scope / 3 BLOB-CARRYING hand-off points out of 35 raw). The newminHandoffPointsfloor counts EXPANDING points rather than raw ones — a raw floor would be satisfied by the 32 inert scalar forwards a collapsed walk still emits — and is set to 1 rather than the measured 3, because a floor AT the measurement turns every legitimate provider-side change into "parser regression?" instead of the divergences it causes (measured: at 3, all three hand-off probes stopped reaching the classifier), while the collapse mode the floor exists for yields 0.minWriteScopes: 1is recorded as fencing NOTHING on this target — its only non-empty scope isattributes, a cdkd-internalResourceCreateResultfield no audited path consults — rather than left looking meaningful. Two new hygiene tests keep both honest: one bands the write floors against their measured yields, the other derives "walk-dependent" by re-classifying with the hand-off points stripped and requires exactly those targets to declare the walk's floor (so CodeBuild, which reaches 0 without the walk, must NOT). The walk's first run found two REAL silent drops inecs-provider.ts, and they are why ECS stays out:PortMappings[].ContainerPortRange(SDKPortMapping.containerPortRange, never written — issue #1472) andLoadBalancers[].AdvancedConfigurationplus itsAlternateTargetGroupArn/ProductionListenerRule/TestListenerRule/RoleArnmembers, i.e. the whole ECS blue/green deployment block (issue #1473). 44 unmeasurable ECS Service paths collapsed to exactly those 5, which is the pass working; an allow-list entry there would have silenced a genuine bug, so both targets' opt-in is blocked on the provider fix and the six paths are pinned BY NAME in a test. The two remaining shapes are recorded with counts rather than papered over: the BUILDER idiom (const out: any = {}; out.Foo = …; return out;— the members ARE written, just not where the scope index looks; CloudWatch AnomalyDetector 3, most of S3's 104 — issue #1474) and the SPREAD-AND-PATCH forwarder (CloudFront's{ ...config }plus ~30 named patches, rejected by the genericity test on those names; 110 — issue #1475). Recognizing the spread is deliberately NOT done here: it would clear 110 of 112 paths in one step, which is the shape most likely to become a rubber stamp. Tests: 168it(blocks / 174 runtime cases (up from 131 / 137) — 18 synthetic hand-off cases (incl. the delegating guard, a computed-literal member name, the declaration-scoped taint collision that the real CloudFront file cannot fence because BOTH itsconfigbindings come from AWS responses, a sibling-module callee that NAMES members being refused, and the bound-(5) filtering converter pinned as credited) (verbatim forward, const binding, same-file converter, the REAL sibling-module converter with and without the resolver, member-naming and spread-and-patch rejections, non-bag and previous-bag rejections, comparison-only rejection, guard arms, seed-key registration, nested recording), 5reachableSdkMemberNames/expandGenericHandoffScopescases incl. the anti-wildcard assertion, 6 real-repo cases (theLinuxParametersmembers invisible BEFORE the fold and credited AFTER, the real ECS / API GW v2 hand-off point sets, the API GW v2 floors, the measured opt-in table reproduced exactly, and the six ECS drops named), and 6 newspawnSync--checkprobes against a scratch COPY of the real providers tree: bothDefaultRouteSettingsforwards deleted -> exit 1 naming all five members; ONE deleted -> exit 0 (which is how the union-across-write-sites bound gets recorded instead of discovered later); the forward turned into a PARTIAL hand-named literal -> exit 1 naming exactly the four members it stops naming and NOT the one it keeps; the forward routed through the realpascalToCamelCaseKeys-> exit 0; the same shape sourced from a non-bag value -> exit 1; and a collapsed walk -> exit 1 naming the walk rather than 13 bogus divergences. The #1448BuildBatchConfig.ServiceRole/BatchReportModefences are unchanged and still fire. NO AWS integ (offline static analysis). - ✅
AWS::Glue::Table/::Database: AWS-authoredParameterssurvive a full-replace update, and user removals still reach AWS (issue #1461) —src/provisioning/providers/glue-provider.ts,tests/unit/provisioning/glue-provider-roundtrip.test.ts,tests/integration/data-analytics/{lib/data-analytics-stack.ts,verify.sh},docs/supported-resources.md. The bug:updateTablebuiltTableInputpurely from the template and Glue'sUpdateTablereplacesTableInputWHOLESALE, so everyParametersentry AWS itself had written was erased by the first unrelated edit.Parametersis a general bag AWS writes into, and for an Apache Iceberg table the two entries it writes —table_type: ICEBERGandmetadata_location— are exactly what make the table READABLE as Iceberg by Athena / Spark / EMR. Verified live 2026-08-10 in us-east-1: after a fresh create both were populated; after a deploy changing ONLYTableInput.Description,aws glue get-table --query 'Table.Parameters'returnednulland the deploy reported success. The table silently degraded to a plain external table pointing at Iceberg data files. Same exposure for a crawler-writtenclassification,EXTERNAL,comment, and Lake Formation markers. This is the "absent-field removal on a full-replace update API" class (#1160's umbrella), but with AWS-AUTHORED rather than user-authored values — which is why no template-side diff hinted at it and why no unit test could have caught it. The fix reads the live resource immediately before the update (GetTable/GetDatabase) and merges back the AWS-authored entries — the read-merge-write the Cloud Control path gets for free. The merge rule is keyed on "present in NEITHER template side", not on "absent from the desired side": desired wins where the user declared the key; a key absent from desired but present inpreviousPropertieswas REMOVED BY THE USER and stays removed; a key present in neither is AWS-authored and is preserved. The naive form would have made user-authored parameters unremovable — the mirror image of the bug — and would have brokencdkd drift --revert's ability to clear a console-side addition. It keeps the provider on the repo's established clear-on-removal position (docs/provider-development.md§2a, issue #1155); no reset SENTINEL is needed because the API is full-replace, so omitting the key IS the reset.updateDatabasehad the identical shape and is fixed in the same change (DatabaseInput.Parameters— Lake Formation / federated-catalog markers). The other full-replace Glue updates were audited against the SDK models and do NOT have it:JobUpdate.DefaultArguments/NonOverridableArguments,ConnectionInput.ConnectionProperties,WorkflowUpdate.DefaultRunPropertiesandCrawler.Configurationare all user-authored bags AWS does not write into on its own, and preserving a console-side addition there would actively BREAKdrift --revert. The read fails CLOSED: onlyEntityNotFoundExceptiondegrades to "no live parameters" (soUpdateTable's own, more actionable error is not pre-empted); anything else throws aProvisioningErrornamingglue:GetTable/glue:GetDatabase, because silently skipping the merge on a throttle or a permission gap would reinstate the very erasure the read exists to prevent. The throw sits OUTSIDE the updatetryso the catch wrapper cannot re-label it, and the original error rides ascauseso a transient throttle stays retryable by the deploy engine's outerwithRetry. The read is also ordered AFTER the pre-flight validation, so an update refused for a malformed physical id or a missingTableInputissues noGet*at all. Tests: 14 unit cases (AWS-authored entries survive an unrelated edit; per-key removal; WHOLE-block removal, which a per-key test does not cover; a user-CHANGED value taking the user's side; a template that never declaredParameters; an empty live map leaving the payload byte-identical;drift --revertstill clearing a console addition, since that path passes the AWS-current snapshot aspreviousProperties;CatalogIdforwarding; the read not issued on a pre-flight refusal; not-found not masking the real error; the fail-closed throw asserted UNWRAPPED and with noUpdateTableissued; plus the Database twins). Six of them were confirmed to FAIL against a neutralized merge before being trusted. Real-AWS coverage — mandatory here, because the wiped values only exist on a live table: thedata-analyticsfixture gains an UPDATE phase (CDKD_TEST_UPDATE=true). The Iceberg table's ONLY phase-2 change is itsDescription, and verify.sh asserts that edit landed FIRST (otherwise the parameter assertions would pass vacuously against a table nothing updated) and then re-asserts BOTHtable_type == ICEBERGand a populateds3://metadata_locationthrough the same helper used after create. The removal half rides the sibling plaineventstable — phase 1 sets{classification, owner_team}, phase 2 dropsowner_team— so a user-parameter edit can never be confused with Glue's own Iceberg bookkeeping. 3-axis review added six things. (1) A rollback regression the fix would have introduced:rollback-executor.tscalledprovider.update()WITHOUTwithRetryat both itsrevertandrevert-failed-updatearms, unlikedeploy-engine.tsanddrift.ts— so the new pre-read made a throttle fail a rollback op that previously issued no read at all, and the best-effort catch counts that as a failure and moves on, leaving state unreverted. Both call sites are now retry-wrapped; a transient failure on a RECOVERY path is the worst place to add one. (2) A TOCTOU window that is data-affecting, not cosmetic: an Iceberg commit from Spark / Athena / EMR landing between the pre-read and theUpdateTablewould be UNDONE by writing back themetadata_locationcdkd read, pinning the table to an older snapshot — strictly worse than the bug being fixed.UpdateTableRequest.VersionId(whoseConcurrentModificationExceptionis a documentedUpdateTableerror) is now sent as an optimistic-concurrency precondition, with a dedicated error message naming the cause and the re-run remedy instead of a bare SDK exception. It is scoped to updates where the merge ACTUALLY carried live values: when the template declares every parameter, nothing read reaches AWS, so there is no stale value to write back — anddrift --revertis exactly that shape, deliberately overwriting whatever AWS holds, so guarding it would convert an intentional overwrite into a spurious failure.UpdateDatabaseRequesthas noVersionIdmember at all, so the database half keeps the exposure and says so in the docs rather than leaving it implicit. (3) Only the pre-read belongs outside the updatetry— hoistingbuildTableInput/buildDatabaseInputout with it turned aParameters: nulltemplate into a rawTypeErrorwith no resource context; the build + merge moved back inside (neither raises a control-flow class, soupdate-wrap-coveragestays green), and the null value is additionally coerced to "no declared parameters". (4)CatalogIdwas forwarded by the new readers but still not byreadTable/readDatabase— same file, same API, fixed on one side only, so on a cross-account Data Catalogcdkd driftread the account-default catalog and could report the resource gone;readCurrentState's already-declaredpropertiesargument now threads it. (5) Test + integ parity for the Database half, which had 3 unit cases to the Table's 11 and ZERO real-AWS coverage (the phase-2 template left the database untouched): the missing unit analogues landed (whole-block removal, never-declared-Parameters, empty-live, drift-revert, stringification, and theEntityNotFoundExceptiondegradation that mutation-testing proved was uncovered — deleting the line left 35/35 green), and the fixture's phase 2 now mutates the database too. Because an AWS-authored DATABASE parameter cannot be produced from a template (by definition the template never declares it, and a plain Glue database has no Iceberg-marker equivalent), verify.sh writes one OUT-OF-BAND between the phases viaaws glue update-database— indistinguishable to cdkd from any other entry absent from both template sides, which is precisely the merge branch under test. (6)verify.shpinned that AN update landed, not that it was IN-PLACE — a replacement would have satisfied both the Description and freshly-rewritten Iceberg markers, i.e. the exact failure the phase exists to detect dressed up as a pass;Table.CreateTimeis now captured before phase 2 and asserted equal after. Plus nits: optional chaining on the previously-unusedpreviousProperties, conditional-spreadCatalogIdonUpdateTableto match the readers,buildDatabaseInputstringifying its values likebuildTableInputalways did, the phase-specific#609/#1461diagnostics restored to the shared assertion helper, a deliberate (and commented)env -uasymmetry between phase 1 and phase 3, and test arms forparameterKeySet's array / non-object branch and an absent liveParametersfield. One test defect the new tests caught in themselves:mockLiveTable(params, undefined)silently kept the helper's'3'default (a JS default parameter fires on an explicitundefined), making the omit-VersionIdassertion vacuous — the helper now takes an explicitnullsentinel. The semantic price is now documented rather than implicit: a parameter added out-of-band is PERMANENT (preserved on every deploy) and INVISIBLE (post-deployreadCurrentStatefolds it intoobservedProperties, socdkd driftstops reporting it), with the removal paths spelled out;docs/provider-development.md§2b generalizes the price, the TOCTOU rule, and the audit-every-call-site-for-retry rule for the next full-replace provider. A delta re-review then caught three more. (1) The retry wrapper itself was wrong twice, because it did not carry the conventions of the call sites around it. It ignoredprovider.disableOuterRetry— whichCustomResourceProviderandNestedStackProviderBOTH set AND implementupdate()for — so rolling back a Custom Resource UPDATE would re-invoke it on a transient error, deriving a fresh RequestId + pre-signed response URL and stranding the first attempt's response at an S3 key nobody polls: exactly the hang the flag exists to prevent, reintroduced by a fix for an unrelated bug. It also droppedisInterrupted/onInterrupted, which this file's pre-existing wraps already thread, leaving Ctrl-C dead for the full ~47s backoff per op on a recovery path. Both are now routed through oneupdateWithRollbackRetryhelper so the three concerns (retry / opt-out / interrupt) cannot drift apart again, with tests for each. (2) TheVersionIdscoping was wrong and is removed. Sending the precondition only when the merge wrote back live values left the EMPTY-LIVE-READ case unguarded — that update still ships a wholesaleTableInputreplace, so a concurrent commit in the window is wiped, i.e. #1461 surviving its own fix — and its stated rationale did not hold either, because the pre-read runs milliseconds before the send on every update, so a pure template push (drift --revert, the case the scoping was meant to protect) carries a FRESH version too and could never have failed spuriously. It is now sent unconditionally whenever the read returned one, and theConcurrentModificationExceptionmessage is gated on whether a precondition was actually attached so it never claims cdkd refused a write AWS rejected on its own. (3) The guard's premise is now PROVEN against live AWS rather than assumed.UpdateTableRequest.VersionIdis documented only as "the version ID at which to update the table contents" — not explicitly as a precondition — so nothing showed a stale value is rejected rather than ignored, and an ignored token would make the whole guard a placebo that reads as protection in review. A new phase 2b in the fixture reads a table'sVersionId, advances it with an out-of-bandUpdateTable, replays the stale one, and requires AWS to refuse with a concurrency error specifically (any-error would let a shape or auth failure vouch for a guard that does not work); it fails loudly with instructions to REMOVE the guard if AWS accepts the stale write. It runs against the plaineventstable so a failed probe cannot leave the Iceberg fixture half-written. Plus two nits: thereplayFailedOperationstest block got the samebeforeEachmock-clear asreplayRollback(an inlinemockClearleft the documented cross-test leak in place), and both jq payload rebuilds switched from field allow-lists todel(<read-only members>)— an allow-list silently droppedCreateTableDefaultPermissions/TargetDatabase, reproducing inside the test the exact wholesale-replace data loss the test exists to detect. - ✅
gen-nested-key-coverage: the audited unit becomes a PATH and write evidence becomes SCOPED, so a multiply-written member stops vouching for its own siblings (issue #1448) —scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,.claude/rules/providers.md,.claude/rules/code-layout.md,docs/_generated/nested-key-coverage.{json,md}regenerated. Nosrc/**change — pure tooling / docs. The gap: the write-evidence pass #1432 added was sound for what it claimed, but its evidence was NAME-GLOBAL.collectWrittenMemberNamesreturned one flatSet<string>per provider FILE, so a member written ANYWHERE vouched for every CFn key with that spelling. Measured oncodebuild-provider.ts: 11 of the 55 same-spelling keys had more than one write site (Type9,Location5,Name5, andComputeType/EncryptionDisabled/SecurityGroupIds/ServiceRole/SourceIdentifier/SourceVersion/Status/Value2 each), so deleting the forward write of any one of them stayed silent.BuildBatchConfig.ServiceRolewas the sharpest case — the SIBLING of the member that motivated #1432, cleared by the unrelated top-levelserviceRole:write — which meant the pass really fenced 44 uniquely-named members, not all 55. The root cause was the flat KEY model, not the pass:nestedKeysForTargetyielded de-duplicated NAMES, so top-levelServiceRoleandBuildBatchConfig.ServiceRolewere literally the same audited key. The fix moves both sides. CFn side:nestedKeyPathsForTargetyields one audited unit per (handled top-level, nested key) pair — 488 names become 587 paths across the 11 targets — and every classification carriestopLevelProperty/terminalKeyalongside theTop.Keypath. Provider side:collectWriteEvidencereturns{ written, scopes }, where each written name is indexed to every member written BENEATH the value it is written with, resolvingthis.mapSource(source)calls,const/letbindings,?:/??arms, array elements, spreads and.map(cb)callbacks — the same reach as the #1404 taint walk, because CodeBuild'ssource: this.mapSource(source)and itslet buildBatchConfigdelivered as a shorthand property are both that shape. A path's terminal member is then checked against the scope its top-level maps to. Proved against real code, in both directions: deleting the forwardserviceRole:write from a scratch copy of the realcodebuild-provider.tsmakes the shipped--checkexit 1 withAWS::CodeBuild::Project: BuildBatchConfig.ServiceRole [no-write-evidence], while the name-global set still containsserviceRole— which is what shows the scoping, not an unrelated parse change, is doing the work. The seven smaller review items from the issue body (3) and its comment (4) landed too: a literal that only feeds a comparison or a measurement is not delivery (feedsOnlyComparison, the write-side twin ofgen-handled-property-wiring's diff-is-not-delivery rule); compound assignments (??=/||=/+=) andObject.defineProperty(sdk, 'x', {...})are recognized as writes, with the descriptor literal suppressed sovalue/get/setnever enter the set (valueIS a real CodeBuild member); allow-list entries gained apassesdimension (AllowPass=key|shape|write, defaulting to the deliberate #1378['key','shape']sharing) so an entry rationale'd for a SHAPE verdict can no longer silently clear a futureno-write-evidence, with PATH-first / terminal-name-second lookup and the matched key recorded on the verdict so staleness stays exact; and the reverse-map exclusion now applies tocollectStringLiteralsas well (REVERSE_MAP_FUNCTION_PREFIXES, renamed fromWRITE_EVIDENCE_EXCLUDED_*), removing the asymmetry where the write pass excludedreadCurrentStateand the key pass did not — measured free on the real tree (no key moves into a blocking bucket in either pass, whileBucketOwnerAccess/ResourceAccessRole/Valuestop being credited by the read path alone). A--providers-dir=test seam (mirroringgen-handled-property-wiring) letsspawnSyncdrive the SHIPPED--checkagainst a scratch COPY of the providers tree, which is the only way to reach the write-collector floors —loadReport's handledProperties throw precedes them — and is what closes the "the non-zero EXIT is not asserted" item. Floors gained a per-targetminWrittenMembers(CodeBuild 60, measured 82) and a per-targetminWriteScopes(CodeBuild 15, measured 23), because the name set and the scope index regress independently: a broken value walk leaves every name collected and every scope empty.minWriteScopesdeliberately has NO module-wide default — real-tree non-empty scope counts run 51 (S3) / 34 (ECS) / 23 (CodeBuild) / 21 (CloudFront) / 2 (CloudWatch) / 1 (API GW v2), so any shared floor tight enough to fence CodeBuild would throw "parser regression?" on a correct parse the moment a generic-converter target opts in — the miscalibration the #1449 review caught inMIN_WRITTEN_MEMBERS_PER_PROVIDER. The measured blind-spot table was re-run, not predicted: under scoped evidence the would-beno-write-evidencecounts are CodeBuild 0/90 (still free to opt in), API GW v2 13/13, ECS TaskDefinition 25/115, CloudWatch AnomalyDetector 29/29, ECS Service 44/54, S3 106/125, CloudFront 110/112 — 173 -> 327 in total, purely because an unrelated same-spelled write no longer vouches. Those remain #1445's generic-converter blind spot rather than silent drops. The bound is narrowed, not closed, and the script header now says so with measurements: because the fixture'snestedPropertiescapture is FLATTENED per top-level, a duplicate name inside the SAME top-level still vouches — deletingenvironment: { type: … }orsource: { type: … }from a scratch copy of the realcodebuild-provider.tsleaves--checkat exit 0, covered byenvironmentVariables[].typeandauth.typerespectively, and both are genuine silent drops. Scopes are also keyed by NAME and unioned across write sites, so two unrelatedenvironment: { … }literals share one scope. Closing either needs a per-PATH fixture capture (arefresh-cfn-schemas.mjschange plus an AWS re-capture of every fixture), which is out of scope here; both bounds are pinned by tests so they are recorded facts rather than surprises. Tests: 131it(blocks / 137 runtime cases in the file (up from 88 / 91) — 9 new synthetic collector cases (scoping, binding / call /.mapresolution, conditional + spread, depth, compound assignment,definePropertydescriptor suppression, comparison-only literals both directions), path-model + allow-listpasses+ path-vs-terminal lookup + staleness cases, per-SHAPE real-code anchors (the aggregatewritten.sizefloor cannot see a partial collapse), red-direction probes for both new floors, and 12spawnSync--checkprobes: exit 0 on the real tree, and exit 1 forBuildBatchConfig.ServiceRole,BuildBatchConfig.BatchReportMode, a collapsed write parse, and ONE PROBE PER remaining CI-blocking verdict (no-sdk-member,case-divergence,array-vs-wrapper,definition-member-missing, stale allow-list) plus the writer-mode guard. Review round 2 additionally: peeledawaitin the value walk and let the identifier search climb to the module scope (both fail-loud-on-correct-code directions), movedfilter/findout of the callback-returning set (their callback returns a predicate; the value comes from the receiver), restricted same-file callee resolution tothis.helper(…)/helper(…)so an unrelatedclient.mapSource(x)cannot borrow the provider's mapper, rejected--providers-dir=outside--check(it would have rendered the committed matrix from a scratch tree), re-calibrated everyminNestedKeysfloor to the path unit (CloudFront 100->110, S3 100->140, ECS TaskDefinition 50->105, CodeBuild 40->80, CloudWatch AnomalyDetector 5->25, ECS Service 30->45, API GW v2 Authorizer 0->2) with a hygiene test keeping each within 40% of its measured yield, and added a hygiene test forcing everyfreshObjectMappertarget to declare BOTH write floors — which is what makes the "declared per target" decision defensible rather than aspirational. Two shape floors that were vacuous were replaced with shape-UNIQUE real-code anchors (c['OriginKeepaliveTimeout'] = 5in CloudFront's forwardcompleteRequiredUpdateFieldsfor element access,this.client = new CodeBuildClient(…)for property access). Review round 3 then pinned the four round-2 walk changes synthetically — they are inert on the real tree, so nothing else fences them, and thethis.helper-only restriction guards a FALSE CLEAR on a CI-blocking bucket — rejected any unrecognized flag or positional argument (a--chekctypo, and the space form--providers-dir /tmp, both fell through to the WRITER path and rewrote the committed matrix while exiting 0;--helpnow prints usage), stopped the identifier climb at a PARAMETER of the nearest scope and stopped outer scopes from being descended into sibling functions (a parametercfghad resolved to an unrelated method'sconst cfg), and madeconcatdeliver its arguments as well as its receiver (receiver-only under-credits, which flags correct code). Two further bounds are now recorded rather than glossed: the reverse-map exclusion is PREFIX-only, so a suffix-namedvolumesToCfn/metricsSdkToCfnis not skipped (no live impact — the only opted-in target keeps its reverse map insidereadCurrentState), and the blind-spot counts are described as UNMEASURABLE rather than as confirmed non-drops. NO AWS integ (offline static analysis). - ✅
AWS::Glue::Table:IcebergTableInputis REFUSED at pre-flight instead of forwarded — a deliberate parity divergence (issue #1454) —src/provisioning/providers/glue-provider.ts,tests/unit/provisioning/glue-provider-roundtrip.test.ts,docs/supported-resources.md,docs/troubleshooting.md. The decision: a template whoseOpenTableFormatInput.IcebergInputcarries the nested table spec — under the CFn registry spellingIcebergTableInputOR the SDK spellingCreateIcebergTableInput— now fails BEFORE any AWS call oncreate(), with an error naming the offending property path, both AWS-side rejections verbatim, and the working shape spelled out well enough to copy.update()deliberately WARNS instead of refusing (a review finding): rollback replays from cdkd STATE rather than the template (rollback-executor.tscallsprovider.update(..., previousState.properties)), and a table created by a pre-#1390 build carries the key in its state record because the SDK serializer dropped it silently -- refusing on update would make such a table both un-updatable AND UN-ROLLBACKABLE, and the rollback half has no template-side remedy, only hand-editing state.json. Warning costs nothing there:UpdateTableCommandInputhas noOpenTableFormatInputmember, so update forwards nothing and no silent drop can reach AWS from that path. Both messages share one builder so they cannot drift. One rollback arm is still exposed and is filed rather than papered over:replayRollback's reverse-replacement path revives the OLD resource by callingcreate()withpreviousState.properties, which DOES hit the create refusal — that op fails individually (the executor catches per-op, so the rollback is not wedged) but the old table is not restored. Fixing it cleanly needs a way for a provider to tell a state REPLAY apart from a fresh template provision, which means aCreateContextonResourceProvider.create— a cross-cutting interface change, so it is issue #1463 rather than scope creep here. Why this is not cdkd inventing a validation: CloudFormation does not validate the property either — it forwards it and rolls the stack back — so this IS a parity divergence, and it is recorded as a conscious one in a JSDoc block at the check site. What justifies it is that no working deployment is refused. The live probe on #1408 (2026-08-09, us-east-1, 5 rawglue:CreateTableshapes + 5 CloudFormation stacks) showed the spec is undeployable on BOTH paths, and the raw-API half is the decisive one because cdkd callsglue:CreateTabledirectly rather than going through CloudFormation — "CFn also rejects it" alone would NOT have settled the question. Raw:Location information cannot be null while creating an iceberg tablewithout aTableInput.StorageDescriptor,Table metadata information present at multiple parts of input requestwith one; the spec's ownLocationis never read, so no combination exists. CFn: every variant rolls back withTable metadata is expected only via TableInput or via IcebergTableInputProperties inside OpenTableFormatInput— a property name in NEITHER the registry schema nor@aws-sdk/client-glue, i.e. an AWS-side three-way contract bug. The deployable shape (TableType: 'EXTERNAL_TABLE'+StorageDescriptor+IcebergInput{MetadataOperation: 'CREATE'}, optionallyVersion) is unaffected and keeps its real-AWS coverage in thedata-analyticsfixture (PR #1453). The #1390 rename is REMOVED, not merely bypassed:toSdkOpenTableFormatInputexisted only to rename the CFn spelling toCreateIcebergTableInputso the value reached Glue at all; the refusal makes that branch unreachable and untestable, so it is deleted and the create site now forwards the blob verbatim (every member of the deployable shape is spelled identically in CFn and the SDK). The knowledge it carried is preserved as an explicit WARNING in the check's JSDoc — if AWS ever ships a deployable shape and the refusal is relaxed, the rename MUST be restored in the same change, or #1390's silent drop (the SDK v3 serializer discarding the entire unknown-keyed spec whileCreateTablereports success) comes straight back. Tests: 11 in the round-trip file, replacing the 2 that pinned the now-removed rename. Three review findings shaped them. (1) The create/update tests originally could NOT detect the check being moved INSIDE thetry-- the catch wrapper EMBEDS the original message, so everytoContainstill matched andmockSendwas still un-called; the shared helper now pins PLACEMENT (message starts with the rawAWS::Glue::Table <id>:prefix, carries noFailed to create/update Glue Tablerelabel, and has nocause). (2)toEqualon the forwarded blob could not see a member dropped toundefined-- exactly the #1390 failure mode -- so it istoStrictEqual. (3) Only the OUTER non-object guard was covered; the inner one (IcebergInputitself unresolved) is the likelier real shape, so both levels x string/null/array are now a 6-case table that ALSO asserts the blob reaches AWS verbatim rather than merely that nothing threw. Plus: the SDK spellingCreateIcebergTableInput, the working shape forwardingMetadataOperationtoCreateTableCommand, the working shape accepted by update, the update-side WARNING (asserting the shared body plus the update-specific lead), anddelete()NOT refusing -- destroy of a table that somehow carries the key must stay possible, and hoisting the assert into the shareddeletedispatcher would otherwise break destroy with zero test failures. - ✅
AWS::DynamoDB::GlobalTable: per-index auto-scaling is registered,create()registers auto-scaling at all, and a fresh PROVISIONED table starts atMinCapacity(issues #1419 + #1435) —src/provisioning/providers/dynamodb-globaltable-provider.ts, a newtests/unit/provisioning/dynamodb-globaltable-provider-index-autoscaling.test.ts, and thetests/integration/dynamodb-globaltable/fixture. Shipped as ONE change because either half alone makes things worse, which is what #1435 spells out: creating atMinCapacitywhile no scaling policy exists would pin the table at min forever, strictly worse than the previous over-provision. The auto-scaling class (#1419): the provider registered application-autoscaling targets fordynamodb:table:*only. Nothing anywhere registereddynamodb:index:*, so a per-GSIwriteCapacity: Capacity.autoscaled(...)yielded a correct INITIAL capacity and then droppedMinCapacity/MaxCapacity/TargetTrackingScalingPolicyConfiguration— the index sat at its initial capacity forever. This read as "working" precisely because #1387's integ asserted that initial value and passed. Two sibling gaps had the same root:create()calledapplyAutoScalingDiffZERO times (onlyupdate()did), so a freshly created PROVISIONED table had no scaling policy until some later deploy happened to run an update; and the LOCAL replica's read dimension was never registered by ANY path, becauseupdate()'s replica loops allcontinueon the deploy region — only CROSS-REGION replicas ever got a read target. The fix generalizesapplyAutoScalingDiffto the four DynamoDB scalable dimensions (index targets useResourceId: table/<t>/index/<i>; the policy name keeps AWS's own<metricType>:<resourceId>convention so table-level names stay byte-identical and no already-deployed table orphans its existing policy), adds a purecollectAutoScalingTargets(properties, localRegion)that walks all four asymmetric CFn sources (write dimensions on the local region; read dimensions per-replica, incl. the local one), and reconciles them fromcreate()(all specs, after the table AND replicas are ACTIVE), from a newupdate()step 6b (after the GSI diff, so an added index exists and a dropped one is gone), and fromdelete()(index names read from the liveDescribeTable, not the possibly-stale template). A desired target is re-asserted even when the template did not change — a purely diff-gated register would never backfill the tables this issue is about: on anything deployed before this change the settings are byte-identical on both sides of every later deploy, so the dimension stays unregistered forever. Three review findings shaped how that is done. (1) The first cut confined step 6b to the two never-registered dimensions by a STATIC filter, handing table-level write and cross-region read back to their existing diff gates — which are exactly the gates the backfill argument says never fire, so those two dimensions were permanently excluded from the fix; 6b now covers all four, and double-application is avoided by a DYNAMIC skip-set of what earlier steps applied THIS deploy (a dimension whose gate declined is absent from the set and is therefore still backfilled). (2) Re-asserting everything on every deploy costs2 x (1 + N_gsi x (1 + N_replica))serial calls — over a hundred round trips on a 20-GSI, 3-replica table — so presence is probed first with one batchedDescribeScalableTargetsper region and an already-present, unchanged target is skipped; a failed probe means presence is unknown and everything is upserted, the correct direction to fail. (3)RegisterScalableTarget/PutScalingPolicynow carry a throttle-only retry: every error in this path is swallowed into a WARN, so an un-retriedThrottlingExceptionwould silently re-create the never-registered gap under exactly the burst this change introduces.
Three more review findings hardened the failure paths. The create-side registration is the LAST wiring step and is wrapped so it cannot throw: the partial-create cleanup deletes the table directly rather than routing through delete(), so a target registered before a later wiring step failed would be orphaned with no table left to name it — the leak class this issue exists to close, re-introduced on the failure path. The cross-region teardown reads index names from the TABLE's index list rather than the replica's own GlobalSecondaryIndexes, which AWS may omit for a replica that inherits throughput (its ProvisionedThroughputOverride is documented "if not described, uses the source table's"), leaking every index read target in that region. And a GSI added by the same deploy leaves the TABLE ACTIVE while the index is still CREATING, so step 6b waits for index readiness first — best-effort, since a missed registration self-heals on the next deploy but a throw would fail a deploy whose resources are all correct. The capacity class (#1435): deriveRead/WriteCapacityUnits ended in a fixed SeedCapacity ?? MinCapacity chain, but CloudFormation's precedence is context-dependent — live-verified against a real CFn stack (CdkdIssue1427Control, us-east-1), a TableV2 with MinCapacity: 1 / SeedCapacity: 20 reached CREATE_COMPLETE at WriteCapacityUnits: 1 on both table and index, with NumberOfDecreasesToday: 0 ruling out a scale-down. AWS documents SeedCapacity only for the billing-mode transition, and the registry schema marks Min/MaxCapacity Required: Yes against SeedCapacity's Required: No. So the helpers take a CapacitySource ('min' by default, 'seed' at the three PAY_PER_REQUEST -> PROVISIONED flip call sites and nowhere else); cdkd stops over-provisioning every autoscaled PROVISIONED GlobalTable by the seed-to-min ratio — a silent billing symptom, never an error. Tests: 14 new unit tests plus 5 reworked capacity-precedence ones; the three update() tests were mutation-proofed by disabling step 6b and confirming exactly those three fail. Integ: verify.sh gains step 4c (per-index scalable target + policy asserted against the BASELINE deploy, pinning the create-side half), step 12a (the local replica's read dimension), and step 16a2 (the index target is deregistered by destroy — application-autoscaling is a separate control plane, so DeleteTable alone leaves an orphan a future same-named table inherits), and its step 4b write expectation moved 3 -> 2 to match the CloudFormation semantics above.
- ✅
gen-nested-key-coveragegains a WRITE-EVIDENCE pass, sosame-spellingstops vouching for a fresh-object mapper (issue #1432) —scripts/gen-nested-key-coverage.ts,tests/unit/scripts/gen-nested-key-coverage.test.ts,.claude/rules/providers.md,docs/_generated/nested-key-coverage.{json,md}regenerated. The gap: the critic'ssame-spellingbucket is SILENT — the SDK model has a member at the derived spelling, so nothing is reported. That is sound for a provider that FORWARDS a config blob (the serializer carries the key through) and unsound for one that builds a FRESH SDK object naming each member, where an unnamed sub-key is dropped even though the spellings agree perfectly.AWS::CodeBuild::ProjectBuildBatchConfig.BatchReportModeis the measured case: CFn declares it,@aws-sdk/client-codebuilddeclaresbatchReportMode, andCodeBuildProvider.mapPropertiesrebuiltbuildBatchConfignaming only four of the five members. The critic stayed silent — and STILL did with every occurrence of the SDK spelling renamed away, which is what proved the gap structural rather than a tuning miss. So.claude/rules/providers.mdtelling contributors that "a type insideNESTED_KEY_TARGETScannot regress" was over-promising for the fresh-object shape, which is common (CodeBuildProvider,DynamoDBGlobalTableProvider,ECSProvider). The fix addsfreshObjectMappertoNestedKeyTarget. For a target that sets it, a would-besame-spellingkey must ALSO carry WRITE evidence — its SDK-side spelling appearing as a WRITTEN member name (batchReportMode: …,{ batchReportMode },sdk.batchReportMode = …,sdk['batchReportMode'] = …) — or it lands in the new CI-blockingno-write-evidencebucket. Requiring a WRITE rather than a mention is what scopes evidence to the CFn->SDK direction:readCurrentState's reverse map READS the SDK member, and a read is not a property-assignment name. That is not enough on its own for anexact-style target, where the two spellings are identical and the reverse map's CFn-spelled WRITE would vouch for the forward mapper — #1393 item 2 one bucket over — soWRITE_EVIDENCE_EXCLUDED_FUNCTION_PREFIXESskips reverse-map bodies by word-boundary PREFIX — exact-name matching missed the split reverse maps that really exist (readCurrentStateService/readCurrentStateTaskDefinition/ the sixreadCurrentState*methods inapigateway-provider.ts). Measured withdrawal: 8 names froms3-bucket-provider.ts, 71 fromcodebuild-provider.ts, 42 fromecs-provider.ts(0 under the earlier exact match). The pass is OPT-IN per target, and the opt-in set was measured rather than predicted. Would-be-same-spellingkeys with no write evidence: CodeBuild 0/55, CloudWatch AnomalyDetector 12/20, API GW v2 13/13, S3 19/89, ECS TaskDefinition 22/107, ECS Service 37/48, CloudFront 70/112. Those 173 are not silent drops — they are the pass's blind spot, a GENERIC key converter delivering a whole sub-blob with no member to find (ECSProvider.convertLinuxParametersisreturn pascalToCamelCaseKeys(config), deliveringCapabilities/Devices/Tmpfs/Swappinessat once). Enabling the pass tree-wide would bury a real finding under dozens of false ones, and an allow-list cannot fix it because every entry would read "delivered by a generic converter". CodeBuild measures at exactly 0, so it opts in today for free and the #1386 defect becomes non-regressing; teaching the pass to follow a whole-blob hand-off (the taint walkgen-handled-property-wiringalready does one level up) is issue #1445. Proved against real code, in both directions, per the repo checker rules: deleting ONLY the forwardbatchReportMode:write from the realcodebuild-provider.ts— leavingreadCurrentState'sbbc['BatchReportMode'] = project.buildBatchConfig.batchReportModeintact — makes--checkexit 1 namingBatchReportMode [no-write-evidence], while the SAME regression withfreshObjectMapperremoved exits 0, silent. A write-collector parse floor (MIN_WRITTEN_MEMBERS_PER_PROVIDER) turns a collapsed parse into one legible error instead of 55 bogus divergences. Tests: 22 new (12 collector shape / direction / exclusion probes — incl. the three false-credit shapes an independent review caught: an element-access VARIABLE key, a destructuring ASSIGNMENT at any nesting depth or in a for-of head, and the exact-vs-prefix exclusion miss; 7 synthetic classification cases incl. the loose-literal non-rescue and the lower-first write lookup; and 6 real-code probes — the A/B above, the unregressed-zero with a 50-key coverage floor, the measured withdrawal lists, and theBuildBatchConfig.ServiceRolecase that pins the name-global bound). Known bound, documented rather than papered over: write evidence is a flat per-FILE name set and the audited unit is a key NAME rather than a path, so a member written anywhere vouches for every key of that spelling — 11 of CodeBuild's 55 same-spelling keys have >1 write site, andBuildBatchConfig.ServiceRole(the SIBLING of the motivating member) stays silent when dropped. The pass therefore fences the 44 uniquely-named members. Moving the key model to paths is #1448. - ✅
AWS::DynamoDB::GlobalTable: the per-GSI on-demand reset stops firing on a present-but-unresolvable value (issue #1440) —src/provisioning/providers/dynamodb-globaltable-provider.ts+tests/unit/provisioning/dynamodb-globaltable-provider-gsi-throughput.test.ts. The class: the #1423 per-GSI reset decided "the template DROPPED this member" from the TRANSLATED side, andtoSdkGlobalSecondaryIndexesruns every value throughtoFiniteNumber— which returnsundefinedfor a present-but-unparseable value (an unresolved{Ref: …},'', an object). Such a GSI was therefore indistinguishable from a removal and was answered with the-1sentinel, silently CLEARING the ceiling the template was trying to SET. That is the same silent-wrong-action class the reset exists to remove, and the identical hazard was caught in self-review on the table-level path during #1434's PR (the gate there tests RAW key presence); this closes the per-GSI half, which #1434 deliberately left alone because it needed more than a one-line gate change. The fix addscollectRawOnDemandDeclarations(properties, region)besidetoSdkGlobalSecondaryIndexes— a per-IndexNamemap of RAW CFn key presence walking the SAME two sources the translation does (top-level GSI for the write half; the LOCAL replica's index entry for the read half, with the GSI-level spelling as the documented hand-authored fallback). Keeping the two functions adjacent is deliberate: they must agree on where each member lives, and a divergence between them is exactly what would re-open the bug. A missing entry means "not declared", which lets the reset FIRE — the DESTRUCTIVE direction, NOT a conservative one (the first draft's comment claimed the opposite and review caught it). Two different mechanisms keep that safe: a non-arrayGlobalSecondaryIndexesis refused loudly bytoSdkGlobalSecondaryIndexes, while a malformed per-ENTRY shape is deliberately passed through untouched but carries no usableIndexName, so the reset loop skips it before consulting the map. Be precise about what the guard buys — review probed the PR tip and disproved the obvious claim: it does NOT put the value back on a path that reaches AWS. The coercion happens upstream intoSdkGlobalSecondaryIndexes, so the unparseable value is already gone before the reset loop and NOTHING is sent for that member either way. What the guard removes is the DESTRUCTIVE half; what remains is a no-op — and a silent no-op is its own trap, since the only output was the immutable-field warning further down, which tells the user to RECREATE THE INDEX (useless and alarming advice for an unresolved intrinsic). The suppression therefore now WARNS, naming the member and saying the limit was left unchanged. Review also caught a source divergence that reintroduced the very bug: an already-SDK-shapedgsi['OnDemandThroughput']WINS over the derived members in the translation, so reading the derived spellings reported a member DECLARED that the translation never sends — an explicit{MaxReadRequestUnits: 50}beside a leftoverWriteOnDemandThroughputSettingssuppressed the write reset and left the old ceiling live, the #1160 silent drop re-created by the guard itself. The collector now derives both flags from the explicit block alone when present. A third review pass then folded in the BLOCK-level case: a block that is ITSELF an unresolved intrinsic ({"Fn::If": […]}) is a record with no member, so the member test reported "not declared" and fired the destructive reset — #1440 one level up from where the guard looked.isUnresolvedIntrinsicBlock(all keysRef/Fn::*) routes it into the same suppression + warning, while a genuine{}still counts as a removal. The same pass made the non-stringIndexNameskip EXPLICIT in the modified loop — such an entry had been safe only by a Map identity miss downstream, an accident rather than a guarantee. Tests: 9 units (unresolved intrinsic on the write half; on the read half via the replica entry; the GSI-level read fallback spelling counted as a declaration; the accurate warning; no misleading recreate-the-index advice alongside it; the explicit-block precedence; a block-level intrinsic treated as declared; a genuine empty block still treated as a removal; and a fence that a GENUINE removal still resets, so the guard narrows the reset rather than disabling it). Revert-proofed — neutering the raw-presence guard fails three, and neutering the explicit-block precedence fails its own test, while the #1423 fence stays green throughout. - ✅
AWS::DynamoDB::GlobalTable: the TABLE-level on-demand write ceiling resets when the template drops it; the two REPLICA overrides are proven un-resettable and deliberately left alone (issue #1434) —src/provisioning/providers/dynamodb-globaltable-provider.ts+tests/unit/provisioning/dynamodb-globaltable-provider-gsi-throughput.test.ts. The class: removingWriteOnDemandThroughputSettingsfrom a template never setflatChanged, so noUpdateTablewent out and the old ceiling stayed live in AWS while cdkd reported success — the absent-field-reset silent drop (#1160), the table-level sibling of the per-GSI case #1423 closed. The fix merges per member rather than branching on "the whole block disappeared" (the shape the #1433 review caught: a block can survive with its member dropped, which is the likelier user edit) and is SUPPRESSED while the billing mode is flipping, since dropping the on-demand block on the way to PROVISIONED is the natural template edit rather than a clear request, and step 4's flip owns that call. TheoldBilling/newBillingpair is now resolved ONCE above step 3 and reused by step 4, so the two sites cannot disagree about what "flipping" means. The issue asked for three fields; only ONE turned out to be implementable, and the live probes are what settled it (us-east-1 source + us-west-2 replica, one global table per probe, both torn down — transcripts on the issue). Table-level-1behaves exactly as the issue assumed:{MaxWriteRequestUnits: -1}was accepted andDescribeTablethen returned{MaxReadRequestUnits: 100}, i.e. the dropped member cleared and the untouched sibling preserved, with the reset reading back as ABSENCE never as -1 — verified independently rather than inherited from #1423, since reset semantics are field-specific. The two REPLICA overrides have NO reset mechanism at all:OnDemandThroughputOverride: {MaxReadRequestUnits: -1}is accepted but stored literally as -1 on readback (writing it would be strictly worse than the current no-op — a nonsense value instead of a stale-but-valid one); the documented "empty override means inherit the source table" form{}is accepted and then hangs the table inUPDATINGfor over an hour with the override unchanged and every later call returningResourceInUseException(it left a table AWS refused to delete for having "acted as a source region for new replica(s) ... in the last 24 hours" — treat{}as hazardous in any future probe); an entry carrying onlyIndexNameis rejected outright (ValidationException: There are no actions specified in the Replica Update Action).ProvisionedThroughputOverrideis the same story by construction — the registry schema declaresReadCapacityUnits/MaxReadRequestUnits"minimum": 1, and live-1/0both fail validation while{}returnsInternalServerError. So no in-band sentinel exists, and shipping any of them would trade a silent no-op for a live defect. Tests: 6 units covering the full removal, the PARTIAL removal, value-changed (never the sentinel), stringly-typed coercion, billing-flip suppression, and the no-change redeploy; revert-proofed — neutering the reset fails exactly the first two while the other four stay green, since they pin behavior the change does not alter. Related finding filed, not fixed here: the probes surfaced that the table-level on-demand READ ceiling (Replicas[local].ReadOnDemandThroughputSettings, i.e. the canonicalBilling.onDemand({maxReadRequestUnits})) was never wired AT ALL — dropped on the way in rather than merely un-reset, and the repo's own verbatim-cdk synthfixture already carried the value with no assertion over it (issue #1436, since fixed in the throughput-cluster entry above, which also gave that ceiling the reset arm this entry could only give the write half).
Recently Implemented (2026-08-09):
- ✅
AWS::S3::BucketjoinsNESTED_KEY_TARGETS, and the first run found theEventBridgeConfigurationboolean broken in BOTH directions (issue #1430) —scripts/gen-nested-key-coverage.ts(target + three allow-list entries),src/provisioning/providers/s3-bucket-provider.ts,tests/fixtures/cfn-schemas/AWS-S3-Bucket.json+AWS-S3-BucketPolicy.jsonre-captured,docs/_generated/nested-key-coverage.{json,md}regenerated, and a new unit-test file. Why the target was missing:.claude/rules/providers.mdrequires a provider that FORWARDS a nested CFn config blob to be a critic target, andS3BucketProviderforwards a dozen of them (lifecycle, CORS, replication, notifications, encryption, inventory, analytics, metrics, intelligent-tiering, object-lock, website routing) — but the type was never added, so the six #1388 / #1424 lifecycle defects were all found and fixed by hand in PR #1426. Adding it required re-capturing the schema fixture first: the storedAWS-S3-Bucket.jsonpredated thenestedProperties/definitionShapescapture the critic reads. The audit is now 115 nested keys (26 provider-handled conversions made visible, 3 allow-listed). What it would actually have caught was measured, not predicted: run against the REAL pre-#1426 provider it flagsTransition/NoncurrentVersionTransition/NoncurrentVersionExpirationInDays-- the legacy-singular defect. Issue #1430 predicted a different three and was wrong on all of them:TagFilters(16 literal sites pre-#1426) andTransitionInDays(2) were already named byreadCurrentState's reverse map, so the file-global literal heuristic calls themprovider-handledhowever broken the write path is (#1393 item 2), and rule-levelExpiredObjectDeleteMarkeris not shape-audited at all because@aws-sdk/client-s3spells the interfaceLifecycleRule, leaving CFn'sRuleinunmatchedDefinitions. The strip-probes therefore use only keys whose evidence a realistic single-site regression can actually remove, and each probe asserts the key really left the evidence set before asserting the bucket. The one live bug found, in both directions. CFn'sEventBridgeConfigurationcarries a REQUIRED booleanEventBridgeEnabled(that is exactly whatCfnBucketrenders), while the SDK'sEventBridgeConfigurationis an EMPTY structure whose PRESENCE enables delivery — so the boolean has no SDK member to land on and the critic bucketed itno-sdk-member. (1) Write side: the provider emitted the SDK block whenever the CFn block existed, so an explicitEventBridgeEnabled: falsesilently ENABLED EventBridge notifications — the inverse of the template's intent. It now emits the block only when the boolean is notfalse, coercing the stringly-typed CFn"false"too; an absent or unresolved value keeps the pre-change enable-on-presence behavior, so an unresolved intrinsic can never silently DISABLE something the template asked for. (2) Read side:readCurrentStatereturned the SDK's{}, but cdkd's state baseline holds the CFn spelling anddrift-calculatoronly descends into keys present in state — so the boolean read back as permanently missing on every EventBridge-enabled bucket. It now always emits{EventBridgeEnabled: <bool>}, matching the always-emit placeholder convention every other reader in this provider follows. Allow-listed, not fixed:TableName/TableArn/TableNamespaceare the FIRST real instance of the unreachable-definition false positiveclassifyTargetShapesdocuments — all three live only underMetadataConfiguration/MetadataTableConfiguration, which the provider declares as silent-drop, so those templates are auto-routed through Cloud Control and no SDK forwarding path exists to drop them. The entries name the top-levels to remove them under, and a stale entry fails in both modes. Tests: 9 units in a new file, revert-proofed — restoring either half of the pre-change behavior fails 4 of the 9, including a round-trip case pinning that the read shape is what the write side accepts (otherwise a drift REVERT would re-send a shape the write path misreads). Also corrected here: the #1431 changelog entry and.claude/rules/code-layout.mdboth still said CodeBuild was not a critic target, though #1431 added it in the same PR. - ✅ The two
handledPropertieswiring gaps the #1404 critic caught are closed by DECLARING the drop, not by faking a wire (issues #1411 / #1412) —src/provisioning/providers/ec2-provider.ts,src/provisioning/providers/logs-loggroup-provider.ts,scripts/gen-handled-property-wiring.ts(bothHANDLED_WIRING_ALLOW_LISTentries removed),src/provisioning/property-coverage.generated.ts+docs/_generated/handled-property-wiring.{json,md}regenerated,.claude/rules/code-layout.md, and thetests/integration/vpc-nat-gateway/fixture. Both issues proposed "wire it into create"; for #1411 that turned out to be IMPOSSIBLE, and re-deriving it changed the fix.AWS::EC2::NatGateway.MaxDrainDurationSecondsis not aCreateNatGatewayRequestmember (@aws-sdk/client-ec2models/models_1.d.ts), and EC2 ships noModifyNatGateway*operation at all — the ONLY two SDK inputs carrying the field areDisassociateNatGatewayAddressRequest(models_5) andUnassignPrivateNatGatewayAddressRequest(models_7), i.e. it is a per-call drain timeout for RELEASING secondary addresses, which is why the CFn registry schema lists it underwriteOnlyProperties. cdkd'supdateNatGatewayrejects every NatGateway property change, so it never issues those two calls and has nowhere to deliver the value. Modelling it as a replacement trigger (the fallback the issue suggested) was rejected too: the registry schema does NOT list it undercreateOnlyProperties, so recreating a gateway on a drain-timeout change would diverge from CloudFormation and needlessly break the data plane.AWS::Logs::LogGroup.ResourcePolicyDocumenttook option 2 of #1412 for the reason the issue itself gives — it maps to the separateAWS::Logs::ResourcePolicytype whoselogs:PutResourcePolicyis ACCOUNT-scoped, not per-log-group, so owning it from a log group's lifecycle would require inventing an ownership answer (which policy name to claim, what to do on delete when the account-wide policy may be shared, how to resolve two log groups declaring conflicting documents); managing the sibling resource remains the real feature. Both are nowunhandledByDesignwith rationales. Correcting a premise carried in both issues: neither provider setsdisableCcApiFallback, so this does NOT hard-reject such templates via the #614 viability guard — it fires the #614 AUTO-ROUTE, provisioning the resource through Cloud Control API where AWS's own handler applies the value. That is strictly better than both the silent drop and a reject. Note on existing stacks: routing stickiness iscc-api->cc-apiONLY (provider-registry.tsshort-circuits on that value alone), so a resource already recordedprovisionedBy: 'sdk'whose template sets one of these properties DOES re-evaluate and flip to Cloud Control on its next UPDATE —deploy-engine.tsdoes that deliberately. That is the intended outcome (the value starts being applied instead of dropped) and costs no physical-ID churn, since Cloud Control updates in place. Tests: 10 new units across two files (each asserts the property is absent fromhandledProperties, present inunhandledByDesignwith a rationale, reported byfindSilentDropProperties, and thatProviderRegistry.getProviderForroutes to CC for a template setting it and to SDK for one that does not), plus a guard thatCreateNatGatewaynever carries the field and a fence that neither retired property may return tohandledProperties;tests/unit/scripts/gen-handled-property-wiring.test.tsre-pointed its real-code stale-entry probes onto a still-live allow-list entry (IAMAccessKeyProvider#Serial). Revert-proofed: re-adding both properties tohandledPropertiesfails 6 of 10 and makesaudit:handled-property-wiring:checkfail naming both. The critic now reports 0 gaps, 2 allow-listed (down from 4). Integ: thevpc-nat-gatewayfixture gains a SECOND, L1-only private NAT gateway setting the property, so the L2 gateway stays on the SDK path and the two together assert heterogeneous routing in one stack. The value iswriteOnlyPropertiesand no EC2 API returns it, so a read-back-and-compare assertion is structurally impossible;verify.shasserts the routing consequence instead (provisionedBy == 'cc-api'on the drain gateway,'sdk'on the plain one, a vacuity guard grepping the literal out of the stack file so the two cannot drift, both gateways live andavailableon AWS, and both gone after destroy). - ✅
AWS::CodeBuild::Project: seven nestedSource/Environment/Cachesub-keys wired, plus theSourceIdentifiercreate failure (issue #1386) —src/provisioning/providers/codebuild-provider.ts, unit tests, and thetests/integration/ci-cd/fixture. The class:mapSource/mapPropertiesbuild FRESH SDK objects, so every CFn sub-key they do not name is dropped by the SDK serializer with the deploy still reporting success — the #1373 nested-key class, in a provider that the same PR brought under that critic'sNESTED_KEY_TARGETS. Wired (create AND update — both sharemapProperties, and the drop is WORSE on update becauseUpdateProjectis read-modify-write, so an unnamed sub-key was actively WIPED off a live project):Source.Auth.{Type,Resource}->auth,Source.GitSubmodulesConfig.FetchSubmodules->gitSubmodulesConfigandSource.BuildStatusConfig.{Context,TargetUrl}->buildStatusConfig(both synthesized by the CDK L2, so daily-pattern surfaces),Environment.Fleet.FleetArn->fleet,Environment.DockerServer.{ComputeType,SecurityGroupIds}->dockerServer,Cache.CacheNamespace->cacheNamespace. Bonus fix found while wiring:Source.SourceIdentifierwas never mapped at all, and AWS REQUIRES it on everySecondarySourcesentry — so any template using secondary sources failedCreateProjectoutright (a loud failure, not a silent drop, which is why #1386's static sweep missed it).SecondarySourcesroute through the samemapSourcehelper, so all four Source sub-keys cover them automatically (pinned by a test). Read side:readCurrentStatereverse-maps each newly-wired sub-key emit-when-present, so the drift baseline stays symmetric with the AWS-current snapshot — exceptSource.Auth, deliberately excluded becauseBatchGetProjectsechoes it back partially and emitting a partial shape would fire phantom drift on every project that sets it (documented in-code). Not mapped:Environment.HostKernelexists in the CFn registry schema but has NO member anywhere in the installed@aws-sdk/client-codebuilddist-typestree, so there is nothing to map it onto until an SDK bump; it is a NESTED key, so it cannot live inunhandledByDesign(top-level-only); it is recorded instead as aNESTED_KEY_ALLOW_LISTentry with the rationale, which the critic fails on once an SDK bump makes the member reachable.handledPropertiesis unchanged (all seven sit under the already-declared top-levelsSource/Environment/Cache), soproperty-coverage.generated.tscorrectly needed no regeneration. Verified against the authoritative schemas, not the issue text: every SDK member name and nesting read off@aws-sdk/client-codebuild'smodels_0.d.ts, every CFn spelling off the livecloudformation:DescribeTyperegistry schema (the repo's capturedAWS-CodeBuild-Project.jsonfixture stores top-level names only). Live-probed before the fixture was written — a CODEPIPELINE-typed source REJECTS both nested Source sub-blocks ("Git submodules config is not supported for CodePipeline source" / "Source type CODEPIPELINE does not support BuildStatusConfig"), so the pipeline-fed project cannot carry them; the fixture adds a SECOND standalone L1CfnProjecton a PUBLIC GitHub source, the cheapest shape that accepts both and needs no source credential.verify.shasserts all four covered values off a SINGLEbatch-get-projectsblob (so a throttle cannot make some assertions pass while others silently vanish) and gone-checks the new project after destroy.Environment.Fleet.FleetArn(paid reserved-capacity fleet),Environment.DockerServer(billable docker server) andSource.Auth(needs a connected source credential) are unit-pinned but deliberately not in the fixture. Tests: 7 new provider units, binding-proofed by reverting the real provider (5 of the 7 fail without the fix; the 2 that pass both ways are the intentional "omits the members when unset" regression guards). - ✅
AWS::DynamoDB::GlobalTable: GSI throughput translated to theCreateTableSDK shape, so a PROVISIONED GlobalTable with a GSI stops failing outright (issue #1387) —src/provisioning/providers/dynamodb-globaltable-provider.ts, a new unit-test file, and thetests/integration/dynamodb-globaltable/fixture. The class: the provider cast the CFnGlobalSecondaryIndexesblob RAW to the SDK'sGlobalSecondaryIndex[], but the two schemas model per-GSI throughput completely differently and the SDK v3 serializer silently drops unknown members — so a PROVISIONED-billing GlobalTable with a GSI failedCreateTableoutright (AWS requiresProvisionedThroughputon every GSI) and everyTableV2per-GSI on-demand limit vanished.TableV2is the recommended L2 since CDK 2.95, so this is a daily-pattern surface. The issue's own mapping table turned out to be materially incomplete, and re-deriving it from the authoritative schemas changed the fix twice. (1)WriteProvisionedThroughputSettingshas EXACTLY ONE member,WriteCapacityAutoScalingSettings— there is no literalWriteCapacityUnits, because write capacity on a GlobalTable is always auto-scaled;CreateTableneeds a concrete number, so the mapping takesSeedCapacity(the documented "initial provisioned capacity units") beforeMinCapacity. (SUPERSEDED by issue #1435 — see the entry above: the precedence is context-dependent, and CloudFormation creates atMinCapacity;SeedCapacityapplies only to thePAY_PER_REQUEST -> PROVISIONEDflip.) (2) Per-GSI READ capacity is not on the top-level GSI at all — CDK synthesizes it toReplicas[?Region==<deploy region>].GlobalSecondaryIndexes[].Read{Provisioned,OnDemand}ThroughputSettings, so the SDK's singleProvisionedThroughput/OnDemandThroughputobject has to be FUSED from both halves (the GSI-level spellings the schema also permits are honored as a fallback for hand-authored templates). Both facts came from the livecloudformation:DescribeTypeschema cross-checked against a realcdk synthunder both billing modes; the repo's captured fixture stores top-level names only and could not settle it. Call sites:create(),addReplica(), theupdate()replica-modify action, theupdate()GSI diff (both sides are now translated BEFORE diffing, so emittedCreate/Updateactions carry real throughput — a side benefit is that an auto-scaling-only edit, invisible to the DynamoDB API, no longer emits a bareUpdate: {IndexName}that AWS rejects as empty), and one site the issue did not name: thePAY_PER_REQUEST -> PROVISIONEDbilling-mode flip, where AWS requires per-indexProvisionedThroughputin the SAMEUpdateTablecall — without it the fix would have made create work while leaving the flip broken. Deliberately left unmapped, recorded in a JSDoc block rather than dropped silently (this provider has nounhandledByDesignmap):Replicas[].ReplicaStreamSpecification.ResourcePolicyandReplicas[].ResourcePolicy(both needPutResourcePolicy, not anyUpdateTablefield) andReplicas[].GlobalSecondaryIndexes[].ContributorInsightsSpecification(needs a per-indexUpdateContributorInsights).handledPropertiesis unchanged, so no coverage regeneration was needed. Three defects in the first cut were caught by review and fixed here, each mutation-proofed: (a) the billing flip built its index updates from the NEW template but filtered them by the PREVIOUS template's names, so an index the deploy REMOVES got no capacity — yet itsDeleteis issued later, so at flip time it is still a live index on a table becoming PROVISIONED and AWS rejects the whole call; (b) the first attempt at suppressing a false immutable-field warning skipped themodifiedloop wholesale on ANY flip, which silently dropped every per-GSIMax{Read,Write}RequestUnitson aPROVISIONED -> PAY_PER_REQUESTflip (the flip call carries per-GSI fields in one direction only) — the loop now sends the fields the NEW billing mode needs and suppresses only the warning; (c) a non-arrayGlobalSecondaryIndexes(an unresolved intrinsic) collapsed to[]and would have created the table with ZERO indexes while reporting success — the #1387 class one level up — and now throws.WarmThroughputalso rides the update-ADD path, so a GSI added later matches whatcreate()sends for the same template. Tests: 29 in a new file, with both property bags copied VERBATIM from a realcdk synthrather than hand-invented — which is precisely what surfaced the read-capacity-lives-on-the-replica asymmetry that a hand-written fixture would have encoded wrongly. Every call site and guard is mutation-proofed individually: reverting any one of them kills a specific test. Integ: the fixture gains two UNCONDITIONALTableV2s (not gated behindCDKD_TEST_UPDATE, so the baseline deploy exercises the previously-failing create path) — two tables because the billing modes cannot coexist on one. L2 was correct here rather than L1, sinceTableV2exposes every property needed.verify.shreads all four GSI values back plus the two table-level ones, and its step 3 stopped taking "the first GlobalTable in state" (which with three tables would have grabbed an arbitrary one) in favor of selecting by logical-id prefix. - ✅
handledPropertiesWIRING critic (gen-handled-property-wiring) + the two live silent drops its first run caught (issue #1404) —scripts/gen-handled-property-wiring.ts(new, FIFTH codegen'd critic),docs/_generated/handled-property-wiring.{json,md}(new matrix),vite.config.ts+.github/workflows/ci.yml(gen +--checksteps),.claude/rules/code-layout.md. Nosrc/**change — pure tooling / docs. The class:handledPropertiescan LIE.gen-property-coverageproves a property is ACCOUNTED FOR andgen-nested-key-coverageaudits spellings INSIDE a forwarded blob, but neither proves an entry is WIRED —ECRProviderdeclaredImageTagMutabilityExclusionFiltershandled while it reached NO API call, so the pre-flight passed on the declaration alone and the value silently vanished (#1392, fixed in #1406). The critic: per declared property it requires read evidence in one of four AST shapes (element-read/property-read/destructure/table-loop) plus an orthogonaldelegatedtag, CLASS-SCOPED via a taint walk seeded from each method's desired-state parameter and propagated only through calls that pass the bag WHOLE — so a sibling class in the same file, a comment, agetDriftUnknownPathsentry, the declaration itself, and areadCurrentStatewrite-back all fail to vouch (each pinned by a test). Two rules keeptable-loopfrom becoming a rubber stamp, since one syntactic site credits N properties at once: the loop body must DELIVER, not merely compare (EC2Provider.updateSubnet's createOnly guard is a change GUARD, and crediting it smuggled the diff-is-not-delivery disguise back in one level up — the rule withdrew the tag from 46 properties across 8 classes, NONE of which became a gap since all are also read individually;RDSDBProxyProvidershows the discrimination, its immutable-field loop losing credit while itsmutableFieldsloop keeps it), and the table is resolved LEXICALLY from the loop outward (a FILE-wide pool let a table local to one class's method vouch for a DIFFERENT class and let two same-named tables override last-wins —glue-provider.tsreally does declareresultx12). Each wired property recordsseededByso a property wired only from a non-delivery member is visible rather than silently green (0 today, fenced). Two strictness calls were forced by the REAL tree, not by fixtures: a whole-bag forward does NOT blanket-excuse un-read declarations (the first draft's excuse silenced the very #1392 property viahasCdkAutoDeleteTag(properties)indelete(); measured cost 0 of 1063), and apreviousPropertiesread is not evidence — with the in-code JSDoc stating honestly that this is NARROWER than it looks (it does not close the disguise for a singleelement-read, only for helpers reached with the previous bag alone; the TABLE case IS closed by the delivery rule). First-run audit (4 flagged): TWO real silent drops, seeded as tracked KNOWN GAP allow-list entries and FILED rather than fixed here —AWS::EC2::NatGateway.MaxDrainDurationSeconds(issue #1411) andAWS::Logs::LogGroup.ResourcePolicyDocument(issue #1412, already admitted in an in-code comment);IAMAccessKeyProvider#SerialandNestedStackProvider#TemplateURLare rationale'd NOT-A-BUG entries. Stale entries fail in both modes, so wiring a property forces its entry's removal. Proven against real code per the repo's checker rules: reverting the realecr-provider.tsto its pre-#1406 state exits 1 naming the property (a FIRST probe that stripped only the lowercase-preads PASSED — the survivingpreviousPropertiesread cleared it, and that false clean is what drove the exclusion); stripping the realVpcIdreads while leaving the comparison-only guard standing must now REJECT; dropping one name from the real Glue / SQS tables must surface a gap; a class appended to the realglue-provider.tsmust not borrowbuildJobCommonFields's local table. The shipped--checkis driven viaspawnSyncagainst a scratch COPY of the providers tree (--providers-dir=seam), so exit code and failure text are covered without ever writing tosrc/. Tests: 68 (shape units, per-SHAPE real-repo floors — 84 classes / 1063 declared / 43table-loopof which 29 sole-evidence — allow-list stale + per-property keying, and the real-code probe set). NO AWS integ (offline static analysis). - ✅
ModifyInstanceFleetneeds BOTH capacities, and every EMRInstanceTypeConfigsconversion site now has real-AWS coverage (issue #1400) —src/provisioning/providers/emr-instance-fleet-config-provider.ts(update()), new fixturetests/integration/emr-instance-fleets/,.claude/integ-coverage-allowlist.json(theAWS::EMR::InstanceFleetConfigentry removed — it now has an integ). The gap: both existing EMR fixtures are instance-GROUP based, and a cluster's instance-collection type is fixed at create (groups XOR fleets), so no fixture could exercise a FLEET. That left all threeInstanceTypeConfigsconversion sites —EMRClusterProvider.toInstanceFleetConfig(inlineCluster.Instances.{Master,Core}InstanceFleet),EMRInstanceFleetConfigProvider.create(AddInstanceFleet), and the same provider'sModifyInstanceFleetupdate — proven only by mocks. #1383 was precisely a send-side-looks-fine / AWS-silently-discards bug (CFnConfigurationPropertiesvs the SDK'sProperties), and a unit test can prove cdkd SENDS the block but never that EMR ACCEPTED it. The fixture: a fleet-based cluster (master + core inline fleets,Ec2SubnetIdsplural —Ec2SubnetIdis the group form) plus a standalone TASK fleet, each carrying a per-InstanceTypeConfigConfigurationsmarker thatverify.shreads back throughListInstanceFleets(SDK,Marker-paginated). The bug it found on its first run:AddInstanceFleettolerates an absent capacity (AWS defaults it to 0) butModifyInstanceFleetrejects the same payload —"The instance fleet (if-...) should have both targetOnDemandCapacity and targetSpotCapacity specified."The provider forwarded the template verbatim and the SDK v3 serializer omitsundefinedmembers, so EVERY resize of an ordinary On-Demand-only or Spot-only fleet failed. The ordinary CDK template declares exactly one of the two, so this was the common case, not an edge case. Fixed by defaulting the undeclared side to0, which also matches CFn desired-state semantics (an omitted capacity means zero) and the provider's owntargetCapacity()helper and delete-path scale-to-0, both of which already sent both members. The pre-existing unit tests all passed aBASE_PROPScarrying BOTH keys — which is exactly why the suite agreed with the bug; the added regression test uses the real one-sided shape and asserts both members are PRESENT on the wire (anObject.keyscheck, sincetoMatchObjectpasses on an absent key whose expected value isundefined). A generalization of memory ruleupdate_api_stricter_than_create_probe_payload: an AWS update API can REQUIRE a field its create counterpart defaults. - ✅ Shape pass for the nested-key critic:
{Quantity, Items}wrapper + definition-placement divergences mechanically enforced (issue #1378) —scripts/gen-nested-key-coverage.ts(shape pass),scripts/refresh-cfn-schemas.mjs(extractDefinitionShapes— per-definition member -> terminal type kind,$ref-resolved + cycle-guarded, top-level block under the reserved#topkey; plus the rider--help/ unknown-flag guard — an unrecognized flag previously fell through to a silent FULL ~135-type re-fetch), target fixtures re-captured,docs/_generated/nested-key-coverage.{json,md}extended. The gap: the #1373 key pass is structurally blind to divergences whose spelling exists SOMEWHERE in the SDK model — the CloudFront bare-array-vs-{Quantity, Items}wrapper family (previously a hand-maintainedQUANTITY_ITEM_FIELDSlist a NEW AWS array member would silently miss) and relocated/renamed members like theCachedMethodssibling-vs-nested placement (the hardest #1370 member). The pass: SDK interfaces are parsed with member type kinds (collectSdkInterfaces; aQuantity-bearing interface is a wrapper), and two new CI-blocking buckets fire when neither provider-named (dot-segment-expanded literals — the'ForwardedValues.Headers'path idiom) nor allow-listed:array-vs-wrapper(a CFnarraymember whose same-spelled SDK members are all wrapper refs) anddefinition-member-missing(a CFn definition member same-spelling an SDK member globally but missing from the same-named SDK interface). Keys with no same-spelled SDK member anywhere stay the key pass's domain (no double-reporting);ambiguousshapes and unmatched definitions stay visible non-blocking. First audit: no live bug — 62 clean bare-array pairs, 17 provider-handled re-shapings (the whole QUANTITY_ITEM_FIELDS family +CachedMethods+GeoRestriction.Locations), one new allow-list entry: legacyS3Origin, which the key pass could never see because the StreamingDistribution API still carries a same-spelled member — the definition pass catching it is the pass working as designed. Proven against real code: full-word-strippingAliasesfrom the REAL provider makes--checkexit 1 namingAliases [array-vs-wrapper](live probe + permanent unit probes for both buckets; the interface parse shares the SDK-member floor so a parser collapse fails loudly). Tests: +19 (shape-pass synthetic buckets incl. segment-expansion credit and per-key dedup,extractDefinitionShapeswalker, real-repo shape fences + floors, real-code probes,--helpguard spawn tests). Nosrc/**changes — pure tooling/fixtures/docs. - ✅ Nested CFn->SDK key-divergence critic (
gen-nested-key-coverage) + the two live bugs its first run caught (issue #1373) —scripts/gen-nested-key-coverage.ts(new, fourth codegen'd critic),scripts/refresh-cfn-schemas.mjs(extractNestedPropertyNames— per-top-level-property nested name capture,$ref-resolved + cycle-guarded, added to the fixtures),docs/_generated/nested-key-coverage.{json,md}(new matrix),vite.config.ts+.github/workflows/ci.yml(gen +--checksteps),src/provisioning/providers/cloudfront-distribution-provider.ts,src/provisioning/providers/ecs-provider.ts, SDK bumps@aws-sdk/client-cloudfront/client-ecs^3.1017->^3.1105. The class: the AWS SDK v3 serializer silently drops unknown keys, so an SDK provider forwarding a nested CFn config blob loses every key whose spelling it does not convert — write-side silent drops the top-level-onlyproperty-coveragepre-flight cannot see, previously found only by live failures (#1165/#1167 ECS, #1160 API GW v2, #1304MetricTimeZone, #1370 CloudFront x5). The critic: per declared target (CloudFront Distribution, CloudWatch AnomalyDetector, API GW v2 Api/Stage/Integration/Route/Authorizer, ECS Service/TaskDefinition), diffs the fixture's nested CFn key names for the provider's OWN handled top-levels against the SDK client model's member names (TS Compiler API overdist-types/models), with the provider's AST-level string literals as evidence of explicit handling;exactvslower-firstkey style per target; bucketssame-spelling/provider-handled/allow-listed/case-divergence(blocks CI, names the SDK near-miss) /no-sdk-member(blocks CI); per-targetminNestedKeys+ per-client SDK-member floors so a parser regression fails loudly; stale allow-list entries fail in both modes (an SDK bump that makes an allow-listed key reachable forces the entry's removal). Proven against real code per the repo's checker rules: re-introducing the #1370AcmCertificateArnrename into the REAL provider exits non-zero naming the key (live probe + permanent unit-test probes). First-run audit (13 flagged): TWO live bugs fixed here — (1) CloudFrontOriginCustomHeaderswas never renamed to the SDK'sCustomHeaders, so origin custom headers were silently dropped on create AND actively WIPED on update by #1371's required-field fill (CustomHeaders: {Quantity: 0}); now renamed + Quantity-wrapped inconvertOrigin, with the inverse restoring the CFn spelling for drift (note: distributions deployed BEFORE this fix haveobservedPropertiesbaselines recording the old read-back keyCustomHeaders, so their first post-upgradecdkd driftreports a one-timeOriginskey-rename drift until the next deploy ordrift --accept— same accepted tradeoff as #1372'sOriginSSLProtocolsrestore); (2) ECS TaskDefinitionS3FilesVolumeConfiguration(the new S3 Files volume) was not mapped inconvertVolumesat all AND is unreachable by the mechanical first-letter flip — the SDK member is the irregular all-lowercase-prefixs3filesVolumeConfiguration; now mapped both directions (write +volumesToCfnread-back). Eight keys (CloudFrontCacheTagConfig; ECS Service deployment-lifecycle members; TaskDefAccessPointArn/FileSystemArn) existed only in newer SDK models — resolved by the client bumps; three legacy pre-2012 CloudFront members (CNAMEs/CustomOrigin/DNSName) are allow-listed with rationales. The ECS Service fixture re-capture also surfaced AWS's new top-levelMonitoringproperty (registered in_todo-backfill.jsonper the property-coverage backfill flow). Shape-level divergences ({Quantity, Items}wrappers, sibling-vs-nestedCachedMethods) are v1-out-of-scope, recorded on #1373. Tests:tests/unit/scripts/gen-nested-key-coverage.test.ts(bucket units, capture-walker units, real-repo floors + fences pinning the #1370/#1373/#1304-fixed keys asprovider-handled, real-code regression probes), provider units for both fixes (ECS irregular-member mapping incl. the flip-spelling negative; CloudFront rename+wrap surviving the update fill, read-side CFn-spelling restore). - ✅ CloudFront Distribution: CFn -> SDK casing/shape maps + UpdateDistribution read-modify-write merge (issues #1370, #1371 — both externally reported) —
src/provisioning/providers/cloudfront-distribution-provider.ts, plus unit tests intests/unit/provisioning/cloudfront-distribution-provider.test.tsand an UPDATE phase + casing assertions added to thetests/integration/s3-cloudfront/fixture. (#1370)convertToSdkFormatpassed mostDistributionConfigkeys through verbatim, but threeViewerCertificatemembers (AcmCertificateArn/SslSupportMethod/IamCertificateId) and top-levelIPV6Enableddiffer in ACRONYM CASING between the CFn schema and the CloudFront API (ACMCertificateArn/SSLSupportMethod/IAMCertificateId/IsIPV6Enabled), andRestrictions.GeoRestrictioncarries a bare CFnLocationsarray where the SDK wants{ RestrictionType, Quantity, Items }— so a custom-domain distribution failed to create ("Your ViewerCertificate is missing one of ACMCertificateArn, IAMCertificateId, or CloudFrontDefaultCertificate"), IPv6 silently ended up disabled, and a geo restriction never reached AWS. Both directions are now mapped (convertToSdkFormat+ theconvertToCfnFormatinverse, socdkd driftcompares in CFn spelling with no phantom drift). The pre-flight property-coverage check cannot catch this class (it compares TOP-LEVEL properties only;DistributionConfigis handled) — the nested-sub-property audit remains tracked under the #1160/#1225 umbrella. (#1371)update()documented itself as merging the current config but actually sent the template'sDistributionConfigverbatim (onlyCallerReferencewas reused), whileUpdateDistributionis a read-modify-write API expecting the COMPLETE config — so EVERY update of a distribution failed on the first required-on-update member the template legitimately omits ("WebACLId is missing for the resource"). The newmergeUpdateConfigmerges per top-level member: template is the authority for members it carries, the live config fills members it never templated, and a member REMOVED since the previous template resets to its CloudFormation default (REMOVAL_RESET_DEFAULTS, sourced from the registry schema'sdefaultannotations + empty-list wrappers + the documented Logging-off shape) — members with no documented default (e.g.IsIPV6Enabled/Staging) keep their live value with a WARN instead of a silent guess.convertToSdkFormatalso stopped mutating its input's nestedLoggingobject (state-sourcedpreviousPropertiesnow flow through it). The live UPDATE run then surfaced a SECOND required-field layer: the API accepts SPARSEOrigins/ cache-behavior sub-shapes on CREATE but rejects them on UPDATE ("The 'OriginCustomHeaders' field is missing" / "OriginReadTimeout is required for updates" / "The parameter SmoothStreaming flag is missing" / field-level-encryption-id / allowed-method-settings / lambda-function-associations), somergeUpdateConfignow runscompleteRequiredUpdateFields— an absent-only fill of the empirically probed required set (per origin:OriginPath'' +CustomHeadersempty + CustomOriginConfig read/keepalive timeouts 30/5; per cache behavior:SmoothStreamingfalse,FieldLevelEncryptionId'',LambdaFunctionAssociations/FunctionAssociationsempty,TrustedSigners/TrustedKeyGroupsdisabled,AllowedMethodsGET/HEAD default with nestedCachedMethods) — validated by driving the exact payload to a liveUpdateDistributionsuccess. The probe also exposed two more members of the casing/shape family, fixed in both directions: the CFn template spellingCustomOriginConfig.OriginSSLProtocols(capital SSL) never matched the converter'sOriginSslProtocolslookup (the protocol list was silently dropped on every create), and the CFn SIBLINGCachedMethodsarray was never nested inside the SDK'sAllowedMethodswrapper (revertCacheBehaviorhas always hoisted it back out — the forward direction was simply missing). The first live run of the extended fixture immediately caught a third defect the shape fix newly exposed: CloudFront does not preserve the submitted order ofGeoRestrictioncountry codes (template[JP, US]read back[US, JP]), so the positional drift compare fired guaranteed phantom drift on any multi-country restriction —getDriftUnorderedPathsnow declaresDistributionConfig.Restrictions.GeoRestriction.Locationsas an unordered set (the FSxWindowsConfiguration.Aliasesprecedent, issue #1096 mechanism). Live-verified end-to-end by the extendeds3-cloudfrontinteg: create assertsIsIPV6Enabled=true+ the geo allowlist reached AWS, drift reports clean on the fresh deploy, the new Phase 1.5 re-deploys withCDKD_TEST_UPDATE=true(comment + geo change) through the merge — a phase that failed unconditionally before #1371 — and destroy completes clean.
Recently Implemented (2026-08-03):
- ✅ The
cdkd rollbackplan preview stops unwinding a record for a delete it will refuse; deadsupportsFinalSnapshotremoved (issue #1368) —src/cli/commands/rollback.ts,src/provisioning/final-snapshot.ts. Before: #1366 taught the plan LABEL to saythe rollback will REFUSE itfor a Snapshot shape cdkd cannot snapshot, butapplyPlanToPreview/applyFailedPlanToPreviewstill deleted that record from the preview state — and the preview state is what the NEXT (older) journal segment's plan is classified against, so an older segment's real work was downgraded toskip — already revertedin the one preview the user reads before typingy. Second-order: the per-item route stamping (#1366) reads the same preview state, so the dropped record also made a later item fall back to the journaled route — the #1366 defect one layer up. Now: both appliers takeskipFinalSnapshot(as the label functions already did) and consult the SAMErefusesFinalSnapshotpredicate with the SAME route, so the label and the previewed state cannot disagree by construction; under--skip-final-snapshotnothing is refused and the unwind is unconditional, matching the executor. Also:supportsFinalSnapshothad zerosrc/callers (never re-exported fromsrc/index.ts, so not a public-API break) and was deleted; its test block is re-homed onto the type SETS as a STRONGER fence — the union pinned against the literal CloudFormation-documented Snapshot-capable list, plus an explicit DISJOINTNESS assertion that nothing had before (load-bearing since #1366:finalSnapshotMechanismtests the atomic set first, so a type in both would silently take the atomic arm and never reach the pre-delete snapshot). Tests: 4 MULTI-SEGMENT CLI cases (a refused completed CREATE and a refused--revert-failedCREATE each keep their record so the older segment is not mislabelled;--skip-final-snapshotand a snapshottable shape both still unwind — the carve-out is refusal-only) + 3 re-homed set fences. Binding-proofed: neutering the carve-out fails 2, and addingAWS::EC2::Volumeto the atomic set fails the union + disjointness fences (and the #1366 matrix cases). - ✅ Rollback events report the route the delete took, and the plan preview stops promising a snapshot it will refuse (issue #1366) —
src/provisioning/final-snapshot.ts(new purefinalSnapshotMechanism(type, route)->atomic-delete-parameter/pre-delete-snapshot/refuse-cc-routed/refuse-unsupported-type, plus therefusesFinalSnapshotpredicate),src/deployment/rollback-executor.ts(prepareCreateRollbackFinalSnapshotswitches on that matrix instead of re-deriving it; the four CREATE-rollback arms emit the EFFECTIVE route; plan items carryeffectiveProvisionedBy),src/cli/commands/rollback.ts(sharedsnapshotNotehelper for both label functions). Before: (1) both delete sites resolved the routing layer viaeffectiveProvisionedBy(state record first) and routed the provider lookup with it, but the emittedROLLBACK_RESOURCE_*event still carriedop.provisionedBy— so when a legacy journal entry disagreed with the state record,cdkd eventsnamed a layer the delete did not use; (2)actionLabel/failedActionLabelrendered[DeletionPolicy Snapshot — final snapshot, then delete]for EVERY Snapshot-policy resource, including the cc-api-routed atomic types and no-mechanism types the executor was about to REFUSE, and the preview dropped their records as if the delete would happen. Now: the event reports the route actually used (resolved BEFORE the record is dropped on the orphan arms, where the authoritative side is about to vanish), and the preview reads the same matrix the replay runs — a shape that will be refused is labelledcdkd cannot snapshot this resource; the rollback will REFUSE it (re-run with --skip-final-snapshot to delete without one).--skip-final-snapshotstill wins over both (nothing is refused under the opt-out). Design note: the refusal verdict lives in the LABEL layer, not as a newrefuse-*action kind from the classifier — whether a Snapshot shape is refused also depends on--skip-final-snapshot, a CLI flag the pure classifier deliberately cannot see, and the label functions already take it. Tests: 4 matrix units infinal-snapshot.test.ts(per-route atomic polarity, route-agnostic pre-delete types, unsupported arm, the predicate pinned in both directions), 7 inrollback-executor.test.ts(event route on completed delete / orphan / failed delete / failed orphan — each with the journal and the record DELIBERATELY disagreeing, which is the only shape that can observe the bug — plus the two plan-stamping cases and the legacy fallback), 5 CLI plan-label units. Binding-proofed three ways: neuteringrefusesFinalSnapshotfails 4, reverting the event route fails 4, reverting the plan stamping fails 4. Scoped out:deploy-engine.ts'sprepareFinalSnapshotForDeletestill carries a third copy of the matrix — routing it through the new helper is the obvious consolidation but that file is in theinteg-destroy+integ-broadgate scope, so a pure refactor there would force a broad real-AWS integ; left for whenever that file is next touched for a behavior reason. - ✅
--revert-failed's delete of a FAILED in-flight CREATE now honorsDeletionPolicy(issue #1362) —src/deployment/rollback-executor.ts(two newFailedOpActionKinds —orphan-failed-create-retain/delete-failed-create-with-final-snapshot;classifyFailedOpreads the CURRENT state record'sdeletionPolicy; the delete branch resolves the routing layer ONCE viaeffectiveProvisionedByand shares it between the snapshot gate and the provider lookup;prepareCreateRollbackFinalSnapshotwidened to a structural op type so both sibling paths run the SAME mechanism matrix),src/cli/commands/rollback.ts(plan labels for both new actions,skipFinalSnapshot-aware like the completed-op labels; preview state drops the record for both). Before: thedelete-failed-createbranch read NODeletionPolicyat all — aSnapshot-policy resource whose CREATE failed AFTER AWS provisioned it (a physical id is recorded, which is exactly what the action requires) was deleted with no final snapshot and no refusal, and aRetain-policy one was deleted outright while the COMPLETED-CREATE path orphaned it. Now: one matrix for both paths —Retainleaves the resource in AWS and drops the record,Snapshotsnapshots then deletes (atomic delete parameter on the SDK route,createPreDeleteFinalSnapshotfor the pre-delete types, REFUSAL for a cc-api-routed atomic type or any other Snapshot shape),RetainExceptOnCreate/Delete/ absent delete plainly. TheRetainarm is a deliberate scope extension beyond the issue text (recorded on the issue before implementation): fixing onlySnapshotwould leave the classifier readingdeletionPolicyfor one value and ignoring it for another, and CloudFormation applies the policy to a failed create's rollback delete too — which is whyRetainExceptOnCreateexists as a separate value to opt OUT of exactly that. Why strict (refuse) rather than tolerant (warn-and-plain-delete) for a shape that cannot be snapshotted: a refusal is a per-op failure, so the journal is KEPT and the op stays inremainingFailedOps— a half-created resource that is not snapshot-capable yet (an RDS instance rejects a final-snapshot delete whilecreating) becomes snapshot-able once it settles, so a re-run completes the job, and--skip-final-snapshotremains the explicit opt-out. The tolerant option's failure mode is unrecoverable by comparison. Tests: 15 new units intests/unit/deployment/rollback-executor.test.ts(classifier per policy incl. the mismatch guard running FIRST, Retain-orphan, atomic+SDK identifier threading withattemptedPropertiesstill flowing, pre-delete snapshot-before-delete ordering, snapshot failure aborting the delete AND keeping the op for a re-run, cc-api refusal, unsupported-shape refusal,--skip-final-snapshotcovering the refused shapes but NOT overridingRetain, routing-source alignment) plus 4 CLI units intests/unit/cli/commands/rollback.test.ts(identifier reaching the delete through the real command, both flag polarities,Retainissuing no delete at all, and the plan labels). Binding-proofed: reverting the two classifier lines fails 13 of them. Docs: cli-reference (DeletionPolicy: Snapshotsection,cdkd rollbackflag table + limitations),.claude/rules/code-layout.md. - ✅ A rolled-back CREATE under
DeletionPolicy: Snapshotis snapshotted then deleted, not orphaned (issue #1358) —src/deployment/rollback-executor.ts(newdelete-with-final-snapshotRollbackActionKind;classifyRollbackOpsplitsRetainfromSnapshot; newprepareCreateRollbackFinalSnapshotmirroring the deploy engine'sprepareFinalSnapshotForDeletemechanism matrix;RollbackExecutorContextgainsfinalSnapshotClients+skipFinalSnapshot),src/deployment/deploy-engine.ts(threads both fromDeployEngineOptionsinto the executor context),src/cli/commands/rollback.ts(--skip-final-snapshot+ stack-region-pinnedAwsClients),src/cli/options.ts(doc comment). Before:classifyRollbackOpmapped BOTHRetainandSnapshottoorphan-retain, so a deploy that failed after creating aSnapshot-policy resource LEFT IT IN AWS and dropped it from state — an untracked, billing resource, reported as a clean rollback (Leaving Cache (AWS::ElastiCache::ReplicationGroup) in AWS (DeletionPolicy: Retain) — removed from state). Deliberate when written (cdkd could not create a final snapshot at all), obsolete oncesrc/provisioning/final-snapshot.tsshipped (#1352 / #1353). Now: CloudFormation semantics — rolling a CREATE back IS a delete, soSnapshottakes the final snapshot and then deletes: atomic delete parameter for the SDK-routedATOMIC_FINAL_SNAPSHOT_TYPES,createPreDeleteFinalSnapshot+ wait forPRE_DELETE_SNAPSHOT_TYPES, and a REFUSAL (counted as a per-op failure, so the segment is not popped and the journal is kept for a re-run — never a silent fall-back to orphaning) for a cc-api-routed atomic type or any other Snapshot-tagged shape.cdkd rollback --skip-final-snapshotis the data-loss opt-out;Retain/RetainExceptOnCreate/Deleteare unchanged. The cc-api test uses the CURRENT state record'sprovisionedBywith the journaled op's as the legacy fallback (sharedeffectiveProvisionedByhelper, also used by #1354'srollbackFinalSnapshotId). The delete-of-the-NEW-resource direction (UpdateReplacePolicy, #1354) keeps its bounded atomic-only scope — that delete is load-bearing for same-name re-creation. Tests: 13 new units intests/unit/deployment/rollback-executor.test.tsplus 4 CLI-wiring units intests/unit/cli/commands/rollback.test.ts(the flag actually reaching the executor context, the plan label under both polarities, and the option being declared on therollbacksubcommand itself — the #1097 failure class) (classifier split; atomic+SDK threads the identifier; atomic+cc-api refuses via the state record AND via the op fallback; pre-delete type snapshots before deleting with the context clients; pre-delete snapshot failure aborts the delete; unsupported type refuses;--skip-final-snapshotplain-deletes incl. the otherwise-refused shapes;Retainstill orphans;RetainExceptOnCreatestill deletes). Binding-proofed: reverting the classifier line fails 9 of them. The command installs a STACK-REGION-PINNEDAwsClientsas the process-global for the replay (mirroringdestroy-runner.ts's cross-region destroy) and restores the original in itsfinally: pinning only the snapshot would be worse than not pinning it at all, since a--stack-regionrun would then take a real, billable snapshot and fail the delete that follows. Docs: cli-reference (DeletionPolicy: Snapshotsection +cdkd rollbackflag table / limitations, incl. the re-run snapshot-cost note for the name-keyed types),.claude/rules/{code-layout,providers}.md. Scoped out at the time, shipped since:--revert-failed's delete of a resource whose CREATE FAILED mid-flight was still policy-unaware — closed by issue #1362 (entry above). - ✅
AWS::EC2::Volumeimmutable-property changes now drive a REPLACEMENT (issues #1356 / #1357) —src/analyzer/replacement-rules.ts,src/provisioning/stateful-types.ts,tests/integration/deletion-policy-snapshot/**. Before: changing an immutable EBS volume property (AvailabilityZone/AvailabilityZoneId/Encrypted/KmsKeyId/OutpostArn/SnapshotId) was classified as an in-place UPDATE and AWS rejected the deploy (Volume properties other than AutoEnableIO, type, size, and IOPS cannot be updated), where CloudFormation replaces the volume. Root cause was NOT a cdkd logic bug: thecreate-only-properties.tsCFn-schema fallback found nothing because the AWS registry schema for this type declares nocreateOnlyPropertiesat all (live-verified 2026-08-03), and the hand-authoredReplacementRulesRegistryhad no entry. Now: a hand-written rule classifies the six immutable properties as replacement and theModifyVolume-mutable set (AutoEnableIO/Iops/Size/Throughput/VolumeType/Tags) as explicitly in-place; the three remaining schema properties (MultiAttachEnabled/SourceVolumeId/VolumeInitializationRate) are deliberately left unclassified rather than guessed at, so the schema fallback still applies to them and no mutable property is ever mistaken for a replacement.AWS::EC2::Volumealso joinsSTATEFUL_TYPES, so the deploy engine's property-driven replacement path demands--force-stateful-recreationbefore destroying a volume's data — the change is therefore "deploy fails with an AWS error" → "deploy refuses until you confirm the data loss", never a silent delete. Tests: newreplacement-rules-ec2-volume.test.ts(per-property replacement / in-place polarity,isClassifiedpinning both directions, stateful guard). Integ: thedeletion-policy-snapshotfixture regains the replacement phase #1354 had to drop —CDKD_TEST_REPLACE=trueflipsVolumeKeep's AZ, and the run asserts the OLD volume is gone, a completed final snapshot of it exists (UpdateReplacePolicy: Snapshothonored at the engine's create-first cleanup delete site — the live coverage #1357 asked for), and the replacement landed in the expected AZ.covers: AWS::EC2::Volume. - ✅
DeletionPolicy: Snapshotcoverage completed +UpdateReplacePolicy: Snapshothonored (issues #1353 / #1354) —src/provisioning/final-snapshot.ts(RedshiftCreateClusterSnapshot+ ElastiCache replication-groupCreateSnapshotpre-delete implementations behind the newcreatePreDeleteFinalSnapshotdispatcher, name-prefix idempotent reuse viafinalSnapshotNamePrefix, per-service 50/255-char identifier caps),src/utils/aws-clients.ts(redshift/elastiCachegetters),src/deployment/deploy-engine.ts(sharedprepareFinalSnapshotForDeletegate reused by the destroy DELETE branch AND all four replacement / recreate delete sites —--replacedelete-first,--recreate-via-*, create-first cleanup, update-not-supported fallback),src/cli/commands/destroy-runner.ts,src/deployment/rollback-executor.ts(rollbackFinalSnapshotId: rollback's delete-new honors the atomic SDK-routed shape only — plain delete elsewhere is a recorded scope decision, since delete-new is load-bearing for same-name re-creation). Before:AWS::Redshift::Cluster/AWS::ElastiCache::ReplicationGroupunderDeletionPolicy: Snapshotwere refused outright (#1352's fail-safe), andUpdateReplacePolicy: Snapshotold resources were plain-deleted after the stateful guard. Now: the FULL CFn-documented Snapshot-capable type list is honored on destroy, template-removal, and replacement paths; refusals remain only for cc-api-routed atomic types and Snapshot on types CFn itself would refuse. Tests: ~60 dedicated units (module incl. Redshift/RepGroup creators + dispatcher fail-closed drift guard, engine replacement-fallback polarities, runner dispatcher routing, rollback helper). Integ: newdeletion-policy-snapshot-heavyfixture (single-node Redshift + 1-node Redis replication group, ~35 min) proves both pre-delete implementations against real AWS. The replacement sites keep unit-only coverage: a planneddeletion-policy-snapshotreplacement phase (AZ flip) could not run because cdkd does not classify anAWS::EC2::VolumeAZ change as a replacement — the AWS registry schema for that type declares nocreateOnlyProperties(root cause filed as #1356; the integ follow-on is #1357). Docs: cli-reference table + notes, README, troubleshooting, rules. - ✅
DeletionPolicy: Snapshotis now honored on every delete path (issue #1352) —src/provisioning/final-snapshot.ts(new),src/provisioning/region-check.ts(DeleteContext.finalSnapshotIdentifier),src/deployment/deploy-engine.ts(DELETE branch),src/cli/commands/destroy-runner.ts, provider deletes inrds-provider.ts/neptune-provider.ts/docdb-provider.ts/elasticache-provider.ts,--skip-final-snapshotflag ondeploy/destroy/state destroy(src/cli/options.ts, threaded throughnested-stack-context.tsfor recursive child destroys). Before:shouldRetainResourceonly honoredRetain/RetainExceptOnCreate, soSnapshotfell through to a plain DELETE — no final snapshot where CloudFormation creates one (live A/B onAWS::EC2::Volume, 2026-08-03: CFndelete-stackleft a completed snapshot,cdkd destroyleft nothing), and the RDS/Neptune/DocDB providers hardcodedSkipFinalSnapshot: true. Silent data loss on a daily pattern — the CDK RDS L2 defaultsremovalPolicytoSNAPSHOT. Now: Tier-A types (RDS DBInstance / DBCluster, Neptune / DocDB clusters, ElastiCache CacheCluster) delete with the API's atomic final-snapshot parameter under a generated, logged identifier (<physicalId>-final-<utcTimestamp>); the CFn cluster-member nuance is matched (a DBInstance withDBClusterIdentifierset gets no instance-level snapshot). CC-routedAWS::EC2::Volumegets a pre-deleteCreateSnapshot(taggedcdkd:final-snapshot-of), waited tocompleted, idempotent across destroy re-runs. Snapshot-tagged types cdkd cannot snapshot yet (AWS::Redshift::Cluster,AWS::ElastiCache::ReplicationGroup— issue #1353) are REFUSED with an actionable error instead of silently losing data, and so is a Cloud-Control-routed resource of an atomic type (provisionedBy: cc-api, the #614 routing — CCDeleteResourcehas no final-snapshot parameter;CloudControlProvider.deletealso fail-closes if the context field ever reaches it). Snapshot-wait failures are wrapped as typedFINAL_SNAPSHOT_*errors that the destroy call sites rethrow BEFORE their "not found = already deleted" heuristics (a rawInvalidSnapshot.NotFoundpoll error would otherwise drop a live, un-snapshotted volume from state), with transient post-CreateSnapshotInvalidSnapshot.NotFoundpropagation tolerated for a bounded window; the engine's EBS snapshot uses a stack-region-pinned EC2 client (DeployEngineOptions.finalSnapshotEc2) instead of the--stack-concurrency-raceable global; ElastiCache snapshot names are capped to the ~50-char cluster naming rules.--skip-final-snapshotis the explicit opt-out for all shapes.UpdateReplacePolicy: Snapshoton the replacement path is follow-up #1354. Tests:tests/unit/provisioning/final-snapshot.test.ts+deletion-policy-snapshot-providers.test.ts+tests/unit/deployment/deploy-engine-deletion-policy-snapshot.test.ts+tests/unit/cli/destroy-runner-deletion-policy-snapshot.test.ts(33 tests, both flag polarities pinned). Integ:tests/integration/deletion-policy-snapshot/(new) — engine path viaCDKD_TEST_UPDATE=truetemplate-removal redeploy + runner path via destroy, both asserting a completed tagged snapshot, artifact cleanup to orphan-zero. Docs: cli-reference "DeletionPolicy: Snapshot" section, state-management v5 note, state-schema / providers rules. - ✅ Destroy data guard extended to S3 Express directory buckets (issue #1344) —
src/provisioning/providers/s3-directory-bucket-provider.ts. Before:delete()PROACTIVELY emptied everyAWS::S3Express::DirectoryBucket(ListObjectsV2 + DeleteObjects on every delete, even before any not-empty error) — where CloudFormation DELETE_FAILs a non-empty directory bucket ("The bucket you tried to delete is not empty", 409; live-A/B-verified 2026-08-03). Now: the empty runs only with an opt-in —DeleteContext.forceDataDelete(--force-stateful-recreationreplacement consent, #1340 plumbing) or theaws-cdk:auto-delete-objectstag (honored when present, thoughTagsis not yet a handled property for this type, so it currently requires--allow-unsupported-properties; no CDKautoDeleteObjectssugar exists for directory buckets). Without an opt-in, DeleteBucket is attempted directly and a not-empty failure surfaces with an actionable message (manualaws s3 rm --recursive; no versioning on directory buckets). A side win: deleting an empty directory bucket now costs ONE DeleteBucket call instead of ListObjectsV2 + DeleteBucket. Thes3-directory-bucketinteg fixture gained averify.shexercising guard refusal with data intact + AWS-native cleanup + clean second destroy (the fixture previously had no verify.sh — the #1344 issue text's claim that it wrote objects was wrong). Tests: provider delete suite reworked (no-opt-in skips listing; refusal test with data untouched; forceDataDelete + tag opt-in paths; binding-proven — 2 fail with the gate reverted). Docs: cli-reference "Destroy data guards" table row, troubleshooting update.
Recently Implemented (2026-08-02):
- ✅ Interrupt handlers now register BEFORE lock acquisition in deploy / destroy / rollback (issue #1348) —
src/deployment/deploy-engine.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/rollback.ts,src/utils/interrupt-signals.ts(comment), plus source-level ordering pins intests/unit/cli/signal-before-lock-ordering.test.ts(new). Before: each site acquired the stack lock and only then registered its graceful-SIGINT handler; a SIGINT/SIGTERM landing in that window (which includes an S3 round-trip — destroy additionally runs its under-lock strong-reference scan before registering) killed the process with the just-written lock stranded for its full 30-minute TTL (reproduced live while testing #1342). Now: the handler (and, transitively, the #1342 SIGTERM forwarder path) is armed first, so an interrupt during acquisition just flips the drain/interrupt flag — the run then starts no work and the normalfinallyreleases the lock. Each site removes the listener in a catch around the acquire so a lock-conflict failure does not leak the handler. destroy-runner safety gate: the second-signal force-quit's best-effortreleaseLockis now gated on alockHeldflag —releaseLockdeletes the lock key unconditionally, and before OUR acquire succeeds the key may belong to ANOTHER process (exactly what a conflicting acquire waits on), so firing it pre-acquire could have deleted a live foreign lock (a hazard the reorder would otherwise have introduced; pre-reorder code could not hit it because the handler did not exist yet). Live-verified: SIGTERM delivered whilecdkd destroyprintsAcquiring lock ...now drains gracefully and leaves no lock (the same timing stranded the lock before this change). - ✅ SIGTERM now rides the graceful-SIGINT path in
deploy/destroy/state destroy/rollback(issue #1342) —src/utils/interrupt-signals.ts(newforwardSigtermToSigint()— registers ONEprocess.on('SIGTERM')listener that re-emits the signal asSIGINT, returns an unregister function), wired at the top ofsrc/cli/commands/deploy.ts/destroy.ts/state.ts(stateDestroyCommand) /rollback.tswith the unregister in each command's outermostfinally(so the forwarder never leaks intocdkd local start-api, which owns a real SIGTERM handler). Before: the interrupt-sensitive commands handled only SIGINT; a CI cancellation (GitHub Actions escalates SIGINT → SIGTERM → SIGKILL; GitLab CI /docker stop/ Kubernetes send SIGTERM directly) killed the process on the unhandled SIGTERM, skipped everyfinally, and stranded the stack lock for its 30-minute TTL. Now: SIGTERM reaches every SIGINT listener — the top-level command handlers, the deploy engine's partial-state save, and the per-provider poll-abort listeners (CustomResource / ACM / CloudFront / Route53) — so a SIGTERM-only cancellation gets the full graceful drain (finish in-flight ops, save state, release the lock), and under GitHub Actions the SIGINT→SIGTERM sequence maps onto the existing second-signal force-quit escalation (destroy's force-quit fires its best-effort lock release right before SIGKILL; deploy's force-quit now prints thecdkd force-unlock <stackName>recovery hint — parity of message, no best-effort release plumbed at deploy's top level). A per-stack pre-start gate indeploy.ts'srunStackInneradditionally honors an interrupt that landed BEFORE the engine registered its handlers (previously the flag was only read by the second-signal escalation, so an early Ctrl-C / SIGTERM let the deploy proceed into lock acquisition + provisioning). SIGKILL remains unhandleable — the docs' CI-cancellation section (PR #1346) still documents the TTL +force-unlockrecovery. Tests:tests/unit/utils/interrupt-signals.test.ts— forwarding / repeat-forwarding / unregister behavior plus a source-level wiring pin asserting all four commands register AND unregister the forwarder (live-line match, so a commented-out call fails). Live-verified:kill -TERMduring a real-AWS deploy exits gracefully with partial state saved + lock released; a second SIGTERM force-quits with the recovery hint. - ✅ Destroy data guards: non-empty S3 buckets / image-carrying ECR repos are no longer force-cleaned without an opt-in (issue #1340) —
src/provisioning/providers/s3-bucket-provider.ts,src/provisioning/providers/ecr-provider.ts,src/provisioning/region-check.ts(DeleteContext.forceDataDelete),src/provisioning/data-delete-intent.ts(new shared intent helpers),src/deployment/deploy-engine.ts(threads--force-stateful-recreationconsent into the four replacement/recreate delete sites). Before:cdkd destroyof ANY non-empty bucket silently deleted every object version + delete marker (deleteBucketWithEmptyRetry's unconditionalemptyBucket), and ANY image-carrying ECR repo was deleted withforce: true— where both CloudFormation (DELETE_FAILED, live-A/B-verified) and Terraform (force_destroy/force_deleterequired) fail and protect the data, and inconsistently with cdkd's own deploy-side stateful-recreation guard (#648). Now: force-cleanup requires an opt-in signal — S3: CDK'saws-cdk:auto-delete-objectstag (autoDeleteObjects: true; the empty-retry also still absorbs the post-CR-cleanup object race for those buckets); ECR:EmptyOnDelete: true(boolean or CFn-string) or theaws-cdk:auto-delete-imagestag; both:DeleteContext.forceDataDelete, set ONLY by the deploy engine's replacement/recreate deletes when--force-stateful-recreationwas passed (the template-removed-resource DELETE path during deploy deliberately does NOT set it — CFn also fails there). Without an opt-in the AWS not-empty error surfaces with an actionable message (empty manually, or redeploy with the opt-in). Same implicit-force→explicit-opt-in migrationremoveProtectionwent through. Parity notes recorded (live CFn A/B 2026-08-02): SecretsManager hard-delete and IAM role force-detach are CFn parity — unchanged. Fixture sweep found no existing fixture relying on the old default (local-run-task-from-statedeletes images AWS-natively pre-destroy;synthetics-canarykeeps its bucket empty by design). Tests:tests/unit/provisioning/s3-bucket-provider-delete-data-guard.test.ts+tests/unit/provisioning/providers/ecr-provider-delete-data-guard.test.ts(11 tests; binding-proven — 3 fail with the gates reverted). Integ:tests/integration/destroy-data-guard/(new) — deploy guarded + opted-in resources, load data into all, first destroy force-cleans ONLY the opted-in pair and fails on the guarded pair with data intact, then AWS-native cleanup + second destroy completes with zero orphans. Docs: cli-reference "Destroy data guards" section, troubleshooting entry.
Recently Implemented (2026-07-31):
✅
--verboseno longer prints NoEcho parameter values (issue #1329) —src/deployment/intrinsic-function-resolver.ts, plus unit tests intests/unit/deployment/intrinsic-noecho-redaction.test.ts(new).NoEcho: trueis the template author's explicit "this value is sensitive" declaration — CloudFormation masks such values everywhere it echoes them — but cdkd's debug output printed them raw at four sites: the user-provided-value / default-value / SSM-resolved-value lines inresolveParametersand theResolved Ref to parameterline inresolveRef. All four now route through the module-localstringifyParameterForLog(paramDef, value)(renders<redacted>when the definition carriesNoEcho: true); resolution itself is unchanged — consuming resources still receive the real value, only the log is masked. Sibling of PR #1328'sstringifyAttributeForLog(name-heuristic redaction forFn::GetAttattribute values; here the author told us explicitly, so no heuristic is needed). Live-verified: a--verbosedeploy of a NoEcho-param stack logs<redacted>at both resolver sites, zero occurrences of the sentinel value in the full log, and the consuming SSM parameter still received the real value. Spun out of PR #1328's delta review.✅
AWS::IAM::AccessKeySDK provider (issue #1323) —src/provisioning/providers/iam-access-key-provider.ts(new),src/provisioning/register-providers.ts, schema fixturetests/fixtures/cfn-schemas/AWS-IAM-AccessKey.json(new), unit tests intests/unit/provisioning/providers/iam-access-key-provider.test.ts(new), real-AWS fixturetests/integration/iam-access-key/(new), rows indocs/supported-resources.md+docs/import.md, and regenerated coverage matrices. The type is NON_PROVISIONABLE in the CloudFormation registry (no Cloud Control handlers), so cdkd's pre-flight rejected any template declaring it — including the documented CDK CI-credentials patterniam.AccessKey+secretsmanager.Secret({secretStringValue: key.secretAccessKey})(found by the 2026-07-31/hunt-bugssweep). The provider mapsCreateAccessKey(create; aStatus: Inactivetemplate gets a follow-upUpdateAccessKey, with delete-on-failure cleanup so a half-created key never strands outside state and burns the 2-keys-per-user quota),UpdateAccessKey(in-placeStatusflip —UserName/Serialare createOnly; a REMOVEDStatusis reset to the CFn defaultActive), andDeleteAccessKey(the owning user comes from state properties, recovered viaGetAccessKeyLastUsedwhen absent;NoSuchEntityException= idempotent success after the shared region check).SecretAccessKeyis a create-time-only attribute —CreateAccessKeyis the only API that ever returns it — so the provider caches it in the create-result attributes (CFn-equivalent behavior; it lives in the S3 state file, the same trust boundary as the rest of cdkd state) andupdate()deliberately returns NO attributes so the cached secret survives (a partial attribute set replaces, not merges);getAttribute('SecretAccessKey')fails loudly instead of returning a wrong answer.Serialis declared HANDLED despite never being sent to IAM: its whole CFn semantic (change → new key) is the registry-schema createOnly replacement classification, and anunhandledByDesignlisting would land it in the silent-drop set, where the #614 auto-route viability guard on adisableCcApiFallbackNON_PROVISIONABLE type would wrongly HARD-REJECT any template usingserial. Import is explicit-override-only (keys are not taggable; verified viaGetAccessKeyLastUsed; the imported record has no cached secret, soFn::GetAtt SecretAccessKeyis documented as unresolvable for imported keys). Drift:readCurrentStateresolves the owner viaGetAccessKeyLastUsed+ListAccessKeys(paginated);Serialis declared drift-unknown (no read API). Three review-driven companions shipped in the same PR: (1)src/deployment/retryable-errors.tsgains the anchored IAM-propagation patternThe user with name—CreateAccessKeyissued ~1s after the same deploy'sCreateUsercan race IAM's own eventual consistency withNoSuchEntity: The user with name X cannot be found., a phrasing no existing pattern matched, so the deploy hard-failed instead of retrying (the paired negative test pins that IAM'sUser with name X already exists.collision phrasing stays non-retryable); (2) a partialCreateAccessKeyresponse carryingAccessKeyIdbut noSecretAccessKeynow best-effort deletes the minted key before failing (otherwise the retry / next deploy burns the 2-keys-per-user quota on a key cdkd never recorded); (3)Fn::GetAttdebug logging redacts credential-bearing attribute VALUES —stringifyAttributeForLoginsrc/utils/stringify.ts(name matches/secret|password|credential/iminus identifier suffixes like...Arn/...Id) — because this PR is the first to cache a live long-lived credential in state attributes, and a--verbosedeploy would have printed the usable IAM secret into terminals / CI logs at the three GetAtt resolution sites insrc/deployment/intrinsic-function-resolver.ts.✅ CloudFront delete now waits out a still-propagating disable (issue #1316) —
src/provisioning/providers/cloudfront-distribution-provider.ts, plus unit tests intests/unit/provisioning/cloudfront-distribution-provider.test.ts.delete()only entered the disable-then-wait path when the config read backEnabled: true; a distribution that was ALREADY disabled but still propagating (Status: InProgress— the state a prior interrupted destroy or an out-of-band console disable leaves behind) skipped the wait entirely, andDeleteDistributionfailed in under a second with "The distribution you are trying to delete has not been disabled" on every retry until propagation finished. Found live while cleaning up an interrupted #1309 benchmark run. The already-disabled arm now runs the samewaitForDistributionStable(id, false)+ post-wait ETag re-fetch as the just-disabled arm (same warn-and-attempt on budget exhaustion); the settled common case pays exactly one confirmingGetDistributionread. More reachable post-#1282 (fire-and-forget default makes interrupted lifecycles onInProgressdistributions more common).✅ BREAKING: CloudFront Distribution default completion is now fire-and-forget;
--full-waitopts into theDeployedwait (issue #1282) —src/provisioning/providers/cloudfront-distribution-provider.ts,src/cli/options.ts,docs/cli-reference.md,README.md,.claude/rules/providers.md, plus unit tests intests/unit/provisioning/cloudfront-distribution-provider.test.tsand the new mechanical backstoptests/unit/provisioning/full-wait-doc-coverage.test.ts(the--full-waitmirror ofno-wait-doc-coverage.test.ts, added now that a second type joined ECS).create()no longer waits forDeployedby default (previously a 3-15 min wait, skippable only via--no-wait); it returns onceCreateDistributionis accepted and prints an INFO line with the manual wait command (aws cloudfront wait distribution-deployed --id <id>) plus a--full-waithint gated onCDKD_WAIT_FLAGS_AVAILABLE(issue #1291 pattern, socdkd drift --revertnever advertises a flag it does not declare). UnderCDKD_FULL_WAITthe wait applies to BOTH create and update — update never waited before, so--full-waitis now full CloudFormation parity — with the ~20 min budget lifted by an explicit--resource-timeoutper the #1280 inner-vs-outer rule (the lift also reaches the delete path's disable-then-wait, which itself is an API requirement and stays unconditional). A--full-waittimeout WARNS and proceeds instead of failing (deliberate divergence from the ECS steady-state timeout: CloudFront has no failure state — a distribution deploy cannot fail, only lag — and failing would hand auto-rollback a healthy distribution to disable-and-delete). Policy-wise this is NOT an extension of the ECS disagree-case precedent — CloudFormation and Terraform (wait_for_deployment, defaulttrue) both wait here — so the wait-semantics rule indocs/cli-reference.mdgained an explicit 3-condition fast-side clause ((a) no in-deploy consumer of the waited state, (b) no failure signal, (c) both modes measurable on the comparison tool), which is also the recorded answer to "why not--no-waitby default?" (ACM / RDS / EC2 each fail condition (a)). The cloudfront benchmark scenario needs re-measuring into the two-row form (new default vswait_for_deployment = false;--full-waitvs Terraform default) — tracked as a follow-up in the PR.✅ Destroy partial-failure guidance now names
cdkd state orphanas the last resort (issue #1303) —src/cli/commands/destroy-runner.ts(the ⚠ "partially destroyed" banner, with the concrete stack name),src/cli/commands/destroy.ts+src/cli/commands/state.ts(thePartialFailureErrormessages), plus a unit test intests/unit/cli/destroy-runner-incremental-state.test.ts. For a resource AWS itself refuses to delete (the #1301 pending SNS subscription pre-fix; any future AWS-side wedge), "re-run 'cdkd destroy' / 'cdkd state destroy'" fails identically forever —state destroydrives the same provider delete — and the actual escape hatch,cdkd state orphan <stack>(drop the state record, leave AWS resources), was not mentioned anywhere in the error output. All three guidance sites now append it, scoped to the "same resource keeps failing" case. Interrupted-destroy (Ctrl-C) messages are deliberately unchanged — an interrupt is not an undeletable resource. Message-only change; verified live by inducing a real partial destroy (security group blocked by an out-of-band ENI) and confirming exit 2 + both new messages.✅
AWS::CloudWatch::AnomalyDetectorSDK provider (issue #1304) —src/provisioning/providers/cloudwatch-anomaly-detector-provider.ts(new),src/provisioning/register-providers.ts, schema fixturetests/fixtures/cfn-schemas/AWS-CloudWatch-AnomalyDetector.json(new), unit tests intests/unit/provisioning/providers/cloudwatch-anomaly-detector-provider.test.ts(new), real-AWS fixturetests/integration/cloudwatch-anomaly-detector/(new), rows indocs/supported-resources.md+docs/import.md, and regenerated coverage matrices. The type is NON_PROVISIONABLE in the CloudFormation registry (no Cloud Control handlers), so cdkd's pre-flight rejected any template declaring it (found by the 2026-07-30/hunt-bugssweep). The provider mapsPutAnomalyDetector(create; also the in-place update path — the registry schema marks every descriptor field createOnly, so onlyConfigurationreachesupdate()) andDeleteAnomalyDetector(delete, addressed by the metric descriptor from state properties;ResourceNotFoundException= idempotent success after the shared region check). There is no server-generated identifier, so the physical id is DERIVED deterministically from the descriptor (<Namespace>:<MetricName>:<Stat>[:<sorted dims>]for single-metric,math:<sha256-16>for metric-math), stable across in-place updates; the schema's read-onlyIdattribute resolves to it.disableCcApiFallback = trueper the NON_PROVISIONABLE provider rule (the #614 unhandled-property auto-route must never send this type to a CC target with no handlers). Import is explicit-override-only (detectors carry no tags and no name). Two shape conversions: CFnConfiguration.ExcludedTimeRangesISO-8601 strings become the SDK'sDate, and CFnConfiguration.MetricTimeZone(capital Z) is RE-KEYED to the SDK'sMetricTimezone(lowercase z) — passing the CFn key through verbatim is a client-side silent drop (the SDK serializer ignores unknown keys; caught by the fixture's first live run, whose Configuration readback came back empty, and now pinned by a unit test + the fixture's readback assertion).✅ Subnet
MapPublicIpOnLaunch: phantom-drift race closed + in-place update wired (issues #1299, #1300) —src/provisioning/providers/ec2-provider.ts,src/analyzer/replacement-rules.ts, plus unit tests intests/unit/provisioning/ec2-provider-subnet-capture-readback.test.ts(new) andtests/unit/provisioning/ec2-provider-roundtrip.test.ts. Two coupled defects surfaced by adrift-revert-vpcinteg failure (2026-07-30): (1) issue #1299 —createSubnetreturned immediately afterModifySubnetAttribute(MapPublicIpOnLaunch=true), and the deploy engine's async observed-state capture (kickOffObservedCapture→DescribeSubnets) raced EC2's eventual consistency, occasionally persisting the stale pre-modifyfalseas the drift baseline — every latercdkd driftthen reported phantomfalse → truedrift on a subnet nothing touched.create()(and the newupdate()path) now read back the attribute via a bounded best-effortwaitForSubnetMapPublicIppoll (6 reads, 100ms exponential backoff, ~3s worst case, zero added latency when the first read already reflects the write; exhaustion / read failure degrades to the pre-fix behavior instead of failing the deploy). (2) issue #1300 —updateSubnetrejected EVERY update as "Subnet properties are immutable", thoughMapPublicIpOnLaunchis mutable viaModifySubnetAttributeandTagsviaCreateTags/DeleteTags— socdkd drift --revertcould not revert real (or phantom)MapPublicIpOnLaunchdrift (PartialFailureError: 1 update-not-supported), and a template flip forced a full subnet replacement where CloudFormation updates in place.updateSubnetis now property-aware (diff-based modify + tag diff, zero mutating calls on a no-drift round-trip; changedVpcId/CidrBlock/AvailabilityZonestill reject withResourceUpdateNotSupportedErrorso replacement classification is preserved on the revert path), andreplacement-rules.tsgains an explicitAWS::EC2::Subnetrule (replacementProperties: VpcId/CidrBlock/AvailabilityZone,updateableProperties: MapPublicIpOnLaunch/Tags) so the classification survives acloudformation:DescribeTypefailure.
Recently Implemented (2026-07-30):
✅ Destroy no longer gets stuck on SNS subscriptions in PendingConfirmation (issue #1301) —
src/provisioning/providers/sns-subscription-provider.ts, plus unit tests intests/unit/provisioning/sns-subscription-provider.test.tsand the new real-AWS fixturetests/integration/sns-pending-subscription/.delete()calledUnsubscribeunconditionally, but SNS rejectsUnsubscribefor ANY subscription still pending confirmation (InvalidParameterException: ... Cannot unsubscribe a subscription that is pending confirmation) and no API can remove the record (it auto-expires after ~3 days) — so a stack with a never-confirmed email subscription was permanently un-destroyable:cdkd destroyANDcdkd state destroyfailed the resource on every retry (PartialFailureError, state preserved), and the only escape wascdkd state orphan. Found live by a/hunt-bugssweep. The provider now treats the pending-confirmation rejection as delete success (skip + warn), matching CloudFormation's documented behavior of removing the resource from the stack without unsubscribing; the literalPendingConfirmationplaceholder physical id (possible viacdkd import --resource) short-circuits before the API call the same way. Unlike the NotFound skip, noassertRegionMatchgate is needed — the error proves the subscription was positively found in the client's region.✅
--full-waitECS steady-state cap now respects--resource-timeout(issue #1280) —src/provisioning/resource-timeout-registry.ts(new),src/provisioning/providers/ecs-provider.ts,src/cli/commands/deploy.ts/destroy.ts/state.ts,docs/cli-reference.md, plus unit tests intests/unit/provisioning/resource-timeout-registry.test.ts(new) andtests/unit/provisioning/ecs-service-full-wait.test.ts.ECSProvider.settleServicehardcodedmaxWaitTime: 600for the--full-waitsteady-state waiter, so--resource-timeout AWS::ECS::Service=20mlifted only the deploy engine's OUTER per-resource deadline while the provider's INNER waiter cap fired first at 10 minutes — the same inner-undercuts-outer shapeslow-cc-operation-timeouts.tscloses for Cloud Control types. A new process-wide registry (setResolvedResourceTimeoutsseeded by deploy / destroy / state destroy right aftervalidateResourceTimeouts;resolvedResourceTimeoutMs(type)resolving per-type > explicit global > undefined) now feeds the waiter, whose cap ismax(600s, resolved): an explicit--resource-timeout(per-type or global) lifts it, nothing lowers it below the 600s Terraform-parity floor, and the compile-time 30m default never leaks in. Seeding wiring is pinned by a source-level test; the lift/floor/UPDATE-side behavior is pinned by six new waiter-config cases.
Recently Implemented (2026-07-29):
✅ Deploy tail de-serialized, the redundant pre-lock state GET removed, SecurityGroup wiring batched, and the
AWS::CDK::MetadataDescribeType warning fixed —src/deployment/deploy-engine.ts,src/state/deployment-events-store.ts,src/cli/commands/deploy.ts,src/cli/commands/prefix-migration-check.ts,src/provisioning/describe-type.ts,src/provisioning/create-only-properties.ts,src/provisioning/write-only-properties.ts,src/provisioning/providers/ec2-provider.ts,src/utils/error-handler.ts, plus unit tests intests/unit/deployment/deploy-engine-tail-ordering.test.ts(new),tests/unit/deployment/deploy-engine-create-only-prefetch.test.ts,tests/unit/state/deployment-events-store.test.ts,tests/unit/cli/prefix-migration-check.test.ts,tests/unit/provisioning/{describe-type,create-only-properties,write-only-properties}.test.ts,tests/unit/provisioning/providers/ec2-provider.test.ts. Four independent defects found while reading a verbose deploy timeline:(1) The success-path tail was fully serialized. After the last resource settled, the engine ran
saveState→ delete the rollback journal → update the exports index → release the lock, one after another (measured ~1.0s between "State saved" and "Lock released", reproducing on every scenario). The journal delete and the exports-index update target DISJOINT S3 objects and neither reads what the other writes —updateForStackonly ever touches the exports index (plus, on a first-ever call, a rebuild scan ofstate.jsonfiles, which the journal delete does not affect), and the journal delete reads nothing — so they now run under onePromise.all. What did NOT move, deliberately: both stay strictly AFTER the state save (deleting the journal before the new baseline is durable would lose the ability to revert) and strictly BEFORE the lock release (an early release would let a concurrent deploy observe a journal we are about to delete, or race the exports-index read-modify-write). ThedrainObservedCapturesstep beforesaveStatewas likewise left alone — observed properties MUST be in the record being persisted. Separately,DeploymentEventsStore.finalize()ran flush → index GET → index PUT → prune LIST → prune DELETE as five sequential round trips after "Deployment completed successfully" had already printed. The index READ now overlaps the flush PUT (different objects, no dependency) and the prune LIST overlaps the index PUT (read-only, and its cutoff is known before the PUT). The durability order is unchanged and is what bounds the change: the index is still WRITTEN only after the flush resolved, so it can never advertise a run whose stream is missing, and a stream is still DELETED only after the index that dropped it is durable — an index PUT failure aborts the prune exactly as before. The LIST stays gated on "the retained window is full", so stacks below the cap do not start paying a new LIST.(2) A redundant pre-lock state GET on every deploy. The
--prefix-user-supplied-namesmigration pre-flight issued its owngetStatebefore the lock, then the engine read the same object again right after taking it. Prefix-skipping has been the DEFAULT since v0.94.0, so this fired on every deploy of every stack — including brand-new stacks with no state at all, wherefindPendingPrefixRenamesreturns[]unconditionally.DeployEngineOptionsgained anonCurrentStateLoaded(stackName, state)gate the engine invokes once, immediately after the post-lock state read and before parsing / diffing / any provider call; the CLI's check moved into it via the newcreatePrefixMigrationGatefactory. The contract the check depends on ("surface this before any provider call runs") is preserved, and reading under the lock is strictly MORE authoritative — no concurrent deploy can mutate the state between the check and the diff it predicts. Declining the prompt now raises the newDeployCancelledError, which the deploy CLI unwinds quietly (no error output, no FAILED run event), matching the plain earlyreturnthe pre-lock version used. The gate is scoped by stack name because nested-stack children inherit the parent's engine option object.(3) SecurityGroup creation made needless serial API calls. Wiring a fresh SG was
CreateTags→ oneAuthorizeSecurityGroupIngressPER RULE →RevokeSecurityGroupEgress→ oneAuthorizeSecurityGroupEgressPER RULE, fully serialized — 4 sequential round trips for a typical CDK L2 SG. Tagging and the two rule directions are mutually independent (distinct APIs, distinct rule sets), so they now run concurrently, and both Authorize calls take anIpPermissionsARRAY, so N rules became ONE call per direction. The revoke still strictly precedes the egress authorize, and an EMPTYSecurityGroupEgress: []still revokes-without-authorizing (that is a deliberate "deny all outbound"). The revoke+authorize pair is skipped entirely when the templated egress is EXACTLY the rule AWS already created by default, reusing the existingisDefaultEgressRulepredicate so the create path and the drift reverse-mapper cannot disagree about what "default" means. That skip deliberately does NOT fire for the CDK L2allowAllOutbound: trueshape, which stampsDescription: 'Allow all outbound traffic by default'— the AWS default rule carries no description andAuthorizeSecurityGroupEgressrejects the identical-modulo-description rule as a duplicate, so the description can only be applied by revoking first; skipping there would leave the group in a state CloudFormation would not produce AND would surface as permanent phantom drift (readCurrentStatewould report no Description while state records one). Since this file is in theinteg-destroymarkgate scope, the change needs a real-AWS integ before merge.(4) BUG:
AWS::CDK::Metadatatriggered a misleading warning on every deploy. The deploy-start create-only DescribeType prefetch iterated the RAW template type set, so it always included theAWS::CDK::Metadatasentinel CDK injects into every synthesized template. That type has no CloudFormation registry entry, so the lookup always failed — burning one API call and printingFailed to resolve create-only properties for AWS::CDK::Metadata ... Grant cloudformation:DescribeType ..., naming a pseudo-resource the user cannot act on and implying a missing IAM permission that is not missing. The diff / type-validation / property-validation passes already excluded it; the prefetch did not. Rather than adding a third inline literal, the exclusion became a sharedhasNoRegistrySchema(resourceType)predicate indescribe-type.tscoveringCustom::*,AWS::CloudFormation::CustomResource(previously a private helper insidecreate-only-properties.ts), andAWS::CDK::Metadata. Both DescribeType-backed resolvers short-circuit on it —write-only-properties.tshad no custom-resource guard at all, which was unreachable rather than wrong, and now shares the one list — and the prefetch filters through it as the cheap outer guard.40 unit tests added across the four fixes, taking the suite from 8133 to 8172. No CLI flag, dependency, or state-schema change.
✅ IAM-propagation retries get their own dense backoff schedule, instead of overshooting on the generic exponential one —
src/deployment/retry.ts,src/deployment/retryable-errors.ts,tests/unit/deployment/retry.test.ts,tests/unit/deployment/retryable-errors.test.ts,docs/troubleshooting.md. Measured problem: acdkd deploy --verboseof a stack with one IAM Role + 3 InstanceProfiles + 3t3.microinstances spent ~10.2s of a 25.9s deploy (9.92s -> 20.12s) sitting in retry backoff. All three instance profiles were created at 7.12s;RunInstancesfired ~2.8s later and AWS rejected each instance three times withValue (BenchEc2-Instance1InstanceProfileC04770B7) for parameter iamInstanceProfile.name is invalid. Invalid IAM Instance Profile name. The generic schedule retried at 9.92s (+1s), 11.26s (+2s) and 13.97s (+4s); IAM had actually propagated somewhere in(13.97s, 17.97s], but the 4s step meant cdkd did not look again until 17.97s and the instance only reached "waiting for running" at 20.12s. The root cause is structural and not a bug in the retry loop: CloudFormation and Terraform are slow enough between resources that IAM propagates on its own, and cdkd outruns it — so cdkd hits this class constantly, and its CADENCE (not its correctness) was wrong. Fix:retryable-errors.tsnow stores the message table as two composed halves — the exportedIAM_PROPAGATION_ERROR_MESSAGE_PATTERNS(the 24 just-created-IAM-entity signals:Invalid IAM Instance Profile,cannot be assumed,not authorized to perform,Policy Error: PrincipalNotFound,Caught ServiceAccessDeniedException, ...) plus a private non-propagation half — spread into the same singleRETRYABLE_ERROR_MESSAGE_PATTERNSexport, so retryability keeps ONE source of truth and the newisIamPropagationError(message)predicate only selects a CADENCE (a misfiled pattern can change the backoff shape, never whether an error is retryable at all).withRetrypicks the schedule per attempt: propagation errors back off0.25s -> 0.5s -> 1s -> 2s -> 2s ...(IAM_PROPAGATION_INITIAL_DELAY_MS250,IAM_PROPAGATION_MAX_DELAY_MS2_000,IAM_PROPAGATION_MAX_RETRIES26 = 47.75s of sleep), everything else keeps the generic1s -> 2s -> 4s -> 8scapped at 8s over 8 retries (47s of sleep). The dense budget is deliberately >= the generic one — a tighter probe grid must not shrink the window in which propagation can still be caught, or the fix would trade latency for flakiness, a strictly worse bug. Probe grid after the first failure:0.25, 0.75, 1.75, 3.75, then every 2s out to 47.75vs the old1, 3, 7, 15, 23, 31, 39, 47— from 3.75s onwards the new grid is strictly ahead and it never lags the old one by more than 0.75s in the early band. Against the measured timeline the worst-case overshoot past the propagation instant drops from up to 8s (the un-taken next generic step) to ~2.4s (2s cap + the ~0.35s the rejectedRunInstancesitself costs); realistically this recovers ~1-3s per affected deploy, not the full 10.2s — at least 4.05s of that 10.2s was IAM genuinely not having propagated yet, which no schedule can reclaim. Throttling and other transient errors are untouched on purpose: hammering a rate-limited API is harmful, and the 2s floor is chosen so three parallel instances pollRunInstancesat ~1.3 req/s rather than exceeding its ~2 req/s refill. Because the class is re-evaluated per attempt, a throttle encountered mid-propagation backs off exponentially for that attempt — self-correcting if the dense grid ever does provoke one. The dense schedule applies only where the caller left the schedule at its defaults (the deploy engine's create/update path,cdkd drift --revert, the ELBv2 / ServiceDiscovery attribute calls); any explicitmaxRetries/initialDelayMs/maxDelayMs/isRetryablemeans the caller picked its cadence deliberately and gets it verbatim — so the DELETE path's 3 x 5s, the delete-then-re-create sites' ~64s SQS-cooldown budget, anddescribe-type.ts's throttle-only retry are all unchanged. Two sibling IAM-propagation retry loops were deliberately NOT changed:EC2Provider.ensureIamInstanceProfileAssociated's associate/poll loop already ramps linearly from 1s (1,3,6,10,15,... = 45s), so its worst overshoot inside the window that matters is ~3s — a marginal gain that is not worth puttingsrc/provisioning/providers/**into the diff (and its real-AWS integ gates) for; andCustomResourceProvider.invokeCustomResourceWithRetryhas no backoff to tighten at all — it recycles the backing Lambda's execution environment and re-invokes immediately, with the cold start providing the delay. 25 unit tests added — 8 cadence tests inretry.test.ts+ 17 classifier tests inretryable-errors.test.ts, taking the suite from 8108 to 8133 (dense schedule used for the exact measured wire message; generic schedule preserved for aRate exceededthrottle and for a name-onlyThrottlingException; per-attempt re-classification when a throttle interrupts a propagation retry; total-sleep budget assertion of 47.75s >= 47s; caller-supplied schedules honoured verbatim; every propagation pattern round-trips through both classifiers), using the existingsleeptest seam so nothing waits for real.
Recently Implemented (2026-07-28):
- ✅
AWS::CloudFront::OriginAccessControlgets an SDK Provider, taking it off the Cloud Control polling path —src/provisioning/providers/cloudfront-oac-provider.ts(new),src/provisioning/register-providers.ts,src/provisioning/property-coverage.generated.ts,tests/fixtures/cfn-schemas/AWS-CloudFront-OriginAccessControl.json(new),tests/unit/provisioning/cloudfront-oac-provider.test.ts(new),docs/supported-resources.md,docs/import.md. Why:S3BucketOrigin.withOriginAccessControl()is the standard way to write CloudFront + S3 in CDK, and the OAC it synthesizes was the ONLY resource in such a stack without an SDK Provider — so it fell through to the Cloud Control API and paid ProgressEvent polling (measured ~2.3s on the deploy critical path: 0.43s submit, two polls, 0.33s read-back) for aCreateOriginAccessControlcall that returns synchronously. The Distribution references the OAC, so that latency delayed everything downstream. Same shape as theAWS::EC2::EIPprovider (PR #1175), which cut ~23s to ~2.4s.createcallsCreateOriginAccessControland records the generatedIdas the physical id;updatefetches the currentETagwithGetOriginAccessControland passes it asIfMatchtoUpdateOriginAccessControl(every config field is mutable in place, so an OAC is never replaced);deletedoes the sameGet-then-DeleteIfMatchdance and treatsNoSuchOriginAccessControlfrom EITHER call as idempotent success, but only behind the sharedassertRegionMatch()guard so a wrong-region destroy cannot silently strip the resource from state;getAttribute('Id')returns the physical id with no AWS call (Idis the type's only read-only attribute AND itsprimaryIdentifier);readCurrentStatemaps the AWS-currentOriginAccessControlConfigback to CFn shape forcdkd drift(field names are identical on both sides, absent optionals dropped so they cannot fire phantom drift);importis explicit-override only, verified with a singleGetOriginAccessControl(OACs carry no tags and the config'sNameis a display field AWS does not accept as a lookup key). The CFn schema has exactly two top-level properties, so property coverage is complete with nounhandledByDesignentries:OriginAccessControlConfigis handled,Idis read-only. A missing required config field (Name/OriginAccessControlOriginType/SigningBehavior/SigningProtocol) raises aProvisioningErrornaming every offender before the AWS call, rather than letting the SDK reject with an opaque serialization error. - ✅ Deploy preflight trimmed from 6 sequential state-bucket round trips to 3 (issue #1283) —
src/cli/config-loader.ts,src/utils/expected-bucket-owner.ts,src/state/s3-state-backend.ts,src/cli/commands/deploy.ts,tests/unit/cli/config-loader.test.ts,tests/unit/utils/expected-bucket-owner.test.ts,tests/unit/state/s3-state-backend.test.ts. Why: everycdkd deploypaid a fixed cost before the first resource was touched, and the state-bucket preflight — which runs concurrently with synth and measured as the LONGER of the two poles — was almost entirely serialized AWS calls:sts:GetCallerIdentity, thenHeadBucketoncdkd-state-{acct}, thenHeadBucketon the legacycdkd-state-{acct}-{region}, thenGetBucketLocation, then a THIRDHeadBucketon the bucket just probed, with a freshGetCallerIdentitybehind eachExpectedBucketOwnerheader. Three changes: (1) the two candidate-nameHeadBucketprobes are independent and now run underPromise.all; (2)verifyBucketExists({ existenceAlreadyProbed })drops the duplicateHeadBucketwhen the bucket name came from the DEFAULT-name resolution AND that resolution's own probe came back clean — theGetBucketLocation+ region-correct client rebuild still always runs, and the skip is gated by the exportedstateBucketExistenceConfirmed(resolved), which requires BOTH a'default'/'default-legacy'source ANDprobe === 'ok'.ResolvedStateBucketgained an optionalprobefield ('ok'= 2xx or a 301 that only means "another region";'access-denied'= 403) because the name resolution deliberately treats 403 as "exists": that keeps name selection working for a bucket this identity cannot head, but it is exactly the case where a secondHeadBucketstill earns its round trip — it turns a confusing mid-deploy state-read failure into an up-front "Access denied" before any asset is published. An explicitly-specified bucket (--state-bucket/CDKD_STATE_BUCKET/cdk.json) is never probed at all, so it always keeps the full fail-fast check; (3)ExpectedBucketOwnerresolution is now memoized by the resolved ACCESS KEY ID as well as per client, and the default-name resolution seeds it viarecordResolvedAccountId(stsClient, accountId)with the account its ownGetCallerIdentityjust returned. The security property is unchanged and is what constrains the design: an access key belongs to exactly one account, so the key-id cache answers the same question the per-client cache did; an assumed-role session carries its ownASIA…key and still resolves the PRODUCER account, keeping the cross-accountFn::GetStackOutputRoleArnpath correct; and the seed is keyed by the seeding client's OWN credentials (never a caller-supplied account), so it is a memoization of a call that just happened rather than a global override. Also fixed in passing: the existence probe built its S3 client on the DEFAULT credential chain while the bucket name came from the--profileidentity, so under a profile pointing at another account every probe came back 403 = "exists" by accident; it now reuses the STS client's own credential provider, which is also what makes the skippedHeadBucketgenuinely redundant rather than merely duplicated. Net: 3 fewer sequential AWS round trips per deploy (2GetCallerIdentity+ 1HeadBucket), plus one probe-latency overlap. NOT applied tocdkd destroy/diff/import/state *— they callresolveStateBucketWithDefaultand keep the unconditionalverifyBucketExists, since their preflight is not on a latency-sensitive path. - ✅
AWS::EC2::Instance.AvailabilityZoneis handled, so an ordinary CDK L2 instance stays on the SDK path (issue #1276) —src/provisioning/providers/ec2-provider.ts,src/provisioning/property-coverage.generated.ts,tests/unit/provisioning/ec2-instance-no-wait-gate.test.ts. Why:AvailabilityZonewas in neitherhandledPropertiesnorunhandledByDesign, and CDK's L2ec2.InstanceALWAYS emits it (from the selected subnet), so the #614 silent-drop routing rule sent EVERY ordinary CDK-authored instance to the Cloud Control API. Both paths wait forrunning, so this was never a semantics difference — it was pure overhead: CC's own stabilization detection plus the client-side ProgressEvent poll loop, on a type this provider handles directly. It also left the SDK provider's whole create/update/readback surface (including the #609 security backfill) dead code for L2 users. Verified before implementing by reading aws-cdk-lib 2.244.0'saws-ec2/lib/instance.jsAND by synthesizing a benchmark-shaped L2 instance (t3.micro + IAM role / instance profile + security group + encrypted EBS block device + userData + tags), which emits exactlyAvailabilityZone/BlockDeviceMappings/IamInstanceProfile/ImageId/InstanceType/SecurityGroupIds/SubnetId/Tags/UserData--AvailabilityZoneis the ONLY one of those missing from the handled set (every other unhandled candidate isundefinedunless the user opts in, so synth omits it), which is what makes mapping this one property sufficient. One opt-in still routes to Cloud Control:associatePublicIpAddressmakes CDK dropSubnetId/SecurityGroupIdsand emitNetworkInterfacesinstead, which remains a silent drop (tracked separately). What shipped: abuildPlacementhelper maps the CFn property ontoRunInstances'Placement.AvailabilityZone(omitted entirely when absent, so no pointless emptyPlacement: {}), the property joinshandledProperties, andreadInstanceCurrentStatereverse-maps it out ofPlacementso drift does not report a phantom diff.AvailabilityZonealso joins theReplacementRulesRegistryrule for this type (alongside the pre-existingEbsOptimized, plusImageId/SubnetId/KeyName, which carried the same latent exposure). Thecreate-only-properties.tsDescribeType fallback already classified all four from the registry schema'screateOnlyProperties, but it degrades to an EMPTY list whencloudformation:DescribeTypeis unavailable -- andupdateInstancesilently ignores an AZ / ImageId / SubnetId / KeyName change, so the degraded path would classify in-place and leave state recording the new value while AWS keeps the old one. Before this fix the same template change went down the CC path, where AWS hard-rejected it, so the hand rule is what keeps the degraded-IAM failure mode loud. Routing is sticky: a resource already in state withprovisionedBy: 'cc-api'stays on the CC path, so the improvement applies to newly created instances. Tests: the Placement mapping, the absent-property omission, ahandledPropertiesassertion, afindActionableSilentDropsassertion against the full L2-emitted property set (the layer the routing decision actually reads), thereadCurrentStatereverse map, and the four-property replacement classification. Theec2-instanceinteg fixture now emitsavailabilityZonetoo, so the newPlacementcreate path runs against real AWS and theprovisionedByassertion proves the resource stayed on the SDK path. No CLI flag, dependency, or state-schema change. - ✅ SDK waiter poll caps tightened, so a resource that is already ready is noticed in seconds rather than tens of seconds —
src/provisioning/providers/ec2-provider.ts,tests/unit/provisioning/poll-cap-tightness.test.ts. Why: the AWS SDK's generic waiter backs off asuniform_random(minDelay, min(minDelay * 2^(attempt-1), maxDelay)), so once the schedule saturates, the expected lag between a resource becoming ready and cdkd noticing ismaxDelay / 2. At the SDK defaultmaxDelayof 15s that is 7.5s of pure detection lag per waited resource, paid on every deploy, invisible in logs because nothing is failing — the resource is simply ready and cdkd has not looked yet. A Terraform-comparison benchmark made it measurable: the ec2 scenario lost by ~7.5s, which is exactly one such lag on the critical-path instance. What shipped: EC2 Instancerunning/terminatedwaits move fromminDelay5 /maxDelay15 to 2 / 5, and NAT Gatewayavailable/deletedfrom 5 / 15 to 5 / 10 (NAT genuinely takes ~90s, so a 2s floor would only add API calls; the cap is what matters). Real effect measured cold against Terraform: cdkd sees an EC2 instance reachrunningin 3.7-7.8s where Terraform's coarser poll reports a flat 15s for the same instances. A second, generalised guard:poll-cap-tightness.test.tsgained a rule that scans every{minDelay, maxDelay}waiter config in the provider tree and fails if anymaxDelayexceeds 10s, with a coverage floor of ≥6 sites so the rule cannot silently stop finding anything. The rule was verified the only way such a rule can be trusted — by reverting a real call site back to 5/15 and confirming the suite goes red — rather than against a synthetic fixture that would share the author's blind spots. No CLI flag, dependency, or state-schema change; this changes only how often cdkd asks, never what it waits for. - ✅ Wait semantics made explicit per resource type: ELBv2 LoadBalancer waits for
active, ECS Service keeps its non-wait default behind a new--full-wait, EC2 Instance honors--no-wait(issues #1274 / #1275 / #1277 / #1278) —src/provisioning/providers/elbv2-provider.ts,src/provisioning/providers/ecs-provider.ts,src/provisioning/providers/ec2-provider.ts,src/cli/options.ts,src/cli/commands/deploy.ts,docs/cli-reference.md,README.md,tests/unit/provisioning/{elbv2-loadbalancer-active-wait,ecs-service-full-wait,ec2-instance-no-wait-gate}.test.ts,tests/integration/ecs-fargate/. Why: a Terraform-comparison benchmark surfaced that cdkd's completion definition was decided ad hoc per provider and undocumented. Three concrete defects: (a)ELBv2Providerreturned as soon asCreateLoadBalancerreturned, on the header comment's premise that "ELBv2 Create* APIs are synchronous" — factually wrong for a LoadBalancer, which comes backState.Code: provisioningand 503s on theDNSNamecdkd had just handed downstream, while CloudFormation AND Terraform both wait foractive; (b) the EC2 Instancerunningwait was not gated onCDKD_NO_WAIT, so--no-waitsilently did nothing on any stack containing an instance and the two benchmark columns measured the same thing; (c)docs/cli-reference.mdclaimed default waiting was "the same behavior as CloudFormation", which no longer held. What shipped: ELBv2createLoadBalancerwaits foractiveviawaitUntilLoadBalancerAvailable(maxWaitTime600 matching Terraform'saws_lbcreate timeout,minDelay5 /maxDelay10 per the #1177 poll-cap sweep), gated onCDKD_NO_WAITand placed INSIDE the existing partial-create cleanuptry/catchso a waiter timeout deletes the LB rather than stranding it for the next deploy to hitDuplicateLoadBalancerName; update is untouched (SetSubnetsetc. act on an already-active LB). ECS Service KEEPS its non-wait default — CloudFormation waits for steady state and Terraform'swait_for_steady_statedefaults to false, nothing downstream needs a steady service (Fn::GetAttyieldsName/ServiceArnimmediately), and making it wait would be the one thing that puts Terraform's out-of-the-box default ahead of cdkd's — but the choice is no longer implicit: a new sharedsettleServicehelper prints the exactaws ecs wait services-stable --cluster X --services Ycommand on create AND update, and the new--full-waitflag opts intowaitUntilServicesStable(with a best-effortDeleteService --forcebefore failing a create, same partial-create reasoning as ELBv2).--full-waitand--no-waitare opposite ends of one axis and are rejected as a pair byvalidateWaitFlags, beforeapplyRoleArnIfSetso the rejection really does precede every AWS call. A one-shot post-CreateServicehealth probe was deliberately NOT added: at that moment a healthy and a doomed service are indistinguishable (ACTIVE/runningCount: 0/rolloutState: IN_PROGRESS), so it would warn on every deploy and imply a guarantee it cannot support.docs/cli-reference.mdreplaces the flat--no-waitlist with a six-column wait-semantics table (cdkd's three modes next to CloudFormation and Terraform) under a governing statement that cdkd is template-compatible with CloudFormation but NOT wait-semantics-identical: where the two engines agree cdkd matches them, where they disagree the default takes the dev/test-friendly side and--full-waitopts into the CloudFormation one, with ACM Certificate documented as the deliberate exception (an un-issued cert makes downstream CloudFront / ALB creates fail outright, and Terraform expresses that wait as a separateaws_acm_certificate_validationresource cdkd has no equivalent of). Tests: three new unit suites pin the default wait, the--no-wait/--full-waitgate, the poll caps, the create-side cleanup + its manual-command warning, and the negative contracts (ELBv2 update does not wait; an ECS update-side wait failure does NOT delete the service). Theecs-fargateinteg gained a Phase 0 rejecting--no-wait --full-waitand passes--full-waiton both the create and theCDKD_TEST_UPDATEredeploy, asserting aCOMPLETEDrollout withrunningCount == desiredCountimmediately after deploy returns — the only place the realwaitUntilServicesStablecall is exercised against AWS. A regression the gating itself introduced, fixed here (issue #1279): with therunningwait skipped,createInstance's post-launch IAM-instance-profile association check ran against apendinginstance, and AWS rejectsAssociateIamInstanceProfilefor anything notrunning/stopped. That is not a propagation error the retry loop absorbs -- it stays true until the instance is running, i.e. exactly the wait--no-waitasked to skip -- so the create FAILED and the stack rolled back. Every CDK L2ec2.Instancegets an instance profile, so--no-waitwas broken for all of them. The check is now skipped under--no-waitwith a warning that names the instance and the exactdescribe-iam-instance-profile-associations/associate-iam-instance-profilecommands to verify and repair, since a silently profile-less instance is a worse surprise than an unassigned IP; the default mode still enforces the association, so the fresh-profile propagation race the helper exists to close stays closed. Reachable only in combination with the #1276 routing fix (an L2 instance that still routes to Cloud Control never reaches this SDK code), which is why it surfaced during the benchmark rather than in theec2-instanceinteg. Known behavior change: any stack with an ELBv2 LoadBalancer now takes 90-180s longer on a default deploy. That is the fix working, and it is the clearest evidence the completion-definition policy is not reverse-engineered from desired benchmark numbers. No dependency or state-schema change.
Recently Implemented (2026-07-27):
- ✅ Lambda Function URL update-field removal resets to CFn defaults (issue #1160, lambda-url batch) —
src/provisioning/providers/lambda-url-provider.ts,tests/unit/provisioning/lambda-url-provider-update-removal.test.ts,tests/integration/cloudfront-function-url/(removal phase). Why: a priority-(3) batch of the #1160 umbrella audit.update()passedInvokeMode/CorsintoUpdateFunctionUrlConfigonly when present, and the API MERGES (live-probed 2026-07-27: an update omitting both retains the live values), so a property DROPPED from the template silently kept its old live value while CloudFormation resets it. What shipped (sharedclearOnUpdateRemovalhelper, #1223):InvokeMode->'BUFFERED'(the documented default),Corsremoval ->Cors: {}(live-probed: an empty Cors object clears CORS entirely;GetFunctionUrlConfigomits the field afterwards). The pre-existing Class-2 empty-placeholder sanitize is preserved by normalizing the all-emptyreadCurrentStateCors placeholder to "absent" on BOTH sides — a placeholder new side with real previous CORS still clears, and a placeholder-only previous never manufactures a spurious clear call. Thecloudfront-function-urlinteg gained a baseline URL-config assertion + aCDKD_TEST_REMOVAL=truephase 1b (drop both fields, assertInvokeModeback toBUFFERED+Corsgone). No deferrals:AuthTypeis CFn-required (always sent),TargetFunctionArn/Qualifierare create-only. - ✅ DLM LifecyclePolicy update-field removal resets to CFn defaults (issue #1160, dlm batch) —
src/provisioning/providers/dlm-lifecycle-policy-provider.ts,tests/unit/provisioning/providers/dlm-lifecycle-policy-provider-update-removal.test.ts,tests/integration/dlm-lifecycle-policy/(removal phase 2b). Why: a priority-(3) batch of the #1160 umbrella audit.update()mapped the default-policy shorthand fields intoUpdateLifecyclePolicyonly when present, and the API MERGES (live-probed 2026-07-27: an update carrying only PolicyId retains every live value), so a field DROPPED from the template silently kept its old live value while CloudFormation resets it. What shipped (sharedclearOnUpdateRemovalhelper, #1223):CreateInterval->1,RetainInterval->7,CopyTags->false,ExtendDeletion->false,CrossRegionCopyTargets->[], andExclusions->the per-DefaultPolicyexplicit-empty clear shape (VOLUME:{ExcludeBootVolumes:false, ExcludeVolumeTypes:[], ExcludeTags:[]}; INSTANCE:{ExcludeTags:[]}— the volume-only sub-fields are live-probe REJECTED on IMAGE_MANAGEMENT policies). All resets live-probed as accepted + applied; the service create-defaults empirically match CFn's documented defaults. A KEPT-but-partialExclusionsneeds no #1225-style handling — the API replaces the whole object (live-probed), so pass-through is already sub-field parity. Thedlm-lifecycle-policyinteg gained a second always-DISABLED VOLUME default policy + baseline assertions + aCDKD_TEST_REMOVAL=truephase 2b. Deferred with in-code rationale:Description(UpdateLifecyclePolicy rejects'', no documented clear sentinel — no wire shape can reset it),State/ExecutionRoleArn/PolicyDetails(required-on-create, no CFn default). - ✅ ElastiCache CacheCluster/SubnetGroup update-field removal resets to CFn defaults (issue #1160, elasticache batch) —
src/provisioning/providers/elasticache-provider.ts,tests/unit/provisioning/elasticache-provider-update-removal.test.ts. Why: the priority-(2)elasticachebatch of the #1160 umbrella audit (absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157).updateCacheCluster/updateSubnetGrouppassed optional fields intoModifyCacheCluster/ModifyCacheSubnetGroup, which MERGE (absent = "no change"), so a property DROPPED from the template silently kept its old live value while CloudFormation resets it. What shipped (sharedclearOnUpdateRemovalhelper, #1223): cluster —SnapshotRetentionLimit->0(CFn default; SDK doc: 0 turns backups off),AutoMinorVersionUpgrade->true(service create-default, live-verified 2026-07-27),NotificationTopicArnremoval -> the documented disable sentinelNotificationTopicStatus: 'inactive'(no clear-to-empty input exists; a KEPT ARN is now sent with an explicit'active'status so re-adding a topic after a removal reactivates delivery), andLogDeliveryConfigurationsper-LogType disable entries{ LogType, Enabled: false }for log types dropped from the template (ModifyCacheCluster applies each request entry to only its own LogType, so BOTH whole-property and per-entry removal are covered); subnet group — a removedDescription/CacheSubnetGroupDescriptionresets to the sameSubnet group for ${logicalId}defaultcreateSubnetGroupsynthesizes (create/update parity).readCurrentStatenow gates itsNotificationTopicArnemit onTopicStatus !== 'inactive'so the post-removal read round-trips clean. Deliberately NOT reset (in-code rationale):EngineVersion(no downgrade; removal implies an engine-default version change cdkd must not synthesize — #1222 rationale),CacheParameterGroupName(default.<family>is engine+version dependent),PreferredMaintenanceWindow/SnapshotWindow(AWS-assigned random windows, no documented reset sentinel),IpDiscovery(removal only meaningful ondual_stackclusters whose default AWS does not document; a wrong reset flips client DNS resolution),VpcSecurityGroupIds(#1160-audit UNCERTAIN, existing empty-guard). Tests: the #1157-style trio per API + per-entry log-delivery removal + the notification active/inactive round-trip gate, also pinning the deliberate non-resets (fails-without-fix proven via stash revert: 6 failed / restored 9/9). Live-verified end-to-end via cdkd (us-east-1, rediscache.t3.microsingle node): phase 1 deploy withSnapshotRetentionLimit: 3+ a same-stack SNSNotificationTopicArn(AutoMinorVersionUpgrade omitted — DescribeCacheClusters reads the create defaulttrue), phase 2 addsAutoMinorVersionUpgrade: false, phase 3 DROPS all three plus the subnet-groupDescriptionand DescribeCacheClusters/DescribeCacheSubnetGroups confirm retention0, topic statusinactive, AMVU back totrue, and the reset description — then destroy clean. Part of #1160 (umbrella stays open). No CLI flag, dependency, or state-schema change. - ✅ Neptune + DocDB update-field removal resets to CFn defaults (issue #1160, bundled neptune + docdb batch) —
src/provisioning/providers/neptune-provider.ts,src/provisioning/providers/docdb-provider.ts,tests/unit/provisioning/neptune-provider-update-removal.test.ts,tests/unit/provisioning/docdb-provider-update-removal.test.ts,tests/integration/docdb-neptune/. Why: the priority-(2) neptune/docdb batch of the #1160 umbrella audit — both families are ModifyDBCluster / ModifyDBInstance twins of the RDS batch (PR #1222).updateDBCluster/updateDBInstance/updateDBSubnetGrouppassed optional fields into merge-semantics Modify APIs (absent = "no change"), so a property DROPPED from the template silently kept its old live value — worst caseDeletionProtection: the user removes it, cdkd reports success, but the live cluster (or Neptune instance) still refuses deletion. What shipped: the sharedclearOnUpdateRemovalhelper resets the safely-resettable fields to their documented CFn/API defaults — Neptune cluster:DeletionProtection->false,BackupRetentionPeriod->1,IamAuthEnabled(-> Modify'sEnableIAMDatabaseAuthentication)->false; Neptune instance:DeletionProtection->false,AutoMinorVersionUpgrade->true(CreateDBInstance doc: "Default: true"); DocDB cluster:DeletionProtection->false,BackupRetentionPeriod->1; both families' DBSubnetGroup: a removedDBSubnetGroupDescriptionresets to cdkd's create-timeSubnet group for <logicalId>fallback (CFn declares the property required, so this is create/update parity for cdkd's tolerant template shape). Deliberately NOT reset (in-code rationale per method):EngineVersion(engine-default synthesis),DBClusterParameterGroupName/DBParameterGroupName(engine-version-dependentdefault.*family name),PreferredBackupWindow/PreferredMaintenanceWindow(AWS assigns a RANDOM window at create; no documented reset sentinel),VpcSecurityGroupIds(#1160-audit UNCERTAIN, existing empty-guard),Port/DBPort(engine-dependent default), DocDBMasterUserPassword(secret), DocDB instanceAutoMinorVersionUpgrade(inert — the SDK doc says DocDB never performs minor version upgrades regardless of the value). Tests: the #1157-style trio per API (removed -> exact reset; never-present -> stays absent; mixed -> kept fields pass through), also pinning the deliberate non-resets. The shareddocdb-neptuneinteg gained aCDKD_TEST_REMOVAL=truephase: the baseline deploy setsDeletionProtection: true+BackupRetentionPeriod: 7on both clusters (+IamAuthEnabled: trueon Neptune), the removal redeploy DROPS them, verify.sh polls DescribeDBClusters until both clusters read the CFn defaults, and the destroy runs WITHOUT--remove-protectionas live proof the reset landed (cleanup hardened with best-effort protection flip-offs for aborted runs — the #1222 pattern). No CLI flag, dependency, or state-schema change. - ✅ Cloud Control DELETE treats a handler-reported
ErrorCode: NotFoundas already-gone, and the failed-CREATE remnant cleanup no longer warns on a not-found (issue #1252) —src/provisioning/cloud-control-provider.ts,tests/unit/provisioning/cloud-control-provider.test.ts,.markgate.yml. Why: during the 0727 integ sweep,codedeploy-lambda-deployment-group's failed-CREATE remnant delete FAILED with CodeDeploy'sNo Deployment Group found for name: ...(Status Code 400) — a not-found whose wording contains none of the canonical substrings (not found/does not exist/NotFound) the DELETE idempotency check matched on, so cdkd emitted a misleading "a retry may fail with AlreadyExists until it is removed manually" warning over a benign already-gone state (the in-run retry then created the DG fine). What shipped: (1)delete()'s idempotent-success condition now ALSO accepts the STRUCTURED signal — aCloudControlOperationFailedErrorfrom an async FAILED DELETE carryingccErrorCode === 'NotFound'— under the sameassertRegionMatchguard as the string forms; (2)cleanupFailedCreateRemnant's catch downgrades service-worded not-found messages (new exportedisNotFoundMessage, which adds theno ... foundshape) to an "already gone; nothing to clean up" info — scoped to the remnant path where a false positive only downgrades a warning, NOT used for the main DELETE idempotency decision; (3).markgate.yml'sinteg-destroyscope gainssrc/provisioning/cloud-control-provider.ts(the CC delete/remnant path is deletion logic but was outside the gate's include list). Tests: structured-NotFound DELETE resolves (+ region-guard negative + non-NotFound still throws), both remnant shapes warn-free,isNotFoundMessagepositive/negative table — fails-without-fix proven via sed-swap revert (4 failed, restored 87/87). No CLI flag, dependency, or state-schema change. - ✅ SQS
DeduplicationScope/FifoThroughputLimitreset on template removal (issue #1160 sqs follow-up mini-batch) —src/provisioning/providers/sqs-queue-provider.ts,tests/unit/provisioning/sqs-queue-provider-update.test.ts,tests/integration/fifo-sqs-event-source/. Why: the #1249 review surfaced two SUSPECT entries PR #1240's sqs batch missed: both attributes are CFn "No interruption" in-place updateable (registry-schema createOnly is only[FifoQueue, QueueName]), but neither had aSQS_ATTRIBUTE_REMOVAL_RESETentry, so dropping them from the template silently kept the live high-throughput config (SetQueueAttributesmerges absent attributes) while CloudFormation resets them. What shipped:DeduplicationScope -> 'queue'andFifoThroughputLimit -> 'perQueue'added to the removal-reset map (the documented defaults — a bare FIFO queue reads exactly those values, live-verified 2026-07-27). FIFO-only, same no-guard reasoning asContentBasedDeduplication(the reset only fires when previousProperties carried the key, which only a FIFO queue can). Combined-shape semantics live-probed: both-removed ('queue'+'perQueue'in one call) accepted; only-FifoThroughputLimit-removed with keptDeduplicationScope: 'messageGroup'accepted; only-DeduplicationScope-removed while the template KEEPSFifoThroughputLimit: 'perMessageGroupId'is loudly rejected by AWS (InvalidAttributeValue, queue state unchanged) — that is CFn PARITY (CloudFormation's reset-to-default hits the same service constraint), so the reset is deliberately passed through with NO suppression guard, pinned by a dedicated unit test. Tests: the #1157-style trio per field + the combined one-call shape + the kept-inconsistent parity case (fails-without-fix proven via sed-swap revert: 4 new tests + the round-trip placeholder test failed, restored 21/21). Thefifo-sqs-event-sourceinteg baseline now setsdeduplicationScope: MESSAGE_GROUP+fifoThroughputLimit: PER_MESSAGE_GROUP_IDand theCDKD_TEST_REMOVAL=truephase 1b drops all three FIFO attributes, polling until CBD/scope/limit readfalse/queue/perQueuewith an unchangedCreatedTimestamp(in-place). Live-verified end-to-end against real AWS (us-east-1). Part of #1160 (umbrella stays open). No CLI flag, dependency, or state-schema change. - ✅ Rollback reverse-replacement no longer deletes the live resource on a name-idempotent re-create (issue #1247, rollback sibling of #1238) —
src/deployment/rollback-executor.ts,tests/unit/deployment/rollback-executor.test.ts. Why: the deploy engine's #1238NAMED_REPLACEMENT_IDEMPOTENT_CREATEguard (PR #1248) closed the create-first same-name hazard on deploy, but the rollback executor's reverse-replacement replay carried the SAME shape: it re-creates the OLD resource create-first while the NEW resource is still alive, and a name-idempotent Create API whose old/new resources share a user-supplied name silently returns the LIVE new resource's physicalId as the "re-created old" one — the delete-new step then deleted the very resource just recorded in state (silent resource deletion + state divergence). Reachability is narrow (needs a journaled replacement op — old id != new id — whose old/new share a name, i.e. a--replacedelete-first forward replacement of a name-idempotent type with a non-name-derived physicalId; SQS is structurally immune because its URL is name-derived) but real. What shipped: after the create-first re-create returns, ifcreateResult.physicalId === current.physicalId(and the new resource was NOT already deleted by the collision fallback), the delete-new step is skipped and the op WARN-AND-ADOPTS instead of hard-failing: state keeps the intended post-rollback record (previousState properties + the live physical id), the op is marked replayed with an exit-2 warning naming what was NOT reverted (the old resource's original properties may not have been re-applied) and pointing atcdkd drift/cdkd deploy/ a rename for reconciliation. Deliberately DIFFERENT semantics from the deploy-side hard-fail because rollback is a RECOVERY flow: a segment failure would block the segment pop and strand the user in a replay loop that can never succeed (memory rule: fail-fast on a state the user is re-running to FIX strands the resource). Anupdate()re-apply is deliberately not attempted (the op was classified reverse-replacement precisely because the property is immutable in place), and an automatic delete-first fallback is not taken either (unlike the collision case — where the Create THREW — the Create here RETURNED the only live copy; deleting it on speculation risks total loss with no--replace-style opt-in). The delete-new-first path is exempt (re-acquiring the same id after the new resource is gone is the expected outcome, mirroring the deploy-side guard's delete-first exemption). Tests: same-id shape -> no delete of the live resource, warnings=1 / failures=0, prev-properties state record, op replayed (fails-without-fix proven via sed-swap revert: pre-fix the delete fired); delete-new-first same-id exemption -> no warning; different-id shape pinned unchanged by the pre-existing #1199 suite. No CLI flag, dependency, or state-schema change. - ✅ SQS
ContentBasedDeduplicationclassified as updateable — no more queue replacement on a CBD toggle (issue #1237) —src/analyzer/replacement-rules.ts,tests/unit/analyzer/replacement-rules-sqs.test.ts,src/provisioning/providers/sqs-queue-provider.ts(stale-NOTE removal only),tests/integration/fifo-sqs-event-source/. Why: found during the #1160 sqs-batch live verification. TheAWS::SQS::Queuerule listedContentBasedDeduplicationinreplacementProperties, but CloudFormation documents it as "Update requires: No interruption" and the registry schema'screateOnlyPropertiesis only[FifoQueue, QueueName]—SetQueueAttributesflips it in place on a live FIFO queue (live-verified 2026-07-27 via raw SDK and again viacdkd deploy). The misclassification forced a DELETE+CREATE on any CBD change (message loss, plus the #1238 same-name deletion hazard) and made the #1160 removal reset (SQS_ATTRIBUTE_REMOVAL_RESET'sContentBasedDeduplication -> 'false') unreachable through a plain deploy. What shipped:ContentBasedDeduplicationmoved toupdateableProperties(explicitly classified so the DescribeType createOnly fallback can never override it); the provider's and its unit test's "currently UNREACHABLE via a plain deploy" NOTEs removed.DeduplicationScope/FifoThroughputLimitwere audited in the same pass: they are unlisted in the rule and correctly resolve to in-place via the fall-through + schema fallback (not the same misclassification class — no change). Tests:replacement-rules-sqs.test.tspins CBD as in-place UPDATE +isClassifiedtrue + QueueName/FifoQueue still replacement (fails-without-fix proven via temporary revert: 1 failed / restored 11/11). Thefifo-sqs-event-sourceinteg gained aCDKD_TEST_REMOVAL=truephase 1b that DROPScontentBasedDeduplicationon redeploy and asserts the live queue readsfalse(bounded 60s retry per SetQueueAttributes propagation docs) with an unchangedCreatedTimestamp(in-place, replacement-guarded re issue #1238), then destroys clean. Live-verified end-to-end (deploy -> toggle false -> remove entirely -> destroy, same CreatedTimestamp throughout). No CLI flag, dependency, or state-schema change. - ✅ Same-name replacement fails loudly instead of silently deleting the resource (issue #1238) —
src/deployment/deploy-engine.ts,tests/unit/deployment/deploy-engine-same-name-idempotent-create.test.ts. Why: observed live (2026-07-27, us-east-1): a FIFO SQS queue with an explicitQueueNameunderwent a property-driven replacement; SQSCreateQueueis name-idempotent, so the create-first attempt returned the OLD queue's URL as the "new" physicalId instead of colliding, and the flow's delete-old step then deleted that very queue — deploy reported success, the resource was gone, state still recorded it (silent resource deletion + state divergence). The pre-existingNAMED_REPLACEMENT_COLLISIONhandling (#960 follow-up) only caught Create APIs that THROW "already exists"; name-idempotent Creates sailed past it. What shipped: after the replacement CREATE returns, the engine compares the new physicalId against the old one wherever the old resource was still alive at create time. On a match: (a) property-driven create-first without--replacefails the resource with the actionableNAMED_REPLACEMENT_IDEMPOTENT_CREATEerror (rename / remove the explicit name, or re-run withcdkd deploy --replace) — no delete is issued, the old resource stays live, and its state record is preserved verbatim; (b) under--replacethe engine falls back to the same delete-first + re-create sequence as the collision path (now shared viareplaceDeleteFirstAndRecreate, covering the SQS ~60sQueueDeletedRecentlycooldown); (c)UpdateReplacePolicy: Retainhard-fails (Retain pins the name, and the create-first same-id outcome would otherwise re-adopt the resource Retain just orphaned) — including the--recreate-via-*+ Retain variant, which skips the destroy and would silently re-adopt without applying the new properties. Delete-first paths (--replacefallback,--recreate-via-*without Retain, CC UPDATE-unsupported fallback) are exempt: re-acquiring the same physical id under the same name after the old resource is gone is the expected outcome. Live-verified end-to-end (deploy CBD FIFO queue → CBD removal fails with the guard, queue + state intact →--replacere-run replaces cleanly through the cooldown → clean destroy). - ✅ SecretsManager Secret update-field removal resets to CFn defaults (issue #1160, secretsmanager batch) —
src/provisioning/providers/secretsmanager-secret-provider.ts,tests/unit/provisioning/secretsmanager-secret-provider-roundtrip.test.ts,tests/integration/secrets-dynamic-ref/. Why: the priority-(3)secretsmanagerbatch of the #1160 umbrella audit (absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157).update()gatedDescription/KmsKeyIdon!== undefined(KmsKeyId additionally skipping'') intoUpdateSecret, which MERGES (absent = "no change"), so a property DROPPED from the template silently kept its old live value while CloudFormation resets it. What shipped: both fields route through the sharedclearOnUpdateRemovalhelper (#1223) with clear value''—Description: ''clears the description, andKmsKeyId: ''is the SDK-documented "use the Amazon Web Services managed key aws/secretsmanager" sentinel (live-probed 2026-07-27: accepted, and DescribeSecret afterwards OMITS KmsKeyId — the pristine never-had-a-key shape, fully drift-clean; the previous in-code claim that AWS rejects''as an invalid ARN was empirically false and the comment was corrected;alias/aws/secretsmanageralso works but leaves an explicit alias in DescribeSecret, so''is the strictly better sentinel). The''readCurrentState-placeholder defenses are preserved and strengthened: a placeholder-only previous side fires no reset, and a placeholder never passes through as a customer-key value — which also fixes a latentdrift --revertgap where a console-side KmsKeyId add could not be reverted to the managed key. Tests: the #1157-style trio per field + placeholder-previous no-reset + kept-customer-key pass-through (fails-without-fix proven via sed-swap revert: 3 failed / restored 14/14). Thesecrets-dynamic-refinteg gained baseline Description/KmsKeyId assertions and aCDKD_TEST_REMOVAL=truephase 2 that drops both and asserts DescribeSecret reports the pristine shape (both absent), then destroys clean. Deferred (in-code rationale):Typekeeps its emit-when-present gate — the #1160 UNCERTAIN bucket tracks it (partner-managed secrets, no documented clear sentinel, unprobeable without a partner-linked secret). No CLI flag, dependency, or state-schema change. - ✅ SQS Queue update-attribute removal resets to CFn defaults (issue #1160, sqs batch) —
src/provisioning/providers/sqs-queue-provider.ts,tests/unit/provisioning/sqs-queue-provider-update.test.ts,tests/integration/sns-sqs-event/. Why: the priority-(3)sqsbatch of the #1160 umbrella audit.SQS_ATTRIBUTE_REMOVAL_RESET(the provider's local removal-reset map) covered 9 attributes but missedContentBasedDeduplicationandSqsManagedSseEnabled, so dropping either from the template silently kept the live value (SetQueueAttributes merges absent attributes) while CloudFormation resets them. What shipped:ContentBasedDeduplication->'false'(FIFO-only; the reset can only fire on a FIFO queue because only those can have carried the attribute) andSqsManagedSseEnabled->'true'(SSE-SQS is the service default), PLUS a mandatory mutual-exclusion guard: the SSE reset is skipped when the desired properties carry a non-emptyKmsMasterKeyId— live probe confirmed AWS rejectsKmsMasterKeyId+SqsManagedSseEnabled='true'in one SetQueueAttributes call ("You can use one type of server-side encryption (SSE) at one time"), while the both-removed shape (KmsMasterKeyId=''+SqsManagedSseEnabled='true') is accepted; the empty-string readCurrentState placeholder does NOT suppress the reset. Tests: the #1157-style trio per field + the KMS-guard / empty-placeholder / both-removed cases (two-stage fails-without-fix proof: map entries bind, guard binds). Thesns-sqs-eventinteg gained an SSE-removal L1 queue (SqsManagedSseEnabled: false) and aCDKD_TEST_REMOVAL=truephase 2 that drops the property and asserts the live queue readstrueagain with an unchangedCreatedTimestamp(in-place, replacement-guarded re issue #1238), then destroys clean. Known limitation (filed): theContentBasedDeduplicationreset is unreachable through a plain deploy until issue #1237 (replacement-rules misclassifies CBD as replacement-requiring) is fixed — it is pinned by unit tests + a raw-SDK live probe instead. No CLI flag, dependency, or state-schema change. - ✅ IAM Role update-field removal resets to CFn defaults (issue #1160, iam-role batch) —
src/provisioning/providers/iam-role-provider.ts,tests/unit/provisioning/iam-role-provider.test.ts,tests/integration/iam-role-prefixed-name-update/. Why: the priority-(3)iam-rolebatch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157).IAMRoleProvider.update()gatedDescription/MaxSessionDurationon!== undefinedintoUpdateRole, and that API MERGES (absent = "no change" — live-verified 2026-07-27; the SDK doc's "default value of one hour is applied" sentence is CreateRole text and does NOT apply to UpdateRole), so a property DROPPED from the template silently kept its old live value while CloudFormation resets it. What shipped: both fields route through the sharedclearOnUpdateRemovalhelper (#1223):Description->''(the documented clear sentinel),MaxSessionDuration->3600(the IAM/CFn default). Never-present fields stay absent from the input, and the drift-revertDescription: ''pass-through rationale is preserved. Deferred fields: none — these are the only two UpdateRole fields; AssumeRolePolicy / PermissionsBoundary / policies / tags already handle removal via dedicated Delete/Detach calls. Tests: the #1157-style trio (removed ->''/3600; never-present -> absent; mixed -> kept fields pass through); the pre-existing test that PINNED the silent drop (asserting omission on removal) was converted into the never-present case. Theiam-role-prefixed-name-updateinteg gained aCDKD_TEST_REMOVAL=truephase 3 (kept separate fromCDKD_TEST_UPDATEso the migration-prompt regression assertions stay non-vacuous): phases 1-2 setdescription+maxSessionDuration: 7200, phase 3 drops them and asserts the live role reads cleared / 3600 with an unchanged RoleId, then destroys clean. No CLI flag, dependency, or state-schema change. - ✅
--strict+--ignore-errorsannotation flags onsynth/deploy(issue #1230, CDK CLI parity follow-up to #1228) —src/synthesis/stack-messages.ts(StackMessageOptions+ failAt resolution),src/cli/options.ts(sharedannotationMessageOptions),src/cli/commands/synth.ts+deploy.tswiring,tests/unit/synthesis/stack-messages.test.ts. Why: #1228 shipped the always-on default (fail on error annotations, display warnings/infos) with no knobs; the CDK CLI carries two related global flags cdkd lacked. Flag semantics were verified against the CDK CLI SOURCE (validateMetadataFailAtinaws-cdk/lib/cli/cdk-toolkit.ts) + empirically (the originally-planned--no-validationturned out to control thevalidateOnSynthpolicy-validation path, NOT annotations — the real bypass is the global--ignore-errors). What shipped:processStackMessages(stacks, logger, options?)now logs every message at its level ([Error at /path] …vialogger.error, matching the CDK CLI's log-lines-then-bare-Found errorsoutput shape) and resolves the failure threshold exactly like the CLI: default fails on errors (Found errors);--strictalso fails on warnings (Found warnings (--strict mode); errors win when both exist; infos never fail);--ignore-errorsdisplays everything but never fails ("will likely produce an invalid deployment" — same caveat as upstream); strict overrides ignoreErrors when both are passed (upstream precedence). Both flags registered onsynthANDdeployvia one shared option pair. Tests: strict warning-fail / info-pass / error-precedence, ignore-errors display-but-proceed, combined-flags precedence, plus the reshaped default-path assertions (error lines now logged, bareFound errorsthrown). Live-verified against aws-cdk-lib 2.248.0 side-by-side with the real CDK CLI:--ignore-errorsexits 0 on an addError app (error line still displayed),--strictexits 1 on a warning-only app with the exact upstream message, on bothsynthanddeploy --dry-run. Also restores the #1225 changelog bullet header that a prior merge-conflict resolution had glued into the #1228 entry. No dependency or state-schema change.
Recently Implemented (2026-07-26):
- ✅ CDK annotation messages: fail on
addError, displayaddWarning/addInfo(issue #1228) — newsrc/synthesis/stack-messages.ts,src/synthesis/assembly-reader.ts(StackInfo.messages),src/types/assembly.ts(ArtifactManifest.additionalMetadataFile),src/cli/commands/synth.ts+deploy.tswiring,tests/unit/synthesis/stack-messages.test.ts. Why: cdkd previously ignored CDKAnnotationsentirely — a stack carrying an error annotation (the mechanism L2 constructs/aspects use to mark must-not-deploy configurations) sailed throughcdkd synth/cdkd deploywith exit 0 and got deployed, where the CDK CLI refuses withFound errorsexit 1; warnings/infos were never shown. What shipped:collectStackMessagesreads annotation entries from BOTH cloud-assembly layouts — the artifact's inlinemetadata(older aws-cdk-lib) and the<artifactId>.metadata.jsonside file referenced byadditionalMetadataFile(current aws-cdk-lib; a referenced-but-unreadable side file throws, fail-closed) — intoStackInfo.messages.processStackMessagesprints[Warning at /path] …/[Info at /path] …and throwsSynthesisErrorwith the CDK CLI format ([Error at /path] …+Found errors, exit 1) when any stack in the given set carries an error.synthchecks all synthesized stacks;deploychecks the FINAL deploy set (selection-aware like the #1150 deferred macro expansion — an error in a non-selected sibling stack does not block deploying the selected ones) before macro expansion / assets / locks / any AWS mutation.diff/list/ other synth-driven commands deliberately unchanged (upstreamcdk diffdoes not fail on error annotations either). Live-verified against aws-cdk-lib 2.248.0: error app → synth + deploy exit 1 with the two-line format; warning+info app → both lines displayed, run proceeds; 2-stack app with the error on the non-selected stack → selected stack deploys, targeting the bad stack fails. - ✅ Sub-field-removal classification inside kept config objects (issue #1225, first pass) —
src/provisioning/providers/asg-provider.ts+ecs-provider.ts(comment-only),tests/unit/provisioning/asg-provider-update-removal.test.ts. Why: the #1160 clear-on-removal batches reset TOP-LEVEL property removal; a sub-field dropped from a KEPT config object is the same bug class one level down and had no recorded classification, inviting a future "helpful" normalization that would break CFn parity. What shipped (doc-based classification + pins, no behavior change): ASGInstanceMaintenancePolicykept-partial is REJECTED by AWS (SDK doc: both percentages must be specified) — CloudFormation submits the same partial object, so the loud failure IS parity and pass-through is correct (pinned by a new unit test asserting no sub-field synthesis); ASGCapacityReservationSpecificationkept-partial is UNPROBED (a live probe needs a billed Capacity Reservation) and passes through verbatim (pinned); ASGAvailabilityZoneDistributionhas a single sub-field (no partial shape). ECSDeploymentConfiguration's comment now records that its sub-field merge ALSO makes a kept-partial block silently retain dropped sub-fields — tracked in #1225 with the whole-block reset (both blocked on the same live probes: deployment-type-dependent defaults + circuit-breaker/alarms clear shapes). Remaining #1225 work (narrowed on the issue): those ECS probes, the ASG CapacityReservationTarget probe, and a LambdaImageConfigkept-partial probe. No CLI flag, dependency, or state-schema change. - ✅ Shared
clearOnUpdateRemovalhelper extracted (issue #1223) — newsrc/provisioning/update-removal.ts, edits tosrc/provisioning/providers/{lambda-function,ecs,rds,asg}-provider.ts, newtests/unit/provisioning/update-removal.test.ts,docs/provider-development.md§2a. Why: the #1160 clear-on-removal fix pattern had accumulated four verbatim private copies ofclearOnUpdateRemoval<T>(newValue, previousValue, clearValue)(Lambda #1157, ECS #1164, RDS #1222, ASG #1224), and every remaining SUSPECT-family batch would add another. What shipped: the tri-state resolver (present -> pass through; removed -> explicit reset; never present -> stay absent) now lives once insrc/provisioning/update-removal.tswith the canonical JSDoc; all 41 call sites across the four providers import it, the private copies are deleted, and §2a's reference snippet points at the shared helper. Pure mechanical refactor — no behavior change; the per-provider §2a trio suites still pin the end-to-end semantics and a new module-own test pins the helper's contract directly (incl. falsy-but-present values never being replaced). No CLI flag, dependency, or state-schema change. - ✅ ASG update-field removal resets to CFn defaults (issue #1160, asg batch) —
src/provisioning/providers/asg-provider.ts,tests/unit/provisioning/asg-provider-update-removal.test.ts,tests/integration/launchtemplate-asg-inplace/. Why: the priority-(1)asgbatch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157, pattern twin of the ECS batch #1164).ASGProvider.update()spread every optional field intoUpdateAutoScalingGrouponly when present, and that API MERGES (absent = "no change"), so a property DROPPED from the template silently kept its old live value while CloudFormation resets it to the default. What shipped: theclearOnUpdateRemoval(next, previous, clearValue)helper routes 13 fields, each reset to its CFn default or SDK-documented clear sentinel:HealthCheckType->'EC2',HealthCheckGracePeriod->0,Cooldown/DefaultCooldown(treated as ONE logical field across both template spellings)->300,TerminationPolicies->['Default'],NewInstancesProtectedFromScaleIn->false,CapacityRebalance->false,MaxInstanceLifetime->0(documented clear sentinel),DesiredCapacityType->'units',DefaultInstanceWarmup->-1(documented remove sentinel),InstanceMaintenancePolicy->{MinHealthyPercentage: -1, MaxHealthyPercentage: -1}(documented clear sentinels),CapacityReservationSpecification->{CapacityReservationPreference: 'default'},AvailabilityZoneDistribution->{CapacityDistributionStrategy: 'balanced-best-effort'},DeletionProtection->'none'. Deliberately NOT reset (in-code rationale):DesiredCapacity(CFn leaves capacity unmanaged when absent — scaling policies own it),LaunchTemplate<->MixedInstancesPolicy/VPCZoneIdentifier<->AvailabilityZones(mutually-exclusive pairs — a removal is a switch),ServiceLinkedRoleARN(no clear sentinel),Context(reserved),SkipZonalShiftValidation(transient request flag), andAvailabilityZoneImpairmentPolicy(DEFERRED — no SDK-documented sub-field defaults). Tests: the #1157-style trio (removed -> exact reset; never-present -> stays absent; mixed -> kept fields pass through) + dual-key Cooldown cases (spelling switch is not a removal). Thelaunchtemplate-asg-inplaceinteg gained aCDKD_TEST_REMOVAL=truephase 3 (kept separate fromCDKD_TEST_UPDATEso the #985 only-instanceType-changed assertion stays non-vacuous): phases 1-2 set non-defaultHealthCheckGracePeriod/MaxInstanceLifetime/TerminationPolicies, phase 3 drops them and asserts the live values return to defaults, then destroys clean. No CLI flag, dependency, or state-schema change. - ✅ RDS DBCluster/DBInstance update-field removal resets to CFn defaults (issue #1160, rds batch) —
src/provisioning/providers/rds-provider.ts,tests/unit/provisioning/rds-provider-update-removal.test.ts,tests/integration/rds-aurora/,tests/integration/rds-dbinstance-backfill/. Why: the priority-(2)rdsbatch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157, pattern twin of the ECS #1164 / ASG batches).updateDBCluster/updateDBInstancepassed optional fields intoModifyDBCluster/ModifyDBInstance, which MERGE (absent = "no change"), so a property DROPPED from the template silently kept its old live value — worst caseDeletionProtection: the user removes it, cdkd reports success, but the live cluster/instance still refuses deletion. What shipped: theclearOnUpdateRemoval(next, previous, clearValue)helper resets the safely-resettable fields to their documented CFn/API defaults — cluster:DeletionProtection->false,BackupRetentionPeriod->1,MonitoringInterval->0,EnableIAMDatabaseAuthentication->false; instance:DeletionProtection->false,MonitoringInterval->0,EnableIAMDatabaseAuthentication->false. Deliberately NOT reset (in-code rationale per method):EngineVersion(removal implies an engine-default version change cdkd must not synthesize),ManageMasterUserPassword+MasterUserSecret(off-flip requires a MasterUserPassword),MonitoringRoleArn(no clear API; inert at interval 0),ServerlessV2ScalingConfiguration(engine-mode implications),PubliclyAccessible(context-dependent default),VPCSecurityGroups/VpcSecurityGroupIds(#1160-audit UNCERTAIN, existing empty-guard),AllocatedStorage(no shrink),Port/DBPortNumber(engine-dependent default). Tests: the #1157-style trio per API (removed -> exact reset; never-present -> stays absent; mixed -> kept fields pass through), also pinning the deliberate non-resets. Therds-aurorainteg (ModifyDBCluster) andrds-dbinstance-backfillinteg (ModifyDBInstance) each gained aCDKD_TEST_UPDATE=truephase: phase 1 setsDeletionProtection: true+EnableIAMDatabaseAuthentication: true, phase 2 DROPS both and polls DescribeDB* until both read false, and the phase-3 destroy runs WITHOUT--remove-protectionas live proof the reset landed (cleanup paths hardened with best-effort protection flip-offs for aborted runs). No CLI flag, dependency, or state-schema change. - ✅ CR response-client follow-ups:
setResponseBucketdrops its region-hint parameter + the finally-guard test pin (issue #1202, follow-up to #1195) —src/provisioning/providers/custom-resource-provider.ts,src/provisioning/provider-registry.ts,src/cli/commands/deploy.ts,tests/unit/provisioning/custom-resource-provider-response-bucket-region.test.ts. Why: (1) the #1195 region-hint client (new S3Client({ region: bucketRegion })insetResponseBucket) was built on the DEFAULT credential chain, dropping--profile— and withreuseClientCredentials: truethe region-correction rebuild inherited those default-chain credentials too (pre-existing: the pre-#1195 code built the client identically). The hint also added nothing post-#1195: the lazyensureResponseClient()probe resolves the bucket's ACTUAL region regardless of the starting client's region, and deploy.ts's hint was the deploy region — the shared client's region already. (2) The #1195 hardening's finally-block generation guard (a stale probe settling while a successor probe is in flight must not null the successor's single-flight promise) had no test directly pinning it. What shipped:setResponseBucket(bucket)/ProviderRegistry.setCustomResourceResponseBucket(bucket)take NO region parameter; deploy.ts passes only the bucket. Region correction now always starts from the sharedAwsClients.s3client, so--profile/ static credentials carry into both theGetBucketLocationprobe and the rebuilt client at every call site (deploy / destroy / drift / state / rollback). A new unit test pins the finally-block generation guard (stale probe A settles while successor probe B is in flight → B's promise survives, a third op joins B instead of starting a redundant probe C; break-tested — removing the guard fails it). Tests: the 7-test response-bucket-region suite updated for the new signature + the guard pin. Live-verified via thebench-cdk-sampleinteg run cross-region (AWS_REGION=us-west-2against the us-east-1 state bucket — itsCustom::VpcRestrictDefaultSGcustom resource exercises the presign path without the hint) with a clean destroy. Internal API change only (setResponseBucket/setCustomResourceResponseBucketare not user-facing); no CLI flag, dependency, or state-schema change. - ✅ Custom-resource
ResponseURLpresign region-corrects to the state bucket's actual region (issue #1195) —src/provisioning/providers/custom-resource-provider.ts,tests/unit/provisioning/custom-resource-provider-response-bucket-region.test.ts. Why: the custom-resource response bucket is cdkd's STATE bucket, and the S3 client that places the placeholder object and signs the pre-signedResponseURLPUT was built from the deploy/base region (setResponseBucket(bucket, baseRegion)from deploy.ts, or no region at all from the destroy / drift / state call sites). A pre-signed URL's host is region-specific, so deploying a CR-bearing stack to a region different from the state bucket's actual region (the account-scoped region-free default bucket) failed with an S3 301 PermanentRedirect ("must be addressed using the specified endpoint") on the placeholderPutObject. The state backend (#60), LockManager (#803), and ExportIndexStore (#819) already region-correct via the sharedrebuildClientForBucketRegionhelper (#827); the CR response path was the one remaining un-migrated state-bucket consumer. What shipped: a lazy, memoizedensureResponseClient()(mirroring the siblings'clientResolved/ single-flightresolveInFlightpattern) runs before the first response-bucket S3 operation ingenerateResponseURL, resolving the bucket's actual region via the cachedGetBucketLocationprobe and swapping in a region-corrected client (reuseClientCredentials;tolerateNonStandardClientso test doubles degrade to no-rebuild; the sharedAwsClients.s3original is never destroyed).setResponseBucket'sbucketRegionargument is now only a starting hint (its "state bucket is always in the base region" assumption was wrong), and re-calling it re-arms the probe. All call sites benefit, including the region-less destroy / drift / state ones. Tests: 5 new unit tests (corrected client carries the placeholder PutObject + cleanup + presign; helper receives the option set; null → original client kept; memoization; re-arm on re-set; concurrent ops share one in-flight probe). Live-verified via thecustom-resource-providerinteg run cross-region (AWS_REGION=us-west-2against the us-east-1 state bucket — the exact #1195 repro, which failed pre-fix) with a clean destroy. No new dependency, CLI flag, or state-schema change. - ✅
cdkd rollbackreverse-replacement + opt-in--revert-failed(issues #1198, #1199 — follow-ups to #1183) —src/deployment/rollback-executor.ts,src/deployment/deploy-engine.ts,src/cli/commands/rollback.ts,src/types/rollback-journal.ts; unit tests intests/unit/deployment/{rollback-executor,deploy-engine-rollback-journal}.test.ts+tests/unit/cli/commands/rollback.test.ts. Why: two documented v1 limitations of #1183 — (1) a replacement op rolled back best-effort only (the old physical resource is gone, so replayingprovider.update()with old properties against the NEW resource is guaranteed to throw on the immutable property), and (2) the resource whose op FAILED mid-deploy was left as-is (only completed ops were journaled). What shipped (#1199):classifyRollbackOpnow routes a replacement op (previousState.physicalId !== op.physicalId) to a reverse-replacement: re-CREATE the old resource from its journaledpreviousState(routed via the recorded oldprovisionedBylayer) and delete the new one — create-first, with a delete-new-first + bounded name-release retry fallback when a user-supplied physical name collides.UpdateReplacePolicy: Retainis handled distinctly (reverse-replacement-readopt): the orphaned old resource still exists, so the new one is deleted and state re-adopts the old — a true clean revert. Stateful types (STATEFUL_TYPES) warn loudly that the old data is unrecoverable and the re-created resource starts empty. Applies to BOTH the standalone command and the in-process automatic rollback (shared executor); already-reverted (state at old physical id) →skip-already-done, neither-old-nor-new id →skip-mismatch. What shipped (#1198): the deploy engine journals the failed op(s) on each segment asfailedOperations(ADDITIVE journal field, nojournalVersionbump — old binaries ignore it): logicalId, changeType,previousState, physicalId,provisionedBy, and the intrinsic-RESOLVEDattemptedProperties(snapshotted just before the provider call — load-bearing so a patch-based revert diffs previous-vs-attempted instead of producing an empty patch). A failure before ANY op completed now writes a failed-only segment (previously skipped as empty).cdkd rollback --revert-failed(off by default — the failed resource's remote state is unknown) replays them before the segment's completed ops: failed UPDATE → force-applypreviousState; failed CREATE with a state record → delete; failed CREATE with nothing recorded → warn; failed DELETE → no-op (resource still in place). Without the flag the plan prints a "(left as-is … pass --revert-failed)" hint per failed op. Handled failed ops are stripped from the persisted segment right away and PER-OP (S3StateBackend.setRollbackJournalFailedOperationspersists only the still-pending list — also on the interrupt / partial-failure paths) so a re-run cannot re-issue a revert that already succeeded (the attempted-properties diff side would generate a patch undoing changes that no longer exist). A clean automatic rollback still deletes the journal (failed-op records included) — the flag targets--no-rollback/ interrupted / partially-failed-rollback journals. Tests: classify matrix for both features (Retain/Snapshot/already-done/mismatch; failed CREATE/UPDATE/DELETE variants), replay flows (re-create + delete-new order, collision retry, stateful warning, readopt, delete-new failure leak warning; failed-op force-revert diff sides previous-vs-attempted, partial-failure segment retention), engine journaling (failed op withpreviousState+attemptedProperties, failed-only segment), and command plumbing (--revert-failedon/off/failure). No CLI-breaking change, no state-schema bump, no journal-version bump. - ✅ Standalone
cdkd rollbackcommand to revert a failed--no-rollback/ interrupted deploy (issue #1183) — newsrc/cli/commands/rollback.ts, newsrc/deployment/rollback-executor.ts(extracted fromdeploy-engine.ts), newsrc/types/rollback-journal.ts, plus edits tosrc/deployment/deploy-engine.ts,src/state/s3-state-backend.ts,src/types/deployment-events.ts,src/cli/program.ts,src/cli/commands/deploy.ts,src/cli/commands/export.ts; newtests/integration/rollback-command/; unit tests undertests/unit/{deployment,types,state,cli/commands}/. Why: after a deploy failed with--no-rollback(or was interrupted with Ctrl+C, or its automatic rollback died partway), the only options were fix-forward (cdkd deploy) or clean-up (cdkd destroy) — there was no way to go back to the pre-deploy state (the cdkd equivalent ofcdk rollback/ CloudFormationRollbackStack). What shipped: the deploy engine now persists a rollback journal (s3://bucket/cdkd/{stack}/{region}/rollback-journal.json, a sibling ofstate.json, its ownjournalVersion— NOT a state-schema bump) whenever a deploy ends without a completed rollback: a--no-rollback/ output-resolution failure (reason: no-rollback-failure), a SIGINT (reason: interrupted, so an interrupted deploy is now revertible, not just resumable), and before every automatic rollback (reason: auto-rollback-started, deleted after a clean replay so a rollback that dies partway is resumable). Each journal holds onesegment(a verbatimCompletedOperation[]) per failed attempt; consecutive failed deploys append. The newcdkd rollback [STACK]command (synth-free) loads the journal and replays it newest-first via the sharedrollback-executor.ts, saving state after each op and popping each cleanly-replayed segment; when the oldest replayed segment was the stack's first-ever deploy and state ends empty,state.jsonis deleted too. Flags:--force,--orphan <logicalId>(repeatable, likecdk rollback --orphan),--stack-region,--role-arn,--state-bucket. Exit codes: 0 clean / 2 partial (journal kept, replay is idempotent so re-running is safe) / 1 hard error. The extraction added two behavior fixes shared by both callers: a rolled-back CREATE withDeletionPolicy: Retain/Snapshotis now ORPHANED (not deleted), and replay is idempotent (skips already-reverted / physical-id-mismatched / absent resources). The journal is deleted on the next successful deploy, after a clean rollback, and bycdkd destroy/cdkd state destroy(viadeleteState, which now sweeps the journal key).cdkd exportwarns + confirms if a journal exists.DeploymentRunCommandgained the'rollback'literal (additive, no event-schema bump) socdkd eventsrenders the standalone run. Tests: unit coverage for the executor (deletionPolicy matrix, skip rules,--orphan, ordering, per-op failure counting, interrupt), journal parse/version-guard, the backend journal CRUD +deleteStatesweep, the engine's per-reason segment writes +initialDeploy, and the command's resolution / error / terminal paths; therollback-commandinteg fixture exercises a real--no-rollbackfailure →cdkd rollback→ assert reverted + journal gone (incl. aninitialDeployscenario that deletesstate.json). No state-schema bump. - ✅
AWS::Lambda::EventSourceMappingcachesEventSourceMappingArnforFn::GetAtt(issue #1190) —src/provisioning/providers/lambda-eventsource-provider.ts,tests/unit/provisioning/lambda-eventsource-provider.test.ts,tests/integration/eventsourcemapping-race/. Why:create()/update()recorded only{ Id }, soFn::GetAtt [Esm, EventSourceMappingArn]missed the cached-attribute path; the physical id is the ESM UUID (not ARN-shaped) and the resolver'sconstructAttributehas no ESM branch, so the resolver's shape guard HARD-FAILED the deploy (the #1179 GetAtt-key class, surfaced by the #1187sdk-attr-coveragecritic). What shipped: bothcreate()andupdate()now recordEventSourceMappingArnunder its exact CFn read-only name from the SDK response (createreadsresponse.EventSourceMappingArn;updatealready readupdateResp.EventSourceMappingArnfor the tag diff), so the ARN resolves from cached state. The #1187 lint'sSDK_ATTR_ALLOW_LISTentry that tracked this as a KNOWN GAP was removed, so theaudit:sdk-attr-coverage:checkcritic now verifies the ARN stays cached (a regression re-flags it). Tests: 2 new unit tests assert bothcreate()andupdate()return{ Id, EventSourceMappingArn }; theeventsourcemapping-raceinteg gained aCfnOutputonattrEventSourceMappingArnso the real-AWS deploy exercisesFn::GetAtt EventSourceMappingArn(pre-fix this failed to resolve). No CLI flag, dependency, or state-schema change; theimport()path (override-only) is unchanged. - ✅
AWS::BedrockAgentCore::Runtimeimport()enriches the full read-only attribute set (issue #1188) —src/provisioning/providers/agentcore-runtime-provider.ts,tests/unit/provisioning/agentcore-runtime-provider.test.ts. Why: after #1179, deploy-created runtimes cache the type's read-only attributes under their CFn names, soFn::GetAtt [Runtime, AgentRuntimeArn]resolves from state. An imported runtime (cdkd import --resource <logicalId>=<agentRuntimeId>) recorded only{ AgentRuntimeId }, so the sameFn::GetAttmissed the cache and fell through to the livegetAttribute()path on every reference — correct (the #1179 fix widenedgetAttributeto resolve the full set), just an extraGetAgentRuntimeper distinct attribute reference instead of a cached read. What shipped:import()now issues oneGetAgentRuntimefor the supplied physical id and populatesattributesvia the samebuildAttributes()helpercreate()/update()use, so imported runtimes match deploy-created runtimes' cached-attribute behavior. Enrichment is best-effort: any failure (throttle, transient error, a--resourceid that does not resolve) falls back to the prior minimal{ AgentRuntimeId }record with a debug log — import never fails on the enrichment, and the livegetAttribute()path still resolves the ARN.WorkloadIdentityDetailsis not populated on the import path (GetAgentRuntimedoes not return it — create/update-response field only). Tests: 2 new / rewrittenimport()unit tests assert the enriched attribute map on a successfulGetAgentRuntimeand the minimal fallback on a thrown error. No CLI flag, dependency, or state-schema change; import coverage category is unchanged (still override-only). - ✅
AWS::BedrockAgentCore::Runtimeenriches its read-onlyFn::GetAttattributes under the CFn names (issue #1179) —src/provisioning/providers/agentcore-runtime-provider.ts,tests/unit/provisioning/providers/agentcore-runtime-provider.test.ts. Why:create()/update()recorded the runtime ARN under the state-attribute keyArn, but CloudFormation's read-only attribute name for this type isAgentRuntimeArn. Output / cross-resourceFn::GetAttresolution looks the cached attribute up BY CFn name (IntrinsicFunctionResolver.constructAttributenever calls a provider'sgetAttribute), soFn::GetAtt [Runtime, AgentRuntimeArn]missed the cache, fell through to the unknown-attribute shape guard, and hard-failed because the physical id (the runtime NAME, e.g.cdkd_bench_cdkd-cKobI0ABvH) is not ARN-shaped — aCfnOutputreferencing the ARN broke the deploy. What shipped: a sharedbuildAttributes(response, agentRuntimeName)helper (used by bothcreate()andupdate()) now records the type's registry-schemareadOnlyPropertiesunder their exact CFn names —AgentRuntimeArn/AgentRuntimeId/AgentRuntimeVersion/Status/CreatedAt/WorkloadIdentityDetails(the last two only when the SDK response carries them;WorkloadIdentityDetailsis re-shaped to PascalCase viacamelToPascalCaseKeys), plusAgentRuntimeName.getAttribute()(the live-fetch path used by e.g.cdkd orphan) was widened to the same attribute set via oneGetAgentRuntimecall, keeping the legacyArnname as an alias. Both the container (fromContainerAsset) and S3 (fromCodeAsset) deploy paths are fixed. Tests: 8 new unit tests assert the CFn-named attribute map from create + update (incl. the omit-when-absent case and no leftoverArnkey) and the widenedgetAttributeresolution. No CLI flag, dependency, or state-schema change; existing state files carrying the oldArnkey are unaffected (the next deploy re-writes the attributes).
Recently Implemented (2026-07-23):
- ✅ ECS
readCurrentStatereverse-maps nested objects to CFn PascalCase (issue #1167, read-side follow-up to #1165) —src/provisioning/providers/ecs-provider.ts,tests/unit/provisioning/ecs-provider-readcurrentstate.test.ts,tests/unit/provisioning/ecs-provider-roundtrip.test.ts,tests/integration/ecs-service-update-props/. Why: #1165 fixed the SET path (create/update); its mirror on the READ side (readCurrentState, consumed bycdkd drift) still surfaced the same nested objects in the SDK's camelCase value shape under a PascalCase top-level key (result['DeploymentConfiguration'] = s.deploymentConfiguration->{maximumPercent, ...}), while cdkd's drift baseline uses PascalCase keys (the templateproperties, orobservedProperties).EphemeralStoragewas already re-PascalCased, so the read side was internally inconsistent. The drift comparator descends only into baseline keys, so when a resource's baseline falls back to the templateproperties(PascalCase) — theobservedProperties-absent window (state from an older binary, a capture failure, or before observed-capture existed) —cdkd driftcompared{MaximumPercent}(baseline) against{maximumPercent}(AWS read) and reported phantom drift that wasn't real. What shipped: the read side now reverse-maps SDK camelCase -> CFn PascalCase via the existingcamelToPascalCaseKeys(the inverse of the converter #1165 used) forAWS::ECS::ServiceDeploymentConfiguration/CapacityProviderStrategy/PlacementConstraints/PlacementStrategy+PlacementStrategies/ServiceRegistries/NetworkConfiguration/LoadBalancers,AWS::ECS::TaskDefinitionRuntimePlatform/ProxyConfiguration/PlacementConstraints, andAWS::ECS::ClusterConfiguration/DefaultCapacityProviderStrategy.DeploymentConfiguration.LifecycleHooks[].HookDetails(a free-form document) is preserved verbatim;ProxyConfigurationuses an explicit reverse-map (proxyConfigurationToCfn) because the SDK fieldpropertiesmaps back to CFnProxyConfigurationProperties. Scope note: the wholeContainerDefinitionsblob is still surfaced as raw SDK camelCase (a much larger structure needing the full inverse ofconvertContainerDefinitions, incl. the nestedLinuxParameters/SecretOptions#1165 added) — left as a further follow-up; drift on container-definition sub-fields was already inconsistent before #1165. Migration: no state-schema change; a resource deployed with an OLD binary whoseobservedPropertieswere captured in camelCase will phantom-drift ONCE against the new PascalCase read until its next deploy re-capturesobservedProperties(self-healing) — negligible in practice since these nested fields only became functional in #1165 (any real use requires a re-deploy anyway). Tests: 3 newreadCurrentStateunit tests feed camelCase SDK responses and assert PascalCase output for Cluster / Service / TaskDefinition (incl.HookDetails-verbatim andProxyConfigurationProperties); two pre-existing round-trip tests that asserted the old camelCase read output were flipped to PascalCase. Theecs-service-update-propsinteg gained a phase 1b that asserts the deploy-timeobservedPropertiesbaseline (captured FROM readCurrentState) carriesRuntimePlatform.CpuArchitecturein CFn PascalCase — pre-#1167 the read side emitted camelCase (runtimePlatform.cpuArchitecture) so the PascalCase lookup would be missing; live-verified against real AWS end-to-end with a clean destroy. (A whole-stackcdkd drift-clean assertion was deliberately not used: the same fixture'sContainerDefinitions— out of scope — and the ECS Service's ARN-form physicalId reading back drift-unknown would fail it for unrelated reasons.) No new dependency, CLI flag, or state-schema change. - ✅ ECS provider converts CFn PascalCase nested objects to SDK camelCase (issue #1165) —
src/provisioning/providers/ecs-provider.ts,tests/unit/provisioning/ecs-provider.test.ts,tests/integration/ecs-service-update-props/. Why: unlike most AWS SDK v3 clients,@aws-sdk/client-ecsuses camelCase input shapes while CloudFormation templates carry PascalCase keys.ECSProviderpassed several nested-object / array-of-objects properties (properties['X'] as T) straight into the SDK's camelCase input slots, so the SDK read the camelCase sub-keys it expected, found them absent, and serialized nothing — a silent drop on create AND update (deploy went green, state recorded the intended value,cdkd diffsaid "No changes", so the divergence was permanent and invisible — the SET-path twin of #1160). Confirmed live against real AWS 2026-07-22 forAWS::ECS::Service.DeploymentConfiguration(a customminHealthyPercent/maxHealthyPercent/ circuit breaker deployed as the AWS defaults). Only bit SDK-routed services (a service that trips the #614 CC-fallback routing forwards the full property map to Cloud Control and was unaffected — which is why existing integ coverage missed it). An audit of the whole provider found the same class on four more ECS nested fields, including the high-impactTaskDefinition.RuntimePlatform(aCpuArchitecture: ARM64Graviton task registered as the default X86_64) andEphemeralStorage. What shipped: PascalCase->camelCase converters wired on both create() and update() forAWS::ECS::ServiceDeploymentConfiguration/CapacityProviderStrategy/PlacementConstraints/PlacementStrategies/ServiceRegistries,AWS::ECS::TaskDefinitionRuntimePlatform/EphemeralStorage/ProxyConfiguration/PlacementConstraints/ (per-container)LinuxParameters+LogConfiguration.SecretOptions, andAWS::ECS::ClusterConfiguration/DefaultCapacityProviderStrategy. The pure first-letter-flip fields reuse the shared recursivepascalToCamelCaseKeysconverter (so fields CDK/CFn add later — e.g. the blue/greenStrategy/LifecycleHooks— convert automatically instead of re-introducing the drop), withDeploymentConfiguration.LifecycleHooks[].HookDetails(a free-form__DocumentType) preserved verbatim so its user-defined keys are NOT case-flipped.ProxyConfigurationneeds an explicit converter because the CFn keyProxyConfigurationPropertiesmaps to the SDK'spropertiesfield (not a first-letter flip).NetworkConfiguration/LoadBalancerswere already converted.DeploymentConfigurationremoval-reset stays deferred to #1160 (it merges at the sub-field level, so a correct reset needs the full live-probed default shape); the casing gap that blocked it is now closed, and an absent value still passesundefined(no reset). Audit result: ECS is the only affected provider — every other SDK provider flagged by the raw-nested-pass-through grep (cognito / cloudtrail / apigateway / apigatewayv2 / cloudwatch) wraps a PascalCase-input SDK, so CFn PascalCase matches the wire shape directly and no conversion is needed. Tests: 6 new unit tests feeding CFn PascalCase input and asserting the SDK command receives camelCase (the pre-existing tests hand-fed camelCase and so agreed with the bug — those were flipped to PascalCase), covering all five Service fields on create + update, the TaskDefinition trio (incl. HookDetails-verbatim +ProxyConfigurationProperties->properties), and the Cluster Configuration deep flip. Theecs-service-update-propsinteg fixture (SDK-routed by design) was extended to set a customDeploymentConfiguration(create + a phase-2 change) and a GravitonRuntimePlatform+EphemeralStorage, asserting AWSdescribe-services/describe-task-definitionreturn the custom values (not the defaults) — live-verified end-to-end (deploy -> update -> clean destroy). No new dependency, CLI flag, or state-schema change. - ✅ ECS Service update-field removal resets to CFn defaults (issue #1160, ecs Service batch) —
src/provisioning/providers/ecs-provider.ts,tests/unit/provisioning/ecs-provider.test.ts,tests/integration/ecs-service-update-props/. Why: the priority-(1)ecs Servicebatch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157).ECSProvider.updateService()passedproperties['X'] as T | undefinedstraight intoUpdateService, which MERGES (an absent field = "no change"), so a field DROPPED from the template stayed live on AWS while CloudFormation resets it to the property default — deploy reported success, state dropped the field, nextcdkd diffsaid "No changes", divergence baked in invisibly. What shipped: aclearOnUpdateRemoval(next, previous, clearValue)helper (the ECS twin of Lambda's) sends an explicit reset value when a field was present before and is now absent, for the fields whose reset was live-probed against real AWS (2026-07-22):PlatformVersion->LATEST,HealthCheckGracePeriodSeconds->0,PropagateTags->NONE,EnableECSManagedTags->false,EnableExecuteCommand->false(CFn default; boolean, not live-probed since execute-command needs an SSM task role), andCapacityProviderStrategy/PlacementConstraints/PlacementStrategies->[](empty array is the AWS-documented + live-confirmed clear sentinel; the capacity-provider empty list reverts the service to its launch type).LoadBalancers/ServiceRegistriesalready clear via #975. Fields left untouched (immutable / required / always-carried):ServiceName(immutable, already guarded),Cluster/TaskDefinition/DesiredCount/NetworkConfiguration,SchedulingStrategy(create-only).DeploymentConfigurationremoval is DEFERRED — its correct reset requires the full default shape (live-probed: it merges at the SUB-FIELD level, so a partial reset leavesdeploymentCircuitBreaker/alarmsstuck), and it is entangled with a separate pre-existing CFn-PascalCase (MaximumPercent) -> SDK-camelCase (maximumPercent) nested-object conversion gap (the field is passed raw, unlikeNetworkConfigurationwhich has a converter); fixing it properly means converting the SET path too, out of this batch's scope. Tests: 3 new unit tests (the #1157 trio: removed -> reset / never-present -> absent / kept -> pass-through) + the existingecs-service-update-propsinteg fixture extended with a #1160 removal phase (phase 1 setsPlatformVersion: 1.4.0+HealthCheckGracePeriodSeconds: 30via the L1 escape hatch; phase 2 drops them and asserts AWS reset toLATEST/0). Live-probed reset semantics against real AWS; other #1160 SUSPECT provider families are untouched by this batch. No new dependency, CLI flag, or state-schema change. - ✅ ApiGatewayV2 update-field removal resets to CFn defaults (issue #1160, apigatewayv2 batch) —
src/provisioning/providers/apigatewayv2-provider.ts,tests/unit/provisioning/apigatewayv2-provider-roundtrip.test.ts. Why: the first service-family batch of the #1160 umbrella audit (the absent-field removal silent-drop bug class, reference fixLambdaFunctionProvider#1157). EveryAWS::ApiGatewayV2::*update()passedproperties['X'] as T | undefinedstraight intoUpdateApi/UpdateStage/UpdateIntegration/UpdateRoute/UpdateAuthorizer, all of which MERGE (an absent field = "no change"). So a field DROPPED from the template stayed live on AWS while CloudFormation resets it to the property default — deploy reported success, state dropped the field, nextcdkd diffsaid "No changes", divergence baked in invisibly. What shipped: a sharedclearableUpdate(next, previous, resetValue)helper (the apigatewayv2 twin of Lambda'sclearOnUpdateRemoval) sends an explicit reset value when a field was present before and is now absent, for the fields whose reset was live-probed against real AWS (2026-07-22): ApiDescription/Version->'',DisableExecuteApiEndpoint->false,IpAddressType->ipv4,ApiKeySelectionExpression->WebSocket default; StageAutoDeploy->false; IntegrationDescription->''; RouteAuthorizationType->NONE,AuthorizerId->'',AuthorizationScopes->[],OperationName->''; AuthorizerAuthorizerCredentialsArn/AuthorizerPayloadFormatVersion/IdentityValidationExpression->'',AuthorizerResultTtlInSeconds->0.CorsConfigurationremoval is the one field an emptyCorsinUpdateApidoes NOT clear (live-probed), so it is cleared out-of-band viaDeleteCorsConfiguration.StageVariablesand IntegrationRequestParametersalso merge per-key ({}does not clear), so a newmapWithRemovals(next, previous)helper sends every dropped key with an empty-string value (whole-block removal sends all old keys as''). Fields the underlying API rejects a reset for are deliberately left untouched (and documented inline as matching the same CFn/API constraint):Name/RouteKey/Target/RouteSelectionExpression/IntegrationType/IntegrationUri/IntegrationMethod/PayloadFormatVersion(required per protocol/integration type),IdentitySource/JwtConfiguration(required per authorizer type),EnableSimpleResponses(REQUEST-only — rejects on a JWT authorizer), and StageDescription(UpdateStage silently ignores an empty-string reset) /DefaultRouteSettings(merge-only, no whole-block reset). Tests: 24 new unit tests — the #1157 trio (removed -> reset / never-present -> absent / mixed -> kept fields pass through) across all five APIs, plus the CorsConfigurationDeleteCorsConfigurationpath, per-key + whole-block map clearing, and explicit not-reset assertions for the required/merge-only fields. Live-probed reset semantics against real AWS; other #1160 SUSPECT provider families are untouched by this batch. No new dependency, CLI flag, or state-schema change. - ✅
AWS::Lambda::MicrovmImagedrift detection +--no-waitdocs completion + poll-config hardening (follow-up) —src/provisioning/providers/lambda-microvm-image-provider.ts,tests/unit/provisioning/lambda-microvm-image-provider.test.ts,tests/unit/provisioning/no-wait-doc-coverage.test.ts,tests/integration/lambda-microvm-image/verify.sh,docs/cli-reference.md,README.md,src/cli/options.ts,.claude/rules/providers.md. Why: three more gaps from the MicrovmImage provider: noreadCurrentState(socdkd driftreported the type as drift-unknown), the type was missing from the user-facing--no-waitresource lists (the user caught this — and the audit foundDocDB/Neptune/CertificateManager::Certificatewere ALSO pre-existing gaps in those lists), and the test-only poll-config env vars producedNaN(spurious immediate timeout) on a non-numeric value. What shipped: (1)readCurrentStatemapsGetMicrovmImage+ListTagsback to theName+Tagscdkd stores; the entirewriteOnlybuild config (BaseImageArn/CodeArtifact/Logging/ ...) is declared viagetDriftUnknownPathsso it never fires false-positive drift (GetMicrovmImagecan't read it back). Drift is scoped to the mutable, readable surface (Tags). (2)AWS::Lambda::MicrovmImage(+ the missing DocDB / Neptune / ACM) added to the--no-waittable + intro in cli-reference.md, the README feature bullet, and thenoWaitOptionhelp + JSDoc. A new mechanical testno-wait-doc-coverage.test.tsscans providers forprocess.env['CDKD_NO_WAIT']and fails CI if any handled type is absent from the cli-reference--no-waittable (coverage floor >= 8); step 7 of the "Adding a New SDK Provider" checklist in.claude/rules/providers.mdnames the four doc surfaces. (3) The poll-config reads go throughpositiveIntFromEnv(guardsNaN/ non-positive). Tests: 5 new provider unit tests (readCurrentState Name+Tags / aws:-tag strip / gone -> undefined / getDriftUnknownPaths / poll-config NaN guard) + the mechanical--no-waitdoc test + a real-AWS integ drift phase (deploy -> drift clean -> out-of-band tag mutation -> drift detected -> revert -> clean). No new dependency, CLI flag, or state-schema change. - ✅ Lambda
Environment: {}normalization on update + LoggingConfig removal-reset live verification (issue #1158, follow-up to #1155) —src/provisioning/providers/lambda-function-provider.ts,tests/unit/provisioning/lambda-function-provider.test.ts,docs/provider-development.md. Why: the PR #1157 3-axis review flagged two pre-existing behaviors. (1)Environment: {}(present, noVariableskey — a hand-written L1 / imported-template shape CDK never emits) passed throughupdate()verbatim; live-probed 2026-07-22:UpdateFunctionConfigurationKEEPS the old env vars for a Variables-lessEnvironment, while the template's declarative meaning is "no env vars" — the same absent-means-keep hazard #1155 fixed, one level down. (2) TheLoggingConfigremoval reset{LogFormat:'Text'}was suspected of not restoring a customLogGroupto the/aws/lambda/<fn>default (subfield merge). What shipped: (1)normalizeEnvironmentForUpdaterewrites a Variables-lessEnvironmentto the explicit-clear{Variables: {}}before theclearOnUpdateRemovalpass; populated / absent shapes pass through unchanged. (2) The LoggingConfig suspicion was REFUTED by a live probe (customLogGroupset →{LogFormat:'Text'}-only update →LogGroupreverted to/aws/lambda/<fn>): the API replaces theLoggingConfigobject wholesale, so the existing reset value is correct and no code change was needed. Also:docs/provider-development.mdgains §2a "UPDATE removal semantics — clear-on-removal", codifying the #1155 bug class (merge-vs-full-replace classification, CFn-default reset table, never-synthesize-for-never-set, the sub-structure normalization variant, and the three mandatory unit-test shapes) for every future provider. Tests: two new unit tests (Variables-lessEnvironment→{Variables:{}}on the wire, verified to fail without the fix; populatedEnvironmentpasses through un-normalized). No new dependency, CLI flag, or state-schema change. - ✅
AWS::Lambda::MicrovmImageimport support +--no-waitinteg coverage (follow-up) —src/provisioning/providers/lambda-microvm-image-provider.ts,tests/unit/provisioning/lambda-microvm-image-provider.test.ts,tests/integration/lambda-microvm-image/{verify.sh,lib/lambda-microvm-image-stack.ts},docs/import.md. Why: the initial provider PR shipped with two gaps:import()was unimplemented (socdkd importreported the typeunsupported), and the headline--no-waitbehavior (the reason for the Tier-1 provider over the CC fallback) was unit-tested only, never verified against real AWS. What shipped: (1) an override-onlyimport()— adopt an existing image by ARN via--resource <logicalId>=<arn>(GetMicrovmImageconfirms existence; a bare name is rejected sinceGetMicrovmImagerequires the ARN; returnsnullwhen the image is gone or no ARN is supplied). Moves the type from import.md's "Unsupported" to the override-only adopt-by-ARN list. (2) The integ fixture gained a--no-waitphase: after the waited-create + tags-update + destroy, it re-deploys with--no-wait, asserts the image is stillCREATINGimmediately after cdkd returns (proving cdkd did NOT wait forCREATED, unlike the always-polling CC fallback), waits forCREATED, then destroys clean. Tests: 4 new unit tests (import adopt-by-ARN / not-found -> null / no-override -> null / non-ARN reject) + the real-AWS--no-waitinteg phase. No new dependency, CLI flag, or state-schema change. - ✅ Lambda config-field removal resets to CFn defaults (issue #1155) —
src/provisioning/providers/lambda-function-provider.ts,tests/unit/provisioning/lambda-function-provider.test.ts,tests/integration/lambda-config-field-removal/**. Why: the 2026-07-22 /hunt-bugs sweep confirmed live that removing a previously-setTimeout/MemorySize/Environment(whole block) /EphemeralStoragefrom the template left the old value live on AWS:update()passed those fields straight through asundefined, andUpdateFunctionConfigurationtreats an ABSENT field as "no change" — while CloudFormation resets it to the property default. The deploy reportedupdated+ success, state dropped the field, and the nextcdkd diffsaid "No changes", so the divergence was baked in invisibly. The provider already had the right mechanism (clearOnUpdateRemoval, used byDeadLetterConfig/KMSKeyArn/FileSystemConfigs/ImageConfig/SnapStart/LoggingConfig), but seven fields were missed; the existinglambda-env-removalinteg only covers removing one KEY from a still-presentEnvironment.Variablesmap, not the whole block. What shipped:Timeout(reset3),MemorySize(128),Description(''),Environment({Variables: {}}),Layers([]),TracingConfig({Mode: 'PassThrough'}), andEphemeralStorage({Size: 512}) now route throughclearOnUpdateRemovalinupdate()'sUpdateFunctionConfigurationCommandInput. Never-present fields still passundefined(no spurious reset);Role/Handler/Runtimestay direct pass-throughs (CFn requires them for their package type, so removal is not a valid template transition);Architecturesremoval was already handled on theUpdateFunctionCodepath. Tests: three new unit tests (all-seven removal → exact reset values; never-present → all seven stayundefined; mixed kept/removed → kept fields pass through unchanged while removed ones reset), the first verified to fail without the fix. Real-AWS integtests/integration/lambda-config-field-removal/deploys with six fields set, re-deploys with all six removed (asserts AWS shows Timeout 3 / MemorySize 128 / empty Description / no env vars / EphemeralStorage 512 / TracingConfig PassThrough), then destroys clean. No new dependency, CLI flag, or state-schema change. - ✅
AWS::Lambda::MicrovmImageSDK provider (Tier 1) —src/provisioning/providers/lambda-microvm-image-provider.ts,src/provisioning/register-providers.ts,src/utils/aws-clients.ts,tests/unit/provisioning/lambda-microvm-image-provider.test.ts,tests/integration/lambda-microvm-image/**. Why:AWS::Lambda::MicrovmImage(Lambda MicroVMs, the Firecracker-snapshot compute environment released 2025-09) was only reachable via the Cloud Control fallback. CC works but always polls its request token to a terminal state, socdkd deploy --no-waithad no effect on it (the build is a multi-minute async operation, exactly where--no-waitis useful). What shipped: a dedicated SDK provider on thelambda-microvmsservice (new dep@aws-sdk/client-lambda-microvms, a SEPARATE service model from@aws-sdk/client-lambda).create()maps the CFn-schema PascalCase template (CodeArtifact: {Uri},Tags: [{Key,Value}],Logging: {Disabled: true},Hooks,CpuConfigurations,Resources, ...) to the SDK camelCase shape (codeArtifact: {uri},tags: Record<string,string>,logging: {disabled: {}}, ...), callsCreateMicrovmImage, then pollsGetMicrovmImageuntil the image reachesCREATED(throws onCREATE_FAILED). The poll is gated onCDKD_NO_WAIT— so--no-waitreturns right afterCreateMicrovmImage(stateCREATING) with the image ARN already resolved.update()routes a build-affecting change throughUpdateMicrovmImage(the async rebuildUPDATING -> UPDATED) and defensively refuses an in-placeNamechange (create-only; cdkd'sgetCreateOnlyPropertyPathsalready routes it to replacement).Tagsare reconciled out-of-band viaTagResource/UntagResource(UpdateMicrovmImagehas notagsfield), so a tags-only change updates the tags WITHOUT an image rebuild.delete()callsDeleteMicrovmImageand polls until the image 404s (asyncDELETING -> DELETED) so destroy leaves no orphan. Physical id is the image ARN (primaryIdentifier);getAttributeexposesImageArn/State/LatestActiveImageVersion/LatestFailedImageVersion/CreatedAt/UpdatedAt.getMinResourceTimeoutMs()lifts the deploy engine's per-resource deadline to the poll cap (default 30 min) so a slow build is not truncated. Tests: 14 unit tests (CFn->SDK shape translation for every optional field incl. theLogging.Disabledtagged-union +Tags/EnvironmentVariableslist->record mappings, async CREATING->CREATED poll, CREATE_FAILED/DELETE_FAILED,--no-waitskips the poll, create-onlyNameguard, idempotent NotFound delete). Real-AWS integtests/integration/lambda-microvm-image/uploads a Dockerfile+app.js code artifact to S3, deploys (real Firecracker snapshot build toCREATED), asserts the ARN physicalId +MicrovmImageArnoutput parity +GetMicrovmImagestate, then destroys clean (image 404s, state removed). aws-cdk-lib has noCfnMicrovmImageyet, so the fixture (and real users) declare the resource via theCfnResourceescape hatch. No CLI flag or state-schema change. - ✅ Selection-aware macro expansion + SDK-default region fallback + EarlyValidation retry (issues #1149 / #1150 / #1151) —
src/synthesis/synthesizer.ts,src/synthesis/macro-expander.ts,src/cli/commands/{deploy,diff,destroy,list}.ts,tests/unit/synthesis/{synthesizer-macro-integration,macro-expander}.test.ts. Why: /hunt-bugs sweep 18 hit three macro pre-pass bugs live: (#1150)Synthesizer.synthesize()expanded macros for EVERY assembly stack, socdkd deploy/diffof a macro-FREE stack failed (or paid CFn round-trips) whenever a sibling carried aTransform, andcdkd listof a macro app required an AWS region + CFn access for names that come from the manifest; (#1149) the expansion region chain checked only--region/env/stack-env and hard-errored for users whose region lives in~/.aws/config, even though the STS hop in the same function resolves that profile region fine; (#1151) AWS'sAWS::EarlyValidation::ResourceExistenceCheckhook rejected the transient changeset intermittently (2 consecutive diff failures, then a clean pass minutes later with the same resources), failing whole runs with no retry. What shipped: (1)SynthesisOptions.deferMacroExpansionskips the pre-pass insidesynthesize();expandMacrosForStacksis now PUBLIC anddeploy/diffcall it after stack selection with exactly the final target set (deploy: incl. auto-included dependency stacks), whilelistanddestroydefer and never expand (destroyworks off cdkd state and its cross-stack ordering scan reads the raw templates' literalFn::ImportValue/Fn::GetStackOutputmarkers, so a macro stack stays destroyable even when expansion would fail);listStacks()defers too. Whole-app consumers (synth,import,export,publish-assets,local) keep the expand-everything default. The STS hop for the default state bucket moved insideexpandMacrosForStacksand runs only when a selected stack actually carries a macro (a plain-a cdk.outread no longer pays STS). (2) The macro region chain falls back toresolveSdkDefaultRegion(profile)— a throwaway STS client'sconfig.region()provider, i.e. the exact chain every provisioning client uses — before hard-erroring; the synth-branchCDK_DEFAULT_REGIONresolution gains the same fallback (matches the CDK CLI, which passes the profile-resolved region to the app subprocess). (3)expandMacrosretries a changeset FAILED whoseStatusReasonnamesAWS::EarlyValidation::*up to 3 attempts (fresh transient stack name each attempt, 2s/4s backoff via theretryDelays.sleeptest seam); non-EarlyValidation failures still surface immediately. One deliberate edge: with expansion now selection-scoped, a macro-generatedOutputs[*].Export.Nameon an UNSELECTED sibling is no longer visible to deploy's cross-stack auto-include inference (it reads the raw template); a rawFn.importValueof a transform-generated export withoutaddDependencywould need the producer deployed explicitly. Tests: 8 new unit tests — SDK-chain region fallback, defer skips expansion, macro sibling outside the selection expands nothing and pays no STS, public post-selection expansion expands only the selected macro stack and resolves the default bucket itself,listStacksneeds no region, EarlyValidation retry succeeds with a fresh stack name + per-attempt cleanup, gives up after 3 attempts with 2s/4s backoff, non-EarlyValidation failure not retried. Live-verified against real AWS with the sweep-18 repro app (4 stacks, one LanguageExtensions). No new dependency, CLI flag, or state-schema change.
Recently Implemented (2026-07-21):
- ✅ Property-driven replacement honors
UpdateReplacePolicy: Retainin the stateful guard —src/deployment/deploy-engine.ts,tests/unit/deployment/resource-replacement.test.ts. Why: theupdate-policy-mutationsinteg FAILed at DEPLOY on 2026-07-21 (surfaced by the coverage sweep). A template immutable-property change (e.g. an S3BucketNamesuffix flip) drives a replacement, and cdkd's property-driven replacement path applies a stateful guard that refuses to DELETE+CREATE a stateful resource without--force-stateful-recreation(data-loss confirmation). But the guard calledisStatefulRecreateTargetForReplace— which conservatively treats ANY S3 bucket as stateful — BEFORE reading the resource'sUpdateReplacePolicy. When the policy isRetain, the old physical resource is ORPHANED (kept, not deleted) on replacement, so there is NO data loss and the guard's "confirm the data loss" rationale does not apply; the guard fired anyway, throwingSTATEFUL_REPLACE_BLOCKEDand blocking the documented orphan-on-replace path (Retaining old ... - UpdateReplacePolicy: Retain). What shipped: theupdateReplacePolicyread is hoisted above the guard and the guard is skipped when it equals'Retain'(the same value the create-first replacement path already checks to retain vs delete the old resource, so the two decisions can no longer disagree).'Snapshot'is deliberately NOT exempted — the property-driven path does not implement snapshot-on-replace and falls through to the DELETE branch, so its data really would be lost. The parallel--replaceopt-in guard is unchanged: its fallback unconditionally deletes the old resource first regardless ofUpdateReplacePolicy, so its data-loss guard is correct there. Tests: a new unit test asserts a Retain-policy S3 bucket replacement proceeds without the flag (create called, delete NOT called); live-verified via theupdate-policy-mutationsinteg (deploy proceeds + destroy clean, 0 orphans). No new dependency, CLI flag, or state-schema change.
Recently Implemented (2026-07-20):
- ✅ Per-type Cloud Control operation-timeout floor for slow CREATE/DELETE (OpenSearch domain destroy) —
src/provisioning/slow-cc-operation-timeouts.ts(new),src/provisioning/cloud-control-provider.ts,src/cli/commands/destroy-runner.ts,src/deployment/deploy-engine.ts,tests/unit/provisioning/slow-cc-operation-timeouts.test.ts(new),tests/unit/provisioning/cloud-control-provider.test.ts. Why: theopensearch-domain-getattinteg FAILed at DESTROY on 2026-07-20 — anAWS::OpenSearchService::Domaindeletion routinely runs 15-30 min, but theCloudControlProvider's internal poll cap (MAX_WAIT_TIME_MS) is a flat 15 min, sowaitForOperationthrewDELETE timeout ... after 900swhile AWS was stillIN_PROGRESS, leaving a partially-destroyed stack (the fixture trap cleaned up, but the run could not go green). The 15-min CC inner cap is ALSO shorter than the 30-min outer per-resource deadline (DEFAULT_RESOURCE_TIMEOUT_MS), so every Cloud-Control-routed slow resource silently got HALF the advertised budget. What shipped: a single sharedslowCcOperationTimeoutMs(resourceType, operation)floor table (OpenSearch / Elasticsearch domains at 60 min for CREATE/UPDATE/DELETE; Redshift / ElastiCache ReplicationGroup+CacheCluster / RDS DBInstance+DBCluster at 60 min for CREATE/DELETE) consulted in THREE places so the inner and outer budgets can never drift apart again: (1)waitForOperationnow takesresourceTypeand caps atMath.max(MAX_WAIT_TIME_MS, floor)— a normal type keeps the flat 15 min, a slow type grows to its floor; (2) the destroy-runner outer deadline resolution folds the DELETE floor into itsMath.max(providerMinTimeoutMs, floor, globalTimeoutMs); (3) the deploy-engine outer deadline does the same with the CREATE/UPDATE floor. A user-supplied--resource-timeout <TYPE>=<DURATION>per-type override still wins at both outer sites (explicit escape hatch); the defaultcdkd destroynow simply waits long enough for a slow delete instead of aborting, matching CloudFormation's own synchronous-wait semantics and cdkd's "destroy complete = resource actually gone" contract. RDS / ElastiCache carry SDK providers (only CC-routed via #614) but the outer-deadline sites are provider-agnostic, so the floor also lifts their SDK-path deletes, which are the same slow class. Tests: a pure-helper suite (per-type CREATE/UPDATE/DELETE lookups, the cluster-types-have-no-UPDATE-floor distinction,Math.max-safety) plus two fake-timerCloudControlProviderDELETE tests — a normal type aborts at 900s, an OpenSearch domain is still polling at 20 min and aborts at the lifted 3600s. No new dependency, CLI flag, or state-schema change. - ✅ Import tag-walk migration batch 3 + a
retry.sleeptest seam (issue #1091) —src/provisioning/import-tag-walk.ts, 12src/provisioning/providers/*.ts, ~26 unit-test files. Why: after batch 2 (PR #1119), ~25 providers still hand-rolled the N+1List*+ per-candidate tag-readaws:cdk:pathwalk inimport(), so a single throttled read aborted the wholecdkd importrun; separately, every batch-1/2 wiring test that exercised the throttle path paid a REAL 0.5s backoff sleep because providerimport()signatures expose no retry options to injectretry.sleepthrough. What shipped: 12 more providers migrated onto the sharedimportTagWalkhelper, chosen by throttle exposure starting from the picks named on the issue —stepfunctions-provider.ts(lowercase{key, value}tags re-shaped to{Key, Value}insidetagsOf; the now-dead privatetagsMatchCdkPathdeleted),cognito-provider.ts(map-shaped tags viaObject.entries; theProperties.UserPoolNamename-match rides the walk as a synthetic tag exactly like SNS Topic's batch-2 shape, so a name-only template still resolves with zero per-candidate API calls, and each tagged candidate costs two reads — DescribeUserPool for the ARN, then ListTagsForResource — both inside the retrieddescribecallback),cloudfront-distribution-provider.ts(IsTruncated/NextMarker fold, NoSuchDistribution skip),acm-certificate-provider.ts,cloudtrail-provider.ts(ListTags(ResourceIdList: [arn])single-ARN batch read stays per-candidate),dynamodb-globaltable-provider.ts(same two-read shape as batch-2's DynamoDB Table),eventbridge-bus-provider.ts,eventbridge-rule-provider.ts(templateEventBusNamescopes every ListRules page; tag-read errors still surface un-skipped, as before),firehose-provider.ts(ExclusiveStartDeliveryStreamName/HasMoreDeliveryStreamspagination fold, mirroring Kinesis),wafv2-provider.ts(templateScopeforwarded on every page),s3-directory-bucket-provider.ts(NoSuchTagSet / AccessDenied candidates still skip; deliberately NO 301 skip — directory buckets are zonal), andbudgets-budget-provider.ts(AccountId on every page, constructed-ARN tag reads). Behavior is preserved mechanically: explicit-override / name-fallback branches untouched, not-found skips becamedescribereturningundefined, physicalId/attributes shapes unchanged; the walk adds only the shared throttle backoff, 10-min wall-clock budget, and 1,000-page ceiling. The test-debt item from the issue also ships:importTagWalkTestHooks— a module-level{sleep?}seam inimport-tag-walk.ts(a per-callretry.sleepstill wins) — and a sweep of 14 existing batch-1/2 wiring-test files onto it, cutting the real backoff waits (e.g. the SSM parameter suite dropped from ~510ms to ~5ms of test time; the two EMR instance-fleet/group throttle tests were inspected and left alone — their retries go through the update-path settle polling, notimportTagWalk). Tests: per-provider wiring tests following the batch-2 pattern — a throttled tag read mid-walk is retried and still finds the match, a non-throttling error surfaces with no retry — plus pagination-fold asserts (Firehose exclusive-start name, GlobalTableExclusiveStartTableName, CloudFront marker, EventBridge Rule bus-scoped pages, WAFv2 Scope forwarding), Cognito name-match-short-circuit + map-tag happy path, and an S3 directory-bucket AccessDenied-skip-continues case. NOT live-verified: the tag-match happy path itself is unreachable on cdkd-deployed resources (aws:is an AWS-reserved tag prefix — see the issue's status note); the pagination/tag wire contracts were verified live in PR #1093/#1101. Remaining: ~13 hand-rolled walkers (multi-sub-type providers plus the excluded elbv2/codebuild batched-describe, KMS alias-walk, and FSx parallel-lane shapes), tracked on #1091. - ✅ Integ verify.sh capture-form + function-wrapper gone-probe lint, tree-wide sweep (issue #1120, follow-up to #1097 pattern 2 / PR #1110) —
scripts/check-integ-probe-not-found.ts,tests/unit/scripts/integ-verify-probe-not-found.test.ts, 55tests/integration/*/verify.sh,docs/testing.md,.claude/rules/testing.md,.claude/skills/new-integ/SKILL.md. Why: the #1110 classifier only scanned condition / list-operator positions, so the same silent-pass defect survived in two spellings it never saw: a read-verb command substitution with an error-swallowing fallback (N=$(aws ... --output text 2>/dev/null || echo 0)reads a throttle as "0 remaining"; the tree had ~130 such sites, including post-destroy leak asserts whose fallback literally spelledGONE) and silenced probe wrappers (fn() { aws ... >/dev/null 2>&1; }/ value wrappers with|| truetails — the shape #1110 fixed by hand six times but nothing prevented reintroducing). What shipped: two new classifier categories.blindCaptureProbesflags a$(aws <read-verb> ...)substitution whose failure cannot fail loudly (swallow fallback inside or right after the capture, without stderr routed INTO the capture); plain silenced captures with no fallback stay legal (set -ehard-fails them, only the diagnostic is lost) and the strict stderr-capture idiom ($(cmd 2>&1 >/dev/null || true)) is recognized as strict.silencedFunctionProbesflags bare-statement silenced read probes in function bodies when fully silenced (exit-status wrapper) or swallow-tailed (value wrapper); tail-less value wrappers stay legal. Best-effort cleanup is exempt structurally:set +e[u]..set -e[u]spans, bounded at the enclosing function's close so an unrestoredset +ein an exiting cleanup handler cannot leak over live-phase code; the canonical helper block's lines are skipped by byte-range. The 130-site sweep: mechanical fallback strips onto plain strict captures for live-phase reads,gone_probe-branch rewrites for the 16 sites where not-found is a legitimate outcome (async DynamoDB/RDS/SFN deletes, recovery-window secrets, provisioned-concurrency settle, EC2 terminated-record aging;remove-protectiongained the canonical helper block for this), strict value-wrapper rewrites (find_fixture_*,api_id,find_evaluator_id, ESM list wrappers),gone_probe-backed expected-missing wrappers (queue_url/ssm_value), andset +eubest-effort markers on the seven cleanup helpers that predated the convention. Zero violations tree-wide with NO allowlist entries. Tests: 34 new table tests (positives incl. nested-substitution masking + span bounding; negatives incl. mutation captures,s3 cp, strict idioms), two tree-wide zero-violation tests, and a bash behavioral test proving the strict form propagates values / hard-fails a stubbed throttle while the banned fallback form silently returns the fallback literal. Review hardening (3-axis): the review empirically falsified the "a probe error aborts the caller's$( )capture under set -e" rationale: errexit is CLEARED inside command substitutions (noinherit_errexit), so a multi-statement value wrapper whose probe is not the LAST command silently yielded empty on a probe error. Every intermediateout="$(aws ...)"capture in the tree's wrappers now carries|| return 1(16 sites across 13 fixtures, incl.find_evaluator_id, the 5deletion-ordering-complexfind_*helpers with their innertags=captures, andlist_esms_for_function), enforced by a third new categoryunpropagatedWrapperCaptures(skips last-statement / condition-context /$?-consuming captures; flagslocal V=$(...)outright sincelocalmasks the status). Further: swallow-tail detection extended to|| V=""assignment fallbacks and compact single-line function bodies (both with table tests + tree re-sweep, which surfaced and fixed 15 more sites);set +eucleanup helpers converted to subshell bodies (fn() { ( set +eu; ... ) }) so calling one from aset +eucleanup trap can never re-arm strict mode mid-sweep; everygone_probe-then-requery site gained a TOCTOU guard (canonical not-found on the requery counts as gone, anything else hard-fails); theeventbridge-pipespost-deploy poll andpropagation-races-2post-destroy instance probe gained the gone_probe "not yet"/"swept" branches their siblings had;local V=$(...)/ condition-context bare-silenced captures are documented as a known detector limitation (zero instances today). - ✅ FSx
readCurrentState§3b always-emit placeholders + Class 1 discriminator carve-out (issue #1096 item 2) —src/provisioning/providers/fsx-filesystem-provider.ts,tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts,docs/provider-development.md. Why: the FSx provider had never carried §3b's mandatoryit('emits placeholders for every user-controllable top-level key on AWS minimum response')block (it predates #1089; the provider landed in #1042). #1095 shipped the twodrift --revertround-trip guards but deferred this one, because satisfying it changes WHICH top-level keysreadCurrentStateemits — a behavior change, not a test addition. The deferral grew as the read surface grew (#1095 roughly doubled it with three new variant blocks), so on an AWS-minimumDescribeFileSystemsresponse a file system deployed without e.g.NetworkTypenever carried the key inobservedProperties, and the comparator's state-keys-only walk skipped a console-side ADD forever. What shipped: the four top-level propertiesupdate()can actually mutate are now emitted unconditionally with placeholders —StorageCapacity ?? 0,StorageType ?? 'SSD',FileSystemTypeVersion ?? '',NetworkType ?? 'IPV4'(the two enums use the AWS-documented defaults) — joiningTags, which already always-emitted.FileSystemType/SubnetIds/KmsKeyIddeliberately KEEP theirputIfDefinedguard per §3b's "immutable on create" carve-out (all registry-createOnly;update()rejects any change, so a placeholder buys no drift coverage and anundefined -> [] / ''transition could trip theTOP_LEVEL_IMMUTABLE_PROPSguard on a later deploy);SecurityGroupIds/BackupIdare createOnly AND never returned, so they stay ingetDriftUnknownPaths(). The four<Variant>Configurationblocks take §3b's Class 1 type-discriminator carve-out: at most one is legal for a givenFileSystemType, so emitting all four as{}would makedrift --revertpush a shape AWS rejects.readCurrentStatenow emits EXACTLY the one blockFileSystemTypeselects — unconditionally ({}included, so the always-emit contract holds for the legal block) — and never the other three. This changes one prior behavior: a variant block AWS returns empty is now emitted as{}rather than omitted. Two companion guards ship with it. (1) §3b Class 2 wire-layer sanitize: theStorageCapacity ?? 0placeholder is reachable on a real AWS shape — FSx Lustre Intelligent-Tiering (DataReadCacheConfiguration) provisions no capacity, soDescribeFileSystemsreturns none, which is whyStorageCapacityis neither createOnly nor required in the registry schema.update()now suppresses a non-positiveStorageCapacityinstead of shipping it (folded into thechanged()flag so the no-op early-return sees it too, rather than issuing an emptyUpdateFileSystem); suppressing rather than throwing keeps an otherwise-valid update in the same call working. (2) Unrecognized-discriminator warn: pre-carve-out, an unknownFileSystemTypestill produced a partial snapshot (whatever block AWS returned was emitted regardless of type); post-carve-out it produces none, which reads downstream as "no drift" on a resource cdkd never looked at. The no-block behavior stays (guessing is not better) but now emits alogger.warnnaming the type and stating that its configuration is absent from the drift snapshot. The inline Lustre mapping was extracted to areadLustreConfigurationhelper so all four blocks route through symmetric reverse-mappers. NogetDriftUnknownPaths()contradiction was found — every declared entry is a keyreadCurrentStatestill never emits. §3b gained one clarifying paragraph on the N-way (vs boolean) discriminator shape. Tests: 6 new — the mandatory §3b key-set assertion written once perFileSystemType(LUSTRE / WINDOWS / ONTAP / OPENZFS), each asserting the COMPLETE key list, spot-checking every placeholder value, and asserting the three foreign variant blocks are ABSENT; a Class 1 round-trip guard proving a discriminator-false snapshot replayed throughupdate()ships no foreign variant block (the regression would otherwise pickLustreConfigurationfor a WINDOWS file system viadetectVariantConfigKey's declaration-order walk); an unsupported-FileSystemTypecase emitting no variant block. The pre-existing "omits a variant block entirely when AWS returns it empty" test was inverted to match the new contract. No new resource type, dependency, CLI flag, or state-schema change. - ✅
importTagWalkbatch-2 migration: 12 high-throttle-exposure providers (issue #1091 follow-up) —src/provisioning/providers/{lambda-function,cloudwatch-alarm,logs-loggroup,sqs-queue,sns-topic,iam-role,iam-managed-policy,dynamodb-table,kinesis,s3-bucket,ecr,ssm-parameter}-provider.ts, their unit tests,docs/provider-development.md,.claude/rules/code-layout.md. Why: the shared throttle-tolerantaws:cdk:pathwalk shipped in PR #1093 with two reference callers (EMR Cluster, DocDB); the other ~40 providers still hand-rolled the N+1List*+ per-candidate tag read with NO backoff, so on accounts with many resources of a type (Lambda functions, alarms, log groups, queues, topics, roles, tables, buckets — exactly the types real accounts hold in bulk) a single rate-limited call aborted the wholecdkd importrun. What shipped: behavior-preserving migration of the 12 highest-exposure walkers ontoimportTagWalk. Per-provider semantics preserved inside the callbacks: map-shaped tag responses (LambdaListTags, Logs, SQS) re-shaped to{Key, Value}entries intagsOf; not-found-during-walk candidates still skip (each provider's original skip class:ResourceNotFoundException/NoSuchEntityException/QueueDoesNotExist/RepositoryNotFoundException/ParameterNotFound, and S3's NoSuchTagSet / AccessDenied / cross-region-301 trio); IAM'sIsTruncated-gatedMarkerand ManagedPolicy'sScope: 'Local'AWS-managed exclusion kept; Kinesis'sExclusiveStartStreamName+HasMoreStreamspagination (including the stop-on-empty-page guard) folded intonextMarker; DynamoDB's two-reads-per-candidate (DescribeTable for the ARN, then ListTagsOfResource) both live inside the retrieddescribecallback; S3's single unpaginatedListBucketsbecomes a one-page walk. SNS is the one non-mechanical case: its walk matchesProperties.TopicNameagainst the ARN tail per candidate BEFORE the tag read (and works with no CDK path at all), so the name match is expressed to the walk as a syntheticaws:cdk:pathtag carrying the walk's own lookup key (input.cdkPath, or acdkd:sns-topic-name:sentinel when only a name is present) — a name-only template still resolves with ZERO tag API calls, and a name match still short-circuits the tag read when both are present. One deliberate delta: with neither a cdkPath nor a TopicName, SNS previously paginated all topics matching nothing; it now short-circuits tonullwith no API call (same result, fewer calls). Tests: per provider, two wiring tests following the EMR/DocDB pattern — a throttled per-candidate tag read (HTTP 400 +ThrottlingException) is retried and the match still found; a non-throttling error (AccessDeniedException-shaped) is NOT retried and surfaces with exactly one tag-read call — plus SNS name-path tests (name-only zero-tag-read resolution; name short-circuit with cdkPath present) and an S3 NoSuchTagSet-skip test. Stash-check: the throttle-retry test verified to FAIL against the pre-migration code for the lambda-function / iam-role / s3-bucket samples (the non-retry test passes in both worlds — surfacing unretried errors was already the old behavior). Test-infra gotcha captured in the new blocks:vi.clearAllMocks()does NOT drop unconsumedmockResolvedValueOncequeue entries leaked by earlier tests in the same file, so every added blockmockReset()s the send mock first. Remaining hand-rolled walkers (~25, incl. ACM, CloudFront Distribution, CloudTrail, CodeBuild/CodeCommit, Cognito, EventBridge bus/rule, Firehose, DynamoDB GlobalTable, IAM InstanceProfile, RDS DBProxy family, S3 directory/vectors buckets, Step Functions, WAFv2, Budgets, DLM) stay for later batches;fsx-filesystem-provider.tsis owned by the in-flight #1114 lane and untouched. No new resource type, dependency, CLI flag, or state-schema change. - ✅
Fn::GetAttguard extensions: guarded per-type defaults +--strict-getatt+ deploy-summary fallback count (issue #1111) —src/deployment/intrinsic-function-resolver.ts,src/deployment/deploy-engine.ts,src/cli/options.ts,src/cli/commands/deploy.ts,tests/unit/deployment/intrinsic-getatt-fallback-guard.test.ts(extended),tests/unit/deployment/deploy-engine-strict-getatt.test.ts(new),tests/integration/getatt-fallback-guard/(extended). Why: PR #1108 (issue #1106) guarded onlyconstructAttribute's FINAL unknown-type fallback; the 36 per-type handlers ending indefault: return physicalIdstill shipped a NAME for an unrecognized*Arnattribute, an Output-only bogus GetAtt still exited 0, and the per-resolution warns scrolled away on green deploys. What shipped: (1) the shape guard is extracted into a sharedguardedPhysicalIdFallback(logicalId, attributeName, resourceType, physicalId)helper and EVERY per-type unknown-attributedefault:branch routes through it (same rules:*Arn/*Urlshape mismatch hard-fails, other suffixes warn-and-return); known attributes whose correct value IS the physicalId got explicit cases so they never hit the guard (KMS KeyId,SNS TopicName[new],Cognito UserPoolId[new],EC2 InstanceId[new],LaunchTemplate LaunchTemplateId[new],ECS ServiceArn[new; the provider stores the service ARN as physicalId, so strict mode must not reject the correct fallback on attribute-less imported/legacy state], plus the pre-existingSubnetId/FileSystemId/ApiId/Id/Name/QueueUrl/GroupId/VpcIdcases). (2) New deploy-only flag--strict-getatt: promotes EVERY unknown-attribute physicalId fallback (any suffix, even ARN-shaped) to a hard error AND makes an Output resolution failure fail the deploy (default stays warn-and-skip); the strict output failure fires AFTER provisioning succeeded, so the engine persists the provisioning result FIRST (persistStateAfterOutputFailure: this run's resources +imports/outputReadswith the previously persisted outputs) and THEN rethrows — without this, a FIRST deploy (no incremental ETag saves) would leave every created resource in AWS with zero state written (invisible orphans). Threaded viaDeployEngineOptions.strictGetAtt→ the engine'sIntrinsicFunctionResolverconstructor options; nested-stack child engines inherit it through the options spread. (3) The resolver counts warn-path fallbacks per instance (getPhysicalIdFallbackCount/resetPhysicalIdFallbackCount, reset at eachdeploy()start AND again after the diff phase on the change path, so the count is per-run, never leaks across stacks/tests, and never double-counts a site at diff time and provisioning time), surfaced asDeployResult.attributeFallbackCount, andcdkd deployprintsN attribute resolution(s) fell back to the physical ID (potentially wrong values); re-run with --strict-getatt to fail on theseafter the deployment summary when N > 0. Tests: 22 new/extended unit tests (per-type default hard-fail + known-case exemptions + strict promotion + output strict/default split + counter count/reset) and two new integ phases ingetatt-fallback-guard(strict-mode failure on aBogusNameattribute that default mode warn-passes; default-mode success asserting the summary line). Docs:docs/cli-reference.md--strict-getattsection. - ✅ Generic sparse-ResourceModel GetResource read-back in
CloudControlProvider.create/update(issue #1105 option 1) —src/provisioning/cloud-control-provider.ts,tests/unit/provisioning/cloud-control-provider-sparse-readback.test.ts(new). Why: the "CC-routed type with a sparse CREATEProgressEvent.ResourceModel→ empty stateattributes→Fn::GetAttsilently resolves to the bare physicalId" class had been fixed per-type three times (#984 Backup, #1103 Pipes / S3 AccessPoint / ResourceGroups), and the shape that keeps producing it (a pure-CC daily type nobody has enriched yet) is structurally invisible to thegen-enrichment-coveragecritic. What shipped: a newmergeSparseModelReadbackpass in bothcreateandupdate, running BEFORE the per-typeenrichResourceAttributesswitch so existingif (!enriched['X'])gating composes (one GetResource total, never two). The sparseness predicate is conservative and per-type by construction: the parsed map is sparse only when empty or when every value is a string echo of the physicalId / a segment of a compound|-joined identifier —AWS::ApiGatewayV2::Api's CREATE model (carriesApiEndpoint) never triggers a read-back, while the Pipes / AccessPoint / ResourceGroups / Backup shapes do. On sparse, ONE best-effortGetResourceread-back (reusesreadCcResourceModel, same never-throw contract) merges the AWS-current model over the attributes; on UPDATE this also refreshes attributes a sparse ProgressEvent would otherwise leave stale from CREATE time. All per-type enrichment cases untouched (they still normalize flat keys / compound-id splits / SDK Describe fallbacks). Tests: 8 unit tests — sparse create merges via exactly one GetResource; identifier-echo-only model (incl. compound-id segments) counts as sparse; ApiEndpoint-shaped model spends zero extra calls; read-back throwing still succeeds the create with unmerged attributes; sparse update refreshes; Pipes composition proves the one-call gating; S3 Bucket per-type Arn fallback still applies after the generic pass; a non-string model value counts as real information. No new resource type, dependency, CLI flag, or state-schema change. - ✅ Hard-fail on knowably-wrong
Fn::GetAttphysicalId fallbacks (issue #1106, option 1) —src/deployment/intrinsic-function-resolver.ts,tests/unit/deployment/intrinsic-getatt-fallback-guard.test.ts(new). Why:constructAttribute's final unknown-attribute branch warned ("Unknown attribute X for resource type Y, returning physical ID") and returned the physicalId; in the #1103 incident that shipped four resource NAMES into stack Outputs where ARNs were requested, with a green deploy. What shipped: in that final fallback branch ONLY (after known per-type mappings and enriched attributes are exhausted), an attribute name ending inArnwhose physicalId does not start witharn:, or ending inUrlwhose physicalId does not match^https?://, now throws an actionable error (resource type, logical id, requested attribute, the physicalId that would have been returned, plus an enrichment hint and issue pointer) instead of warning. A physicalId that already IS an ARN / URL passes the shape check and remains a valid fallback (unchanged behavior);Alias/Endpointand every other suffix keep warn-and-return (shape-indistinguishable from a plain name, so false positives are unacceptable).cdkd diffis unaffected: its best-effort resolution catches the throw and renders the raw intrinsic. Tests: 10 unit tests; the 4 throw-asserting ones fail without the fix. - ✅ CC GetResource read-back enrichment for Pipes / S3 AccessPoint / ResourceGroups
Fn::GetAtt(issue #1103) —src/provisioning/cloud-control-provider.ts,tests/unit/provisioning/cloud-control-provider.test.ts,tests/integration/cc-getatt-readback/(new). Why: three more live-confirmed instances of the #984 class, found by /hunt-bugs sweep 16:AWS::Pipes::Pipe,AWS::S3::AccessPoint, andAWS::ResourceGroups::Grouphave no SDK provider (pure Cloud Control) and their async CREATEProgressEvent.ResourceModelis sparse, so stateattributesended up{}andFn::GetAttfell through the resolver'sconstructAttributedefault to the bare physicalId — the resource NAME.GetAtt(Pipe, 'Arn')returnedmy-pipeinstead ofarn:aws:pipes:...:pipe/my-pipe,GetAtt(Ap, 'Arn'/'Alias')returned the access point name instead of the ARN /...-s3aliasalias,GetAtt(Rg, 'Arn')returned the group name. Deploy stayed green (warn-only), silently poisoning Outputs and downstream consumers (IAM policies, alarm actions, S3 clients handed a non-existent alias).AWS::ApiGatewayV2::ApiresolvedApiEndpointcorrectly in the same probe stack — ResourceModel sparseness is per-type, which is why the class keeps resurfacing type by type. What shipped: the Backup-scopedreadBackupResourceModelhelper (PR #992) is generalized toreadCcResourceModel(same CCGetResource+ parse + best-effort-never-throws contract, JSDoc updated) and three newenrichResourceAttributescases overlay the missing readOnly attributes from the read-back, gated on the key attribute being absent so an already-rich ResourceModel costs no extra API call: Pipe (Arn, plusCurrentState/StateReason/CreationTime/LastModifiedTime— the documented GetAtt set), AccessPoint (Arn/Alias/NetworkOrigin), Group (Arn). Per-key literalenriched['...']assignments keep thegen-enrichment-coverageTS-AST parser seeing each case's keys; all three types joinenrichedWithoutCachedSchema(informational — deliberately NO cfn-schemas fixture, per the "no stale fixture files for unregistered types" test). Tests: a new unit describe block mirroring the Backup one — overlay per type, skip-read-back-when-present, only-Alias-missing preserves existing Arn, best-effort failed-read leaves attributes unchanged; 5 tests fail without the fix. Integ: newtests/integration/cc-getatt-readback/— deploys bucket + access point, SQS→SQS pipe (+role), tag-based resource group, then asserts each GetAtt-backed stack output EQUALS the value read back from the service API (equality, not anarn:prefix check) and that the state pipe attribute carries the real ARN; destroy + gone assertions. No new resource type, dependency, CLI flag, or state-schema change. - ✅
cdkd importpersists provider-returned attributes (issue #1098) —src/cli/commands/import.ts,tests/unit/cli/import.test.ts,tests/integration/import-attributes/(new),docs/state-management.md. Why:buildStackStatehardcodedattributes: {}when constructing eachResourceStaterow, so every provider'simport()return value — e.g.EMRClusterProvider'sbuildAttributes(cluster)— was computed and then dropped on the floor.attributesis what backsFn::GetAttresolution against state, so a resource adopted viacdkd importstarted with an empty attribute map while the same resource deployed bycdkd deployhad it populated. Pre-existing and provider-agnostic; surfaced while building the EMR import round-trip integ for #1090, which had to assertobservedPropertiesinstead. The open question, settled: the issue asked whether persisting is right or whether the empty map is deliberate (attributes re-derived on demand rather than trusted from an import-time snapshot). Persisting wins — a deploy-created resource already stores a create-time snapshot, so persisting the import-time one makes an adopted resource state-shape-identical to a deployed one rather than introducing a new staleness class. What shipped:ImportRowcarriesattributes, populated fromResourceImportResultat theprovider.import()call site on theimportedoutcome;buildStackStateuses it. One fix covers every mode — the recursive--migrate-from-cloudformationchild walk calls the sameimportOne/buildStackState, so auto / selective / hybrid / nested cannot diverge. The synthesizedAWS::CloudFormation::Stackrow (cdkd-local ARN, no provider call) correctly keeps{}. Second-order effect, measured rather than assumed:resolveImportedPropertiespassesstackState.resourcesas resolver context, and the resolver readsattributesfor bothFn::GetAttandRef, so populating attributes changes the PERSISTEDpropertiesof imported resources. Pre-fix this did NOT fail —constructAttribute's terminal branch isdefault: return physicalId, so it silently substituted the physical id. Probed against an importedAWS::CodeCommit::Repository(physicalId = repo NAME, noconstructAttributebranch): a consumer{"Fn::GetAtt": ["MyRepo", "Arn"]}persisted as"my-repo"pre-fix and"arn:aws:codecommit:us-east-1:...:my-repo"post-fix. So the change CORRECTS a silently-wrong persisted property and converges import's stored shape onto whatcdkd deploywrites. (The effect only appears for types with noconstructAttributebranch — an SQS Queue probe showed no divergence, which is why the empirical check mattered.) Attribute carry-over guard (found by review): a re-imported row REPLACES the wholeResourceState, so a resource that already had a populated map would have it wiped when the provider returns none. It now falls back to the stored map — but ONLY when the physical id is unchanged, since carrying attributes across a--resource X=<other> --forcere-import would resurrect stale facts about the OLD resource and hand them toFn::GetAtt. Tests: three genuinely fail-to-pass unit tests (attributes persisted; same-physical-id carry-over; downstreamFn::GetAttresolving into persisted properties) plus guard tests for the no-attributes, different-physical-id, and unlisted-resource paths. Integ: newtests/integration/import-attributes/—AWS::IAM::ManagedPolicy(free, instant, and itsimport()returns a non-empty{PolicyArn}) deploy →state orphan→ re-import → assert the persistedattributesmap is non-empty ANDPolicyArnmatches → destroy + GONE assertion.import-nested-stackcannot substitute: its only leaf type isAWS::SSM::Parameter, whoseimport()returnsattributes: {}on both branches, so it passes identically with and without the fix. No new resource type, dependency, CLI flag, or state-schema change. - ✅ Provider-declared unordered plain-string sets in the drift normalizer (issue #1096 item 1) —
src/analyzer/drift-normalize.ts,src/analyzer/drift-calculator.ts,src/cli/commands/drift.ts,src/types/resource.ts,src/provisioning/providers/fsx-filesystem-provider.ts,docs/provider-development.md,.claude/rules/code-layout.md. Why:drift-normalize.tscanonicalized exactly two shapes before the drift comparison — tag lists and arrays whose every element is an AWS id / ARN. Plain-string arrays were deliberately left untouched because a scalar list can be order-significant, but several CFn inputs are semantically UNORDERED sets of plain strings (e.g. FSxWindowsConfiguration.Aliases), so an AWS-side reorder surfaced as phantom drift on a resource nobody touched. Why the obvious local fix is wrong: sorting inside the provider'sreadCurrentStatereverse-mapper looks like a one-liner but breaks theproperties-fallback baseline —runDriftForStackusesobservedPropertieswhen present and falls back to the templatepropertiesotherwise, so for a resource deployed before observed-capture the baseline would be the user's TEMPLATE order while the read side is sorted, MANUFACTURING drift instead of removing it. What shipped: a new optionalResourceProvider.getDriftUnorderedPaths(resourceType): string[](mirroring the existinggetDriftUnknownPathsseam) threaded bydrift.tsintocalculateResourceDrift'soptions.unorderedPathsand applied bycanonicalizeUnorderedArraysAtPathsto BOTH comparison sides — which is exactly the property the provider-local sort loses. Only arrays whose every element is a plain string are sorted, only at declared paths, and a nested array inside a declared path is left alone, so a mis-declared path can never reorder object / mixed-type / array-valued elements. Both provider-declared path lists now share ONE matcher, the exportedmatchesPathPrefix(path, entries)thatdrift-calculator.ts'sisIgnoredPathis a thin alias over, so the two conventions cannot silently drift apart; every entry is a SUBTREE declaration (there is no leaf-only form). One required divergence is documented:isIgnoredPathnever sees a path crossing an array (the comparator compares arrays wholesale viadeepEqual) whereas the unordered walk descends into array elements and gives them the parent's path, so'Items.Aliases'is meaningful forgetDriftUnorderedPathsbut inert as an ignore-path — strictly more permissive.FSxFileSystemProviderdeclares onlyWindowsConfiguration.Aliases.SelfManagedActiveDirectoryConfiguration.DnsIps— which the issue listed as a second confirmed instance — is deliberately NOT declared: the FSx API reference describes it only as "A list of IP addresses of DNS servers or domain controllers", with no statement that order is insignificant, and DNS resolver lists are conventionally preference-ordered. The two failure modes are not symmetric — leaving a genuinely-unordered set undeclared produces a VISIBLE false positive a user can reason about, while declaring an order-SIGNIFICANT list makes real drift SILENTLY INVISIBLE, the worse failure for a drift tool. Same axis that excludes ElastiCachePreferredAvailabilityZones(which AWS does document as positionally node-indexed). The provider docstring carries the citation and the FSx test pinsDnsIpsin AWS order so a future re-add must be deliberate. Tests: command-path tests intests/unit/cli/drift.test.tsdriving the REALobservedPropertiesandproperties-fallback branches (not a proxy flag) plus a negative control proving the clean result comes from the declaration being threaded — all verified to FAIL when thedrift.tswiring is removed;drift-normalize.test.tscases for declared leaf, undeclared path, subtree-prefix entry, non-string / mixed-type / empty / array-of-arrays arrays, and array-element path inheritance; amatchesPathPrefixblock pinning the shared rule; and an FSx test that routes realreadCurrentStateoutput through the real canonicalizer so a path-spelling mistake fails loudly instead of silently no-opping. Known gap: the FSx-declared path itself is unit-tested only — the live integ (drift-revert-arrays) exercises the normalizer and the provider-declares-nothing non-regression path, but a real FSx file system was out of scope. Cross-provider audit (reported, not implemented — each needs per-type verification against the same "is it documented as unordered?" bar): the strongest candidate isAWS::Cognito::UserPool, wherecognito-provider.tsalready carries a comment self-flagging this exact false-positive class onEnabledMfasand naming "a future order-insensitive array compare in drift-calculator" as the fix — that seam now exists. Also ACMSubjectAlternativeNames, CloudFrontAliases/TrustedKeyGroups, Route53ResourceRecords, IAM ManagedPolicyGroups/Roles/Users, WAFv2TokenDomains, ASGAvailabilityZones. Item 2 of #1096 (the FSx §3b placeholder test) is untouched and the issue stays open for it. No new resource type, dependency, CLI flag, or state-schema change. - ✅ Shared throttle-tolerant tag walk for provider
import()(issue #1091) + EMR fleetSUSPENDEDfail-fast (issue #1092 item 2) — newsrc/provisioning/import-tag-walk.ts,tests/unit/provisioning/import-tag-walk.test.ts,src/provisioning/providers/emr-cluster-provider.ts,src/provisioning/providers/docdb-provider.ts,src/provisioning/providers/emr-instance-fleet-config-provider.ts,src/provisioning/import-helpers.ts,docs/provider-development.md. Why: every provider that supports tag-basedimport()auto-lookup does an inherent N+1 read —List*to enumerate candidates, then oneDescribe*/ListTagsForResourceper candidate to read tags, because list summaries do not carry tags. Each provider hand-rolled that loop with NO throttle handling, so on an account with many resources of a type a single rate-limited call aborted the wholecdkd importrun. Separately,EMRInstanceFleetConfigProvider'sFAILED_STATEScontained onlyTERMINATED, so a fleet that enteredSUSPENDED(a resize that could not complete — existing instances keep running but AWS can add/remove none) polled to the fullmaxWaitMstimeout instead of failing fast with the service's own state-change reason. What shipped:importTagWalk({cdkPath, listPage, describe, tagsOf, logicalId?, retry?})centralises the pagination + per-candidate describe +aws:cdk:pathmatch and wraps BOTH callbacks in the deploy engine'swithRetry(0.5s → 1s → 2s → 4s → 5s, 5 retries), returning{summary, detail}for the first match ornull(short-circuiting with zero API calls on an emptycdkPath, replacing theif (!input.cdkPath) return nullguard each provider carried inline). Its classifierisThrottlingLikeError(also exported for providers whose tag API does not fit the callback shape) DELEGATES the error +.causetraversal toretryable-errors.ts's newly-exportedisThrottlingErrorand only adds theRate exceededmessage backstop — an earlier revision of this PR re-implemented the walk while sharing only the tables, and the two copies had ALREADY drifted (the helper checked$metadataat every cause depth, the write path only at depths 0-1), so the status check was folded into the one shared walk;isRetryableTransientErrorconsequently also now catches a retryable HTTP status nested deeper than one cause link. The classifier's POLICY split stays deliberate: the write path's eventual-consistency phrasings (does not exist,not authorized to perform) are terminal on a read-only walk and now surface immediately instead of burning the full backoff budget per candidate. BecausewithRetry's backoff is PER CALL, the walk carries its own limits, surfaced asImportTagWalkLimitErrorwith a--resource <logicalId>=<physicalId>escape-hatch pointer:retry.maxWalkMs(default 10 min — a sustained throttle would otherwise degrade into(pages + candidates) x ~12.5s, ~42 min of near-silent retrying for 200 candidates) andretry.maxPages(default 1,000 — a service returning a non-advancing pagination token would spin forever).retry.isInterrupted/onInterruptedforward towithRetryso Ctrl-C during a throttled sleep is honored as on the deploy path, andretry.loggerdefaults to the process logger so throttled retries AND every skipped candidate are visible under--verbose(a provider whosedescribemaps a broad error class toundefinedwould otherwise silently turn a genuine failure into "no match", and cdkd would CREATE a duplicate resource instead of adopting). Migrated callers:EMRClusterProvider.import(ListClusters+DescribeCluster) andDocDBProvider's three sub-types (DescribeDBInstances/DescribeDBClusters/DescribeDBSubnetGroups+ a new sharedlistTagsForResourcehelper). Batched tag reads are deliberately NOT modelled — neither migrated service offers one; a service that does (e.g. CodeCommitBatchGetRepositories) can satisfy several candidates inside its owndescribecallback. Separately, BOTH halves of the symmetric EMRSUSPENDEDdefect are fixed:SUSPENDEDmeans a resize could not complete (existing instances keep running, AWS can add or remove none), so the wait polled to the fullmaxWaitMstimeout instead of failing fast with the service's own state-change reason.emr-instance-fleet-config-provider.ts'sFAILED_STATESgoes{TERMINATED}->{SUSPENDED, TERMINATED}andemr-instance-group-config-provider.ts's goes{ARRESTED, TERMINATED, ENDED}->{ARRESTED, ENDED, SUSPENDED, TERMINATED}. Issue #1092 item 2 names only the fleet, butSUSPENDEDis a validInstanceGroupStatetoo and the group's resize wait carries the identical defect — shipping one half while declaring the other correct would have been worse than fixing neither. The two sets legitimately differ:ARRESTED/ENDEDareInstanceGroupStatemembers that do NOT exist for fleets, soTERMINATEDis the only state the two enums share (the fleet set is the ANALOGUE of the group set, not a copy — an earlier revision's comment claiming they match was false and is corrected). The fail-fast is scoped to the CREATE path plus post-transition resizes, NOT the first poll of a resize:SUSPENDEDis precisely the state a user re-runscdkd deployto RECOVER from, and the pre-resize read lag both providers already document (right afterModify*the group/fleet still reports its PRE-modify state) means poll #1 of the recovery resize still readsSUSPENDED. Enforcing the failed-state check there would abort the very resize that fixes the resource, on every attempt, leaving it permanently unrecoverable through cdkd — a strictly worse outcome than the slow-timeout bug being fixed. SowaitForGroupReady/waitForFleetReadytake atoleratesStaleFailedStateflag (true only at the resize call sites) that suppresses the check until the resource is observed LEAVING its initial state; from then on it is enforced normally, so a resize that transitionsRUNNING -> RESIZING -> SUSPENDEDstill fails fast. A resource that never leaves its initial state falls through to themaxWaitMstimeout — the pre-fix behavior. Note the asymmetry that makes this specific to failed states: a stale pre-resizeRUNNINGis harmless (the instance-count / capacity check keeps the loop polling), but a staleSUSPENDEDis terminal. Tests: newimport-tag-walk.test.ts(classifier: throttling names, nested cause chain, HTTP 429/503,Rate exceededbackstop, write-path messages NOT matched, cyclic cause chain; walk: first-match, no-match, empty-cdkPathzero-call short-circuit, pagination, describe-undefined skip, throttled-describe retry, throttled-list retry, budget exhaustion, non-throttling immediate rethrow) plus provider-level wiring tests onEMRClusterProvider(retries a throttled DescribeCluster mid-walk and still finds the match/does not retry a non-throttling DescribeCluster error during the walk) andDocDBProvider(a newimport tag walkblock: per-type match, Marker pagination, no-match null,retries a throttled ListTagsForResource instead of failing the walk,does NOT retry a non-throttling error from the walk), andfails fast when the fleet enters SUSPENDED (resize could not complete)on the fleet provider. Every one was verified to FAIL against the pre-fix code. Deferred: migrating the ~40 other providers that hand-roll anaws:cdk:pathwalk — tracked as a follow-up on #1091 so this PR stays reviewable;docs/provider-development.mdnow documentsimportTagWalkas the recommended shape for new providers. No new resource type, dependency, CLI flag, or state-schema change.
Recently Implemented (2026-07-19):
- ✅
AWS::FSx::FileSystemvariant drift read-back +apply*UpdateFielddefault guard (issue #1089 + the FSx-scoped items of #1092, follow-up to #1068) —src/provisioning/providers/fsx-filesystem-provider.ts,tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts,docs/supported-resources.md. Why: #1068 shipped create/update for all four variants butreadCurrentStatestill mapped only the Lustre block, sogetDriftUnknownPathshad to mark the wholeWindowsConfiguration/OntapConfiguration/OpenZFSConfigurationsubtrees drift-unknown —cdkd driftreported "unknown" for every non-Lustre variant's config rather than comparing it. Separately, the fourapply*UpdateFieldswitches had nodefault:arm, so a sub-property added to a*_MUTABLE_SUBPROPSset without a matching case would pass the mutability check, map to nothing, and issue a no-opUpdateFileSystemwithhasMutableDifftrue — silently dropping the user's change. What shipped: threeread*Configurationreverse-mappers turn each variant'sDescribeFileSystemsblock back into the flat CFn*Configurationinput shape (mirroring the LustreDataRepositoryConfiguration→LustreConfigurationmapping), including the WindowsAliasesshape change (API returns{Name, Lifecycle}[], CFn takes a plain name list) and the sharedDiskIopsConfiguration/AuditLogConfiguration/FsrmConfiguration/ReadCacheConfigurationsub-blocks. Read-only API fields that are not CFn inputs (Endpoints,RootVolumeId,EndpointIpAddress,RemoteAdministrationEndpoint,PreferredFileServerIp,MaintenanceOperationsInProgress) are deliberately NOT mapped so they cannot surface as phantom drift.getDriftUnknownPathsdrops the three whole-block entries and keeps only the leaves AWS genuinely never returns: the two write-only credentials (WindowsConfiguration.SelfManagedActiveDirectoryConfiguration.Password—SelfManagedActiveDirectoryAttributeshas noPasswordfield at all;OntapConfiguration.FsxAdminPassword) andOpenZFSConfiguration.RootVolumeConfiguration(configured on the root volume —DescribeFileSystemsreturns onlyRootVolumeId).EndpointIpv6AddressRangeon ONTAP / OpenZFS is reverse-mapped like any other field: it IS present on both read-side interfaces, and since it is also create-mapped and declared mutable, declaring it unknown would have left a user-settable, AWS-mutable, AWS-readable field permanently invisible to drift — the exact gap #1089 exists to close. Theapply*UpdateFieldswitches gain adefault:arm routing to a newunmappedMutableSubprop()that throws a reportableProvisioningErrornaming the exact<ConfigKey>.<Subprop>with no mapping; the variant identity is threaded in via a newVariantFieldContext.VARIANT_MUTABLE_SUBPROPSis now exported so the invariant is testable. A sharedputIfDefinedhelper replaces the repeatedif (x !== undefined)assignments across the whole ofreadCurrentState(behavior-identical; keeps the Lustre block consistent with the three new ones). Tests: 33 generated cases — one per declared-mutable sub-property across all four variants — asserting each maps to a non-emptyUpdateFileSystemfield, which is what makes a future set-addition-without-a-case fail loudly instead of silently; a guard test that temporarily injects an unmapped sub-property and asserts theProvisioningError+ zero AWS calls; per-variantreadCurrentStatereverse-map tests (incl.Aliasesflattening, read-only-field exclusion, write-onlyPasswordabsence, empty-block omission); the two mandatorydrift --revertround-trip guards fromdocs/provider-development.md§3b (unchanged snapshot replays as a no-op; a mutable-only drift replays as a real scopedUpdateFileSystem); and dedicated nested-sub-block update-arm coverage (WindowsAuditLogConfigurationreconciliation, stringly-typedIopscoercion, ONTAPDiskIopsConfigurationindependent ofRouteTableIds, sub-block removal →undefined). Known gaps (neither introduced here): (1) the FSx test file still lacks theemits placeholders for every user-controllable top-level keyblock thatdocs/provider-development.md§3b marks mandatory — retrofitting it would change which top-level keysreadCurrentStateemits on an AWS-minimum response and is deliberately left to a focused follow-up. (2)— RESOLVED forWindowsConfiguration.AliasesandSelfManagedActiveDirectoryConfiguration.DnsIpsare plain-string arrays, whichsrc/analyzer/drift-normalize.tsdoes not canonicalizeAliasesby issue #1096 item 1 (see the entry below), which extended the shared normalizer exactly as this note predicted was the correct fix.DnsIpsis deliberately left undeclared: AWS documents no set semantics for it and DNS resolver lists are conventionally preference-ordered, so declaring it could silently HIDE real drift — the worse failure mode. (3) Windows and ONTAP reverse-mappers are unit-tested only — the live integ covers OpenZFS (issue #1088 tracks per-variant live coverage). - ✅
AWS::FSx::FileSystemWindows / ONTAP / OpenZFS variants (issue #1068, follow-up to #1042) —src/provisioning/providers/fsx-filesystem-provider.ts,tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts,tests/unit/provisioning/provider-registry-cc-routing.test.ts,src/provisioning/property-coverage.generated.ts,src/provisioning/register-providers.ts,docs/supported-resources.md,tests/integration/fsx-openzfs/. Why: the initial FSx provider (#1042) shipped the Lustre variant only;WindowsConfiguration/OntapConfiguration/OpenZFSConfigurationwereunhandledByDesignand pre-flight REJECTED, so a Windows / ONTAP / OpenZFS (or the CDK L2aws-fsx.OpenZfsFileSystem) template failed fast rather than deploying. What shipped: all three config trees move tohandledProperties(FSx'sunhandledByDesignmap is now empty).createmaps each variant's full CFn block to the SDKCreateFileSystem<Variant>Configurationshape (nestedSelfManagedActiveDirectoryConfiguration/AuditLogConfiguration/FsrmConfiguration/DiskIopsConfiguration/RootVolumeConfigurationincl.NfsExports+UserAndGroupQuotas/ReadCacheConfiguration), defensively coercing string-typed numeric/boolean template values — exactly one variant block is non-undefined per file system, so the SDK ignores the rest.updategeneralizes the Lustre sub-property differ into a per-variantcomputeVariantConfigDiff: the mutable subset matching eachUpdateFileSystem<Variant>Configurationis applied,RouteTableIdschanges on ONTAP / OpenZFS are translated intoAddRouteTableIds/RemoveRouteTableIdsset deltas (a pure reorder is a no-op), and any other changed sub-field (createOnly in practice but NOT registry-createOnly — e.g.DeploymentType,OpenZFSConfiguration.RootVolumeConfiguration) is rejected with a--replacepointer. The shared create-poll-to-AVAILABLE/ delete-poll-to-gone /ClientRequestToken/getMinResourceTimeoutMs/ async-FILE_SYSTEM_UPDATE-action machinery is reused unchanged.getAttribute/buildAttributesadd the OpenZFS-onlyRootVolumeId;getDriftUnknownPathsmarks the three variant blocks drift-unknown (their read-back into CFn shape is a follow-up — only the Lustre block is mapped byreadCurrentStatetoday, so listing them avoids a guaranteed phantom drift).disableCcApiFallbackstaystrue(FSx isNON_PROVISIONABLE, so any future unhandled property must never CC-route). Tests: new WINDOWS / ONTAP / OPENZFS describe blocks (variant create mapping + coercion, mutable-update surface incl. SelfManagedAD updates / ReadCache / RouteTableIds Add-Remove + reorder no-op, immutable-sub-prop--replacerejection,RootVolumeIdattribute); the CC-routing guard test was re-pointed (FSx no longer has silent-drop entries, so its variant templates SDK-route cleanly — thedisableCcApiFallback+ silent-drop guard is still covered generically viapickSilentDropFixture). Integ: newtests/integration/fsx-openzfs/— the cheapest variant (OpenZFSSINGLE_AZ_1, no Active Directory) deploy → in-place update → destroy with a GONE-from-AWS assertion (describe-file-systemsempty for the fixture id). Windows / ONTAP are unit-tested and share the OpenZFS-integ-verified create-poll / delete-poll path; a per-variant Windows-AD / multi-AZ-ONTAP integ is an optional cost-gated follow-up. No new resource type / dependency (FSx was already Tier 1); property-coverage regenerated. - ✅ SDK Providers for
AWS::EMR::InstanceGroupConfig/AWS::EMR::InstanceFleetConfig(issue #1070, follow-up to #1064/#1043) —src/provisioning/providers/emr-instance-group-config-provider.ts+emr-instance-fleet-config-provider.ts(new),src/provisioning/register-providers.ts,tests/fixtures/cfn-schemas/AWS-EMR-InstanceGroupConfig.json+AWS-EMR-InstanceFleetConfig.json(new fixtures),.claude/integ-coverage-allowlist.json,docs/supported-resources.md+docs/import.md. Why: both types areProvisioningType: NON_PROVISIONABLE(re-verified live viaDescribeType), so the Cloud Control fallback cannot handle them and pre-flight rejected any stack adding a standalone instance group / fleet to an existing cluster. They are the standalone siblings of the inlineAWS::EMR::Cluster.Instancesgroups/fleets — a template attaches them to an EXISTING cluster referenced byJobFlowId(group) /ClusterId(fleet). Lifecycle:create→AddInstanceGroups/AddInstanceFleet+ pollListInstanceGroups/ListInstanceFleetsuntil the new group/fleet isRUNNING(a failed terminal —ARRESTED/TERMINATED/ENDEDfor groups,TERMINATEDfor fleets — is a hard error);update→ the AWS-mutable surface only — for a groupInstanceCount(ModifyInstanceGroupsresize, polled until the group'sRunningInstanceCountreaches the new target — NOT just groupState: RUNNING, which right afterModifyInstanceGroupsis still the STALE pre-resizeRUNNINGand would letupdate()return before the resize even starts; the fleet resize waits on provisioned capacity meeting the target the same way, direction-aware — scale-UP waits forprovisioned >= target, scale-DOWN forprovisioned <= target(an>=-only wait would return instantly on a scale-down because the stale pre-resize capacity is still above the new lower target)) +AutoScalingPolicy(PutAutoScalingPolicy/RemoveAutoScalingPolicy); for a fleetTargetOnDemandCapacity/TargetSpotCapacity/ResizeSpecifications/InstanceTypeConfigs(ModifyInstanceFleet, polled); every other property is registry-createOnly → replacement, and a createOnly change reachingupdate()is refused with a--replacepointer;getAttribute('Id')/Ref→ the group/fleet id (ig-XXXX/if-XXXX). Delete semantics (a real design question, resolved honestly): EMR has NO standalone "delete instance group / fleet" API — a group/fleet's only lifecycle end is cluster termination.delete()is therefore effectively a no-op that drops cdkd state; in the normalcdkd destroythe parentAWS::EMR::Clusteris terminated in the same run (the group/fleet depends on it viaJobFlowId/ClusterId, so it is deleted first, thenTerminateJobFlowsreleases every group/fleet + its EC2 instances → zero orphans). As a best effort for the "delete only the group/fleet, keep the cluster" case, aTASKgroup/fleet is scaled to 0 (ModifyInstanceGroups InstanceCount: 0/ModifyInstanceFleettarget capacities0) to release its instances;MASTER/COREcannot be scaled to 0 and are a pure no-op. Delete never blocks the destroy (warn-and-continue); anInvalidRequestException(cluster/group gone) is region-guarded viaassertRegionMatch. Note scaling to 0 does NOT remove the group/fleet record (it stays with 0 instances) — documented as the honest limitation. Both setdisableCcApiFallback = true(NON_PROVISIONABLE — no CC handler) andgetMinResourceTimeoutMs()(1h poll ceiling, mirrorsEMRClusterProvider). Every settable schema property is inhandledProperties(zero silent-drop).import()is not wired (both are cluster sub-resources without a per-resourceaws:cdk:pathtag — noted indocs/import.mdalongside the ASG entry; a future override-onlyimport()on the group/fleet id is the natural follow-up). Coverage regen: both types leave the Tier-3 (NON_PROVISIONABLE) set and join Tier-1 inprovider-coverage.json/unsupported-types.generated.ts/property-coverage.generated.ts. Tests:tests/unit/provisioning/providers/emr-instance-group-config-provider.test.ts+emr-instance-fleet-config-provider.test.ts(create poll sequences + terminal-state hard error, resize + auto-scaling / modify-fleet update matrix, immutable-change rejection, TASK scale-to-0 delete + region-guard idempotency + warn-and-continue, getAttribute). Integ: newtests/integration/emr-instance-configs/— a group-based cluster (1xm5.xlargemaster + 1xm5.xlargecore; the core node is required because EMR rejectsAddInstanceGroupson a master-only job flow) + a standaloneTASKInstanceGroupConfig(1xm5.xlarge), deploy → in-place resize (1 -> 2,ModifyInstanceGroups) → destroy (group released with the cluster,TERMINATED+ zero-orphan assertion). A cluster's collection type is fixed at create (groups XOR fleets) so ONE cost-bounded cluster covers onlyInstanceGroupConfig; the structurally identicalInstanceFleetConfigis allow-listed in.claude/integ-coverage-allowlist.json(unit-tested, same create-poll / no-standalone-delete / scale-to-0-TASK design).covers: AWS::EMR::InstanceGroupConfig. Engine fix surfaced by the first live integ run: the fixture's EC2 role + instance profile are created ~1s before the cluster'sRunJobFlow, and EMR rejected the create withFailed to authorize instance profile <arn>— the SAME just-created-instance-profile propagation race #1064 already handles asInvalid InstanceProfile, but with a different EMR sentence the retryable set did not cover. AddedFailed to authorize instance profiletosrc/deployment/retryable-errors.ts(+ unit test) so the deploy engine'swithRetryabsorbs the propagation window; CloudFormation tolerates this via deployment latency, cdkd retries. - ✅
Codeseed-content +Triggerssupport forAWS::CodeCommit::Repository(issue #1066, follow-up to #1059/#1045) —src/provisioning/providers/codecommit-repository-provider.ts,tests/unit/provisioning/providers/codecommit-repository-provider.test.ts,docs/supported-resources.md,tests/integration/codecommit/+ newadm-zipdependency. Why: the two properties wereunhandledByDesignand pre-flight REJECTED (opt-in via--allow-unsupported-properties), so a template carrying either failed rather than silently dropping — but they were unsupported. What shipped:Code(create-only) reproduces CloudFormation's seed orchestration —GetObjectthe S3 ZIP (Code.S3.{Bucket,Key,ObjectVersion}), unpack every file entry withadm-zip(directory entries skipped — CodeCommit has no empty-dir concept), and issue a singleCreateCommitcarrying all files asputFilesonCode.BranchName(defaultmain); an empty ZIP is a warn-and-skip (CodeCommit rejects an emptyputFiles).Triggers(mutable) maps the CFn[{Name, DestinationArn, CustomData, Branches, Events}]list to the SDK's camelCaseRepositoryTrigger[]and reconciles viaPutRepositoryTriggerson create AND update —PutRepositoryTriggersREPLACES the full set, so a dropped entry or a fully-removedTriggersproperty is handled by putting the new set (empty array = clear all), and an unchanged block (compared as canonical JSON) skips the call. Both moved fromunhandledByDesigntohandledProperties(the provider now declares NO by-design-unhandled property → property-coverage regen). Create rollback safety: aCode/Triggersfailure AFTERCreateRepositoryself-cleans (best-effortDeleteRepository) before re-throwing —create()throwing before returning a physicalId means the deploy engine's rollback cannot delete the just-created repo, so the provider deletes it itself, mirroring CFn's rollback-deletes-the-repo behavior.Codeis create-only (CFn ignores it on update, so doesupdate()).getDriftUnknownPathsstill returns['Code', 'Triggers']:Codeis git history (never a comparable attribute), andTriggersis now wired on the write side but not yet read back byreadCurrentState(a follow-up can addGetRepositoryTriggersread-back and dropTriggersfrom the list). Tests: newcreate with Code seed(default-branch seed, explicit BranchName + ObjectVersion, empty-zip skip, missing-Bucket/Key rollback, S3-download-failure rollback),create with Triggers(PascalCase→camelCase mapping, optional-field omission, empty-array skip, put-failure rollback), andupdatetrigger cases (full-replace on change, empty-array clear on removal, no-op on unchanged) — S3 /adm-zipmocked. Integ:tests/integration/codecommit/extended with a repo carrying aCodeseed + an SNSTriggersentry, assertingGetFile(seed committed) +GetRepositoryTriggers(trigger applied) read-back, then a clean destroy. - ✅
import()forAWS::EMR::Cluster(issue #1069, follow-up to #1064) —src/provisioning/providers/emr-cluster-provider.ts,tests/unit/provisioning/providers/emr-cluster-provider.test.ts,docs/import.md. Why: the initial EMR SDK provider (#1064) shipped create/update/delete but deferredimport(), socdkd importreported every EMR cluster asunsupported. What shipped:import(input)resolves the cluster either by an explicit id override (--resource <id>=j-XXXX→ verified viaDescribeCluster; there is no template name property equal to the physical id, so no name fallback) or by theaws:cdk:pathtag walk —ListClustersfiltered to the non-terminated states (aTERMINATED*cluster is never adopted) then aDescribeClusterper candidate to readTags(list summaries carry none), matched viamatchesCdkPath. Returns physicalId +{ Id, MasterPublicDNS }attributes (ornullfor not-found / already-terminated, so the import command marks it skipped). Also addsreadCurrentState+getDriftUnknownPaths: the reverse ofcreate()'sInstancesflatten —DescribeCluster(+ListInstanceGroups/ListInstanceFleetschosen byInstanceCollectionType) is re-bucketed back into the role-keyed CFnMasterInstanceGroup/CoreInstanceGroup/TaskInstanceGroups(+*InstanceFleet(s)) shape with the flatEc2InstanceAttributes(subnet / key name / security groups) folded in, plusTags(normalizeAwsTagsToCfn,aws:*stripped) and the top-level scalar fields — so a freshly-imported cluster gets a realobservedPropertiesbaseline (viacaptureObservedForImportedResources) and drift works. Lossy create-only sub-config AWS does not read back faithfully (ManagedScalingPolicy/AutoTerminationPolicy/BootstrapActions/Steps/KerberosAttributes/Configurations/AdditionalInfo/PlacementGroupConfigs/ApplicationsVersion) is declared ingetDriftUnknownPathsso theproperties-fallback path never phantom-drifts.docs/import.mdmovesAWS::EMR::Clusterfrom the unsupported list into the auto-lookup section. No new resource type / registration / coverage regen (EMR was already Tier 1). - ✅ Drift support for
AWS::CodeCommit::Repository(issue #1065, follow-up to #1059/#1045) —src/provisioning/providers/codecommit-repository-provider.ts,tests/unit/provisioning/providers/codecommit-repository-provider.test.ts,tests/integration/codecommit/verify.sh+README.md. Why: the initial CodeCommit SDK provider did not implementreadCurrentState, socdkd driftreported "unknown" for every repository. What shipped:readCurrentState(physicalId, logicalId, resourceType)issuesGetRepository+ListTagsForResourceand maps the read side back to the flat CFn inputs cdkd stores in state —repositoryDescription→RepositoryDescription,kmsKeyId→KmsKeyId, the tag map →Tags(CFn[{Key, Value}]list,aws:*tags dropped bynormalizeAwsTagsToCfnso a CDK-deployed repo'saws:cdk:pathtag is not phantom drift),repositoryName→RepositoryName. Every user-controllable top-level keyupdate()can mutate is emitted ALWAYS with a?? ''/?? []placeholder when AWS returns it undefined/empty (docs/provider-development.md §3b) — so a repo deployed WITHOUT a description still carriesRepositoryDescriptioninobservedPropertiesand a console-side ADD is visible to drift. The drift comparator (DiffCalculator) canonicalizes tag lists order-independently (drift-normalize.ts) so a tag reorder is never phantom drift. Returnsundefinedwhen the repository is gone (RepositoryDoesNotExistException) so the caller reports drift-unknown rather than throwing (mirrors the optionalimportopt-in shape) — including a repo deleted BETWEEN theGetRepositoryandListTagsForResourcereads (a race with a concurrent destroy), which is caught rather than aborting the wholecdkd driftrun (matches the ECR provider's guard).getDriftUnknownPathsreturns['Code', 'Triggers']— both areunhandledByDesign(create-only seed content / repository triggers), never read back byreadCurrentState, and normally rejected pre-flight, but a state written under--allow-unsupported-propertiescould carry them, so they are excluded defensively. No new resource type, no coverage regen, noregister-providerschange. Tests: newreadCurrentState (drift)describe block in the provider unit test (flat-input mapping, drift detected on a changed description, zero drift on a no-op, tag-reorder not phantom drift,Codeignored viagetDriftUnknownPaths, a genuine tag-value drift, the mandatory always-emit-placeholder key-set assertion on the AWS-minimum response,Tags: []even without an ARN, repo-gone / no-metadata / repo-deleted-mid-read →undefined, non-NotFound propagation) exercising the realcalculateResourceDrift; the integ fixture gained a Phase 1b drift round-trip (clean after deploy → out-of-bandUpdateRepositoryDescriptiondetected as drift exit 1 → revert → clean).
Recently Implemented (2026-07-17):
✅ SDK Provider for
AWS::EMR::Cluster— EMR on EC2 (issue #1043) —src/provisioning/providers/emr-cluster-provider.ts(new),src/provisioning/register-providers.ts,@aws-sdk/client-emr(new dependency),tests/fixtures/cfn-schemas/AWS-EMR-Cluster.json(new fixture). Why: the type isProvisioningType: NON_PROVISIONABLE(re-verified live at implementation time viaDescribeType), so the Cloud Control fallback cannot handle it and pre-flight rejected any stack carrying an EMR cluster. Lifecycle:create→RunJobFlow+ pollDescribeClusteruntilWAITING/RUNNING; aTERMINATED*terminal during create is a hard error and the partially-created cluster is best-effortTerminateJobFlows'd so it does not bill;update→ the LIMITED mutable surface only —SetTerminationProtection(Instances.TerminationProtected),SetVisibleToAllUsers,ModifyCluster(StepConcurrencyLevel),PutManagedScalingPolicy/RemoveManagedScalingPolicy,PutAutoTerminationPolicy/RemoveAutoTerminationPolicy, andAddTags/RemoveTags(full-tag-removal untags explicitly — the #981 ECR regression class);delete→TerminateJobFlows+ poll untilTERMINATEDwith a hard error on timeout (a live cluster bills per instance-hour), idempotent on an already-TERMINATED/ aged-out cluster behindassertRegionMatch, and under--remove-protectionit flipsSetTerminationProtection(false)first (mirroring the EC2/ASG pattern insrc/provisioning/ec2-termination-protection.ts);getAttribute→Id(physical id) /MasterPublicDNS(viaDescribeCluster). Property classification (from the registry schema, not the issue's guesses): everything exceptTags/VisibleToAllUsers/StepConcurrencyLevel/ManagedScalingPolicy/AutoTerminationPolicyandInstances.TerminationProtectedis createOnly → replacement (Instancestopology,Applications,ReleaseLabel, roles, EBS root volume, etc.); an immutable change that reachesupdate()is refused with a--replacepointer. The bulk of the mapping work is the CFnInstancesblock: role-keyedMasterInstanceGroup/CoreInstanceGroup/TaskInstanceGroups(and the*InstanceFleet(s)siblings) are flattened into the SDK'sInstanceGroups/InstanceFleetsarrays with an explicitInstanceRole/InstanceFleetTypediscriminator per entry.getMinResourceTimeoutMs()self-reports a 1-hour ceiling (mirrorsCustomResourceProvider/FSxFileSystemProvider) so slow creates/terminates fit the per-resource deadline without--resource-timeout.disableCcApiFallback = trueso the #614 silent-drop auto-route cannot misroute an unhandled-property EMR template to CC.import()is not wired in this initial provider (tracked as a follow-up overListClusters+DescribeCluster). Unit tests:tests/unit/provisioning/providers/emr-cluster-provider.test.ts(29 tests — create poll sequences + instance-group/fleet flattening, terminal-during-create rollback, mutable-update matrix, immutable-change rejection, delete idempotency + region guard +--remove-protectionflip ordering + timeout hard-error, getAttribute). Integ:tests/integration/emr-cluster/(smallest single-node cluster — 1 masterm5.xlarge,emr-7.x,AutoTerminationPolicyidle-timeout to bound cost; deploy → in-place update → destroy with aTERMINATED/no-orphan assertion).covers: AWS::EMR::Cluster. Engine fix surfaced by the first live integ run:RunJobFlowvalidates the cluster'sJobFlowRoleinstance profile at create time, but cdkd's fast SDK path issuesRunJobFlowonly ~1s after theAWS::IAM::InstanceProfileCREATE — before IAM propagates the profile to EMR's validation layer — so EMR rejected the create withInvalid InstanceProfile: <name>.(the one-word "InstanceProfile", distinct from EC2'sInvalid IAM Instance Profilephrasing already handled). AddedInvalid InstanceProfiletosrc/deployment/retryable-errors.tsso the deploy engine'swithRetryabsorbs the propagation window (same just-created-IAM-dependency class as the Glue / Firehose / Step Functions / EC2 patterns); CloudFormation tolerates this via deployment latency, cdkd retries.✅ SDK Provider for
AWS::FSx::FileSystem— Lustre variant (issue #1042) —src/provisioning/providers/fsx-filesystem-provider.ts(new),src/provisioning/register-providers.ts,src/provisioning/stateful-types.ts(FSx added to the stateful-recreation guard),@aws-sdk/client-fsx(new dependency),tests/fixtures/cfn-schemas/AWS-FSx-FileSystem.json(new fixture). Why: the type isProvisioningType: NON_PROVISIONABLE(re-verified live at implementation time), so the Cloud Control fallback cannot handle it and pre-flight rejected any stack carrying it — including the CDK L2aws-fsx.LustreFileSystem. Scope: the Lustre variant only;WindowsConfiguration/OntapConfiguration/OpenZFSConfigurationareunhandledByDesign(each non-LustreFileSystemTypeREQUIRES its variant block, so every non-Lustre template is caught by the property-coverage pre-flight — no silent drop). Lifecycle:create→CreateFileSystem(orCreateFileSystemFromBackupwhenBackupIdis set — a different API whereFileSystemTypeis not a valid parameter) with a stable immutable-input-hashedClientRequestToken+ pollDescribeFileSystemstoAVAILABLE, best-effort rollback delete on a failed/timed-out create (an orphaned FSx file system bills per hour);update→UpdateFileSystemfor the mutable surface (StorageCapacity, StorageType, FileSystemTypeVersion, NetworkType, and the mutable Lustre sub-props) +TagResource/UntagResourceforTags, with provider-level immutability rejection for Lustre sub-props the registry schema does NOT mark createOnly (DeploymentType/ImportPath/ExportPath/ etc. — the schema-based replacement fallback never fires for them);delete→DeleteFileSystem+ poll until GONE with a hard error on timeout (never warn-and-continue), idempotent NotFound behindassertRegionMatch;getAttribute→DNSName/LustreMountName/ResourceARN/RootVolumeId;readCurrentStatemaps the read-sideDataRepositoryConfigurationback to the flat CFnLustreConfigurationinputs;importverifies an explicit id or walksDescribeFileSystemsfor theaws:cdk:pathtag.getMinResourceTimeoutMs()self-reports a 1-hour ceiling (mirrorsCustomResourceProvider) so slow creates fit the per-resource deadline without--resource-timeout. Unit tests:tests/unit/provisioning/providers/fsx-filesystem-provider.test.ts(30 tests — poll sequences, token stability, replacement classification, tag diff, delete idempotency + region guard). Integ:tests/integration/fsx-lustre/(SCRATCH_2 1.2 TiB, deploy → in-place update (LZ4 compression + tag removal) → destroy with a GONE-from-AWS assertion by id and by tag). Two engine-level bugs surfaced by the first live integ run and fixed in the same PR: (1)UpdateFileSystemapplies changes via an ASYNCFILE_SYSTEM_UPDATEadministrative action — returning onLifecycle: AVAILABLEalone let the next read observe the OLD value (live: LZ4 compression read back as NONE seconds after the update);update()now waits for the action to complete (ignoring the hours-longSTORAGE_OPTIMIZATIONactions, matching CloudFormation) and hard-fails on a FAILED action. (2) The deploy engine's in-place UPDATE state write dropped the resource's storedattributeswhenever the provider's update result carried none — degrading every laterFn::GetAttto the physical-id fallback (live: the stack'sLustreMountNameoutput regressed to the file-system id); the engine now carries the previous attributes forward on in-place updates (replacements still never inherit), and the FSx provider additionally re-derives a fresh attribute set on every update. Plus a CC auto-route viability guard: the #614 silent-drop auto-route now REJECTS pre-flight (clear per-property error + escape hatch) when the route target cannot manage the type —NON_PROVISIONABLEor providerdisableCcApiFallback(first-time consultation of that flag;NestedStackProvider's misroute-to-real-CFn hazard is closed by the same guard). FSx polling loops absorb up to 5 consecutive transient Describe errors (throttle / 5xx) instead of failing a 10-minute create over one throttle.✅ SDK Providers for the Bedrock AgentCore tool types —
AWS::BedrockAgentCore::Browser/AWS::BedrockAgentCore::CodeInterpreter/AWS::BedrockAgentCore::Evaluator(issues #1038 / #1039 / #1058) —src/provisioning/providers/agentcore-browser-provider.ts/agentcore-code-interpreter-provider.ts/agentcore-evaluator-provider.ts(new),agentcore-case-convert.ts(new shared Pascal↔camel helper extracted fromagentcore-runtime-provider.ts, now with apreserveKeysescape hatch for free-form JSON documents),src/provisioning/register-providers.ts,@aws-sdk/client-bedrock-agentcore-controlbumped^3.1017.0→^3.1089.0(the pinned 3.1018.0 predated theEvaluatorConfig.CodeBasedunion member). Why: Browser / CodeInterpreter areProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot handle them and pre-flight rejected any stack carrying them; Evaluator is the newest piece of the AgentCore story cdkd already invests in (cdkd local invoke-agentcore/start-agentcore). Premise corrections vs the issues (verified against the registry schemas + SDK typings): (1) Browser / CodeInterpreter are READ-ONLY singleton types representing the AWS-managed defaults (aws.browser.v1/aws.codeinterpreter.v1) — every schema property is read-only, the only registry handlers are read/list, and the id patterns are hard-locked to the defaults; the issues' proposedcreate → CreateBrowser/CreateCodeInterpreterwould have created CUSTOM tools, which belong to the DIFFERENTAWS::BedrockAgentCore::BrowserCustom/CodeInterpreterCustomtypes (FULLY_MUTABLE, Cloud Control-served). The providers are therefore ADOPT-ONLY:createverifies the default exists viaGetBrowser/GetCodeInterpreterand records its ARN (the primaryIdentifier, CFnRefparity) as physicalId;update/deleteare no-ops (destroy must never touch the AWS-owned defaults);getAttributeserves the read-only attributes live;importauto-adopts with no--resourceoverride. (2) Evaluator is meanwhile FULLY_MUTABLE (the issue's "entered the Tier 3 set" premise was stale — AWS shipped full handlers after it was filed), but the SDK provider still wins on speed and keeps the AgentCore family consistent. Evaluator provider:create→CreateEvaluator(physicalId = evaluator ARN; SDK ops take the id = the ARN'sevaluator/<id>segment viaevaluatorIdFromArn),update→UpdateEvaluator(Description / EvaluatorConfig / Level / KmsKeyArn) +TagResource/UntagResourcetag reconciliation (the update op has no tags parameter; full-removal untags explicitly — the #981 ECR regression class),delete→DeleteEvaluator(NotFound-idempotent,assertRegionMatch-guarded),getAttribute→ EvaluatorArn/EvaluatorId locally + Status/CreatedAt/UpdatedAt viaGetEvaluator,readCurrentState→ CFn-shaped drift read incl. tags (best-effort),import→ explicit ARN verbatim or bare id resolved viaGetEvaluator.EvaluatorNameis the only createOnly property (the issue guessed "possiblyLevel" — Level is updatable per both schema and SDK) → replacement via the registry-schema createOnly fallback, no hand rule needed. The fullEvaluatorConfigunion (LlmAsAJudgeincl. RatingScale/ModelConfig/InferenceConfig,CodeBased→LambdaConfig) plusKmsKeyArn/Tagsmap 1:1 through the shared case converter — withAdditionalModelRequestFields(free-form model fields liketop_k) passed VERBATIM both directions viapreserveKeys. All non-read-only schema properties of all three types are wired (handledProperties, zero silent-drop; Browser / CodeInterpreter declare the explicit empty set — every property is read-only). Tests: 51 new unit tests acrosstests/unit/provisioning/providers/agentcore-{browser,code-interpreter,evaluator,case-convert}.test.ts(adopt/no-op semantics, union case-conversion + preservation, tag reconcile matrix, delete idempotency + region-mismatch refusal, import paths). Integ: newtests/integration/agentcore-tools/fixture — all three types as rawcdk.CfnResources (aws-cdk-lib ships no L1 for the singletons andCfnEvaluatorpredatesCodeBased), a code-based evaluator backed by a fixture Lambda (no Bedrock model-access dependency): deploy (defaults adopted into state +GetAttoutputs, evaluator at TRACE) → UPDATE (Description / Level TRACE→SESSION / added tag, in-place: same evaluator id) → destroy (evaluator gone, AWS-managed defaults still READY, state removed).covers: AWS::BedrockAgentCore::Browser/AWS::BedrockAgentCore::CodeInterpreter/AWS::BedrockAgentCore::Evaluator.✅ SDK Providers for
AWS::ServiceDiscovery::HttpNamespace/AWS::ServiceDiscovery::PublicDnsNamespace(issue #1044) —src/provisioning/providers/servicediscovery-provider.ts,src/provisioning/register-providers.ts. Why: both types areProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot handle them and pre-flight rejected them — while theirPrivateDnsNamespacetwin deployed fine via the existing SDK provider, an inconsistency CDKaws-servicediscoveryL2 users hit directly. What shipped: the existingServiceDiscoveryProvidergained both kinds, sharing its operation-polling plumbing (pollOperation: OperationId →GetOperationuntilSUCCESS, namespace id fromTargets.NAMESPACE) — create →CreateHttpNamespace/CreatePublicDnsNamespace(PublicDns passesProperties.DnsProperties.SOA.TTLthrough and surfaces theHostedZoneIdattribute read back viaGetNamespace; the shared SOA-TTL extraction is now theextractSoaTtlPropertieshelper reused by the PrivateDns paths); update →UpdateHttpNamespace(Description is the sole mutable field) /UpdatePublicDnsNamespace(Description + SOA TTL) plus explicitTagResource/UntagResourcediffs via the sharedsyncNamespaceTagshelper (partial AND fullTagsremoval untag explicitly — the ECR #981 regression class; tag failures throw, never warn-swallow; also wired into the pre-existingPrivateDnsNamespaceupdate path, whose Tags-only changes previously silently no-op'd);Nameis createOnly per the registry schema → replacement handled upstream; delete → the now kind-agnosticdeleteNamespace(DeleteNamespace+ operation poll,NamespaceNotFound-idempotent behindassertRegionMatch);getAttribute→GetNamespace(Id/Arn/HostedZoneId) with aGetService-backed Service branch;readCurrentState(drift) reuses the namespace reader with thePropertiesbag omitted for HttpNamespace (no CFnPropertiesthere);importadopts by explicit id /Properties.Name/aws:cdk:pathtag via the sharedListNamespaceswalk, now filtered by the Cloud MapNamespace.Type(HTTP/DNS_PUBLIC/DNS_PRIVATE) so a same-named namespace of a different kind is never adopted (Cloud Map names are not unique across kinds). The deploy-timeFn::GetAttfallback insrc/deployment/intrinsic-function-resolver.tscovers all three namespace kinds (constructedArn;HostedZoneIdfetched live viaGetNamespaceand resolving toundefined— never the namespace id — on a miss; previously the two new kinds AND PrivateDns'sHostedZoneIdfell through to the bare physicalId). All writable schema properties for both types are wired (handledProperties, zero silent-drop; no new dependency —@aws-sdk/client-servicediscoverywas already in). Tests:tests/unit/provisioning/providers/servicediscovery-namespace-kinds.test.ts(GetOperation polling PENDING→SUCCESS with fake timers and FAIL propagation on create AND delete, SOA-TTL passthrough, HostedZoneId attribute, tag add/change/remove incl. full removal and throw-on-failure, delete idempotency, getAttribute matrix, readCurrentState shape split). Integ: newtests/integration/servicediscovery-namespaces/fixture — oneCfnHttpNamespace+ oneCfnPublicDnsNamespace(SOA TTL 90 + tags); verify.sh asserts both namespaces exist, the public one's Route 53 hosted zone exists and the TTL reached AWS, theHostedZoneIdFn::GetAttresolved into stack outputs, then destroys and asserts zero orphans including the hosted zone.covers: AWS::ServiceDiscovery::HttpNamespace, AWS::ServiceDiscovery::PublicDnsNamespace.✅ SDK Provider for
AWS::CodeCommit::Repository(issue #1045) —src/provisioning/providers/codecommit-repository-provider.ts(new),src/provisioning/register-providers.ts,src/analyzer/replacement-rules.ts,src/deployment/intrinsic-function-resolver.ts. Why: the type isProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot handle it and pre-flight rejected it outright; CodeCommit returned to full General Availability on 2025-11-24 (new sign-ups open again), making a provider worth having. What shipped: create →CreateRepository(RepositoryName— generated defensively when absent,RepositoryDescription,KmsKeyId,Tagswith CFnTag[]→ CodeCommitRecord<string, string>map conversion); update →UpdateRepositoryName(see below) /UpdateRepositoryDescription(empty string clears on property removal) /UpdateRepositoryEncryptionKey(property removal reverts to the AWS-managedalias/aws/codecommitkey) /TagResource+UntagResource(partial AND full tag removal untag explicitly — the ECR #981 regression class); delete →DeleteRepository(idempotent — AWS signals "already deleted" via a nullrepositoryId, NOT an exception, so both the null-id shape and the defensiveRepositoryDoesNotExistExceptionrunassertRegionMatchbefore counting as success); rename is retry-safe (awithRetryre-entry whoseUpdateRepositoryNamehits NotFound probes the new name and treats an already-applied rename as success); getAttribute →GetRepository(Arn/CloneUrlHttp/CloneUrlSsh/Name/KmsKeyId); import → explicitRepositoryNameverify viaGetRepository, else paginatedListRepositories+ListTagsForResourceaws:cdk:pathtag-map lookup. Rename is IN-PLACE, not replacement: issue #1045 proposed classifying aRepositoryNamechange as replacement "for CFn parity", but the CFn docs mark the property "Update requires: No interruption" and the registry schema'screateOnlyPropertiesis empty — real CloudFormation renames viaUpdateRepositoryName, preserving the repository's entire git history (replacement would destroy it), so the provider does the same and returns the new name asphysicalIdwithwasReplaced: false(a hand-writtenReplacementRulesRegistryrule pins all four handled properties as updateable). Ref parity: CFn'sRefreturns the repository ID (a GUID) while every CodeCommit API is name-based (physical id = repository name), socreate()/import()store aRepositoryIdattribute andcfnRefValueFromPhysicalIdrecovers it viastateLookup(falls back to the name on torn state).Code(CFn-only seed-content orchestration) andTriggersareunhandledByDesign— pre-flight rejects templates carrying them (nothing silently dropped) pending a follow-up (CreateCommit/PutFile,PutRepositoryTriggers). New dep@aws-sdk/client-codecommit. Tests:tests/unit/provisioning/providers/codecommit-repository-provider.test.ts(create/update/rename/tag-removal/delete-idempotency/getAttribute/import matrix) + 2Refparity cases intests/unit/deployment/intrinsic-functions.test.ts. Integ: newtests/integration/codecommit/fixture — deploy (desc + 2 tags +Ref-returns-GUID output pin), UPDATE with in-place rename (repository ID must survive) + description change +envtag change +teamtag removal, destroy clean.✅
AWS::Budgets::BudgetSDK provider (issue #1041) —src/provisioning/providers/budgets-budget-provider.ts(new),src/provisioning/register-providers.ts,src/analyzer/replacement-rules.ts,@aws-sdk/client-budgetsdependency. Why: the type isProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot manage it and pre-flight rejected any stack carrying a cost budget — a popular guardrail resource in baseline stacks. What shipped: full SDK provider —create→CreateBudget(one call carries the budget,NotificationsWithSubscribers, andResourceTags; physicalId is the budget NAME, CFnRefparity — a user-suppliedBudget.BudgetNamepasses through VERBATIM, only the logical-id fallback goes through the name generator),update→UpdateBudgetplus in-place notification/subscriber reconciliation (CreateNotification/DeleteNotification/CreateSubscriber/DeleteSubscriber— notifications are addressed by value, subscriber additions are issued BEFORE deletions because a notification must keep >= 1 subscriber, and every reconciler step is idempotent: NotFound on delete / DuplicateRecord on create count as success so a partial failure can be retried forward or rolled back) andTagResource/UntagResourcetag diffs,delete→DeleteBudget(NotFound-idempotent,assertRegionMatch-guarded),getAttribute→Arn(computedarn:aws:budgets::{account}:budget/{name}, existence-verified viaDescribeBudget). Global-endpoint semantics: the Budgets API is a global per-account service served fromus-east-1; the SDK endpoint ruleset routes EVERY aws-partition region tobudgets.amazonaws.com(SigV4 scopeus-east-1), so the client is created with the deploy region like regional providers and the standard region guard stays uniform.AccountId(required on every call) is resolved once per provider instance via STSGetCallerIdentityas a cached single-flight promise (failures not cached).Budget.BudgetNameis createOnly — a rename classifies as REPLACEMENT via a conditional rule on the nested name (budgetNameChangedinreplacement-rules.ts);NotificationsWithSubscribersis createOnly for CloudFormation (whole-budget replacement) but updateable under cdkd thanks to the reconciler. All 3 writable schema properties are wired (handledProperties, zero silent-drop).importadopts by explicit--resourceid /Properties.Budget.BudgetName, or byaws:cdk:pathtag viaDescribeBudgets+ListTagsForResource. Tests: 45 unit tests (create field/amount/date/PlannedBudgetLimits mapping, STS caching + no-failure-caching, notification/subscriber reconcile ordering, tag diff, delete idempotency + region-mismatch refusal, getAttribute, import explicit/tag/miss, replacement-rule matrix). New real-AWSbudgetsinteg fixture: deploy (1 USD monthly cost budget + 80% ACTUAL email notification, CLI assertions) → in-place UPDATE (limit 1→2 USD, threshold 80→90 reconcile, +1 subscriber; provisionedBy=sdk) → destroy (budget gone, state removed).covers: AWS::Budgets::Budget.✅ Fix:
cdkd state infono longer dies withPermanentRedirectwhen the ambient region differs from the state bucket's region (issue #1054) —src/cli/commands/state.ts. Bug: thestate infoflow called its four raw S3 helpers —detectBucketRegion,listStateFileKeys,readSchemaVersion,listAssetStorageMarkers— with the raw ambient-regionawsClients.s3client, soListObjectsV2/GetObjectagainst a bucket in another region 301'd withPermanentRedirect(repro:AWS_REGION=us-west-2 cdkd state info --state-bucket <us-east-1 bucket>; surfaced by the hardened asset-bootstrap fixture, issue #1052). Every OTHER state-bucket consumer already region-corrects via the sharedrebuildClientForBucketRegionhelper (S3StateBackend/LockManager/ExportIndexStore, issue #827) — these direct reads were the one un-corrected path. Fix:stateInfoCommandbuilds one region-corrected S3 client afterverifyBucketExists()(mirroringExportIndexStore's option choices:reuseClientCredentials+tolerateNonStandardClient, never destroying the shared original; only the replacement is destroyed in the command'sfinally), and the four helpers now take the S3 client directly (s3: S3Client) instead ofAwsClients. Side benefit:detectBucketRegionpreviously try/caught the cross-region failure intoRegion: unknown; with the corrected client it now reports the bucket's actual region. Tests: newcross-region state bucket (issue #1054)describe block intests/unit/cli/state-info.test.ts—rebuildClientForBucketRegionmocked to return a sentinel client, asserting ALLGetBucketLocation/ListObjectsV2/GetObjecttraffic goes to the sentinel (not the original), the helper receives the ExportIndexStore-style options, and only the replacement client is destroyed; plus a same-region case (helper returnsnull) asserting the original client carries the traffic.✅
cdkd local run-task --from-staterecognizes custom-named cdkd container-asset repos via the bootstrap marker (issue #1025) —src/local/ecs-task-resolver.ts,src/cli/commands/local-state-loader.ts,src/cli/commands/local-run-task.ts. Why: the run-task image classifier recognized cdkd-owned container-asset images by the literalcdkd-container-assets-prefix (CDK_ASSET_IMAGE_REPO_RE). Sincecdkd bootstrap --container-repo <name>(issue #1011) a region's container repo can carry ANY name — the per-region bootstrap marker is the source of truth — so images in a custom-named repo were not classified as cdk-asset images and lost the localcdk.out-build fast path (falling back to an ECR pull: correct, but slower). What shipped:isCdkAssetImageUritakes an optionalcdkAssetContainerReponame and additionally accepts any ECR-hosted URI (host contains.dkr.ecr., tolerating both concrete and${AWS::...}placeholder hosts) whose repository path component (new helperextractEcrRepoComponent, digest/tag-stripped) equals it; the name is threaded through a new optionalEcsImageResolutionContext.cdkAssetContainerRepofield into all three classification sites (flat/Fn::Sub,Fn::GetAtt,Fn::Join).detectEcsImageResolutionNeedsgainsneedsAssetRepoMarker(any container Image is a flat-extractable ECR-hosted URI NOT matching the conventional shapes), which gates a new best-effort helperloadBootstrapContainerRepoinlocal-state-loader.ts(readscdkd-bootstrap/{region}.jsonfrom the state bucket viaS3StateBackend.getRawObject+parseBootstrapMarker; every miss — no bucket, no marker, malformed marker, AWS error — logs at debug and returnsundefined, never failing the run). The marker read fires only under--from-state(--from-cfn-stackstacks were deployed via CloudFormation → conventional names → the regex suffices); the conventional-prefix regex remains the no-marker/no-AWS fallback. Largely defensive today: the local resolvers classify the SYNTH template's image and synth stays unrewritten by design (asset redirect happens at deploy), so the custom-name branch is reachable mainly when a template/flat string carries an explicit custom-repo ECR URI. Tests: newecs-task-resolverdescribe block (custom-repoFn::Subplaceholder-host + concrete-literal URIs →cdk-assetwith hash; no-context / different-name →ecr;docker.iohost guard →public; digest-suffixed match; conventional shapes still classify;needsAssetRepoMarkertrue/false matrix) + newtests/unit/cli/local-state-loader-bootstrap-repo.test.ts(marker present → name; null body / bucket-resolution failure / malformed JSON / missing fields / S3 read failure →undefined; bucket-root-relative marker key; region-chain fallback; globalClients reset).✅
AWS::DLM::LifecyclePolicySDK provider (issue #1040) —src/provisioning/providers/dlm-lifecycle-policy-provider.ts(new),src/provisioning/register-providers.ts,@aws-sdk/client-dlmdependency. Why: the type isProvisioningType: NON_PROVISIONABLE, so the Cloud Control fallback cannot manage it and pre-flight rejected any stack carrying a Data Lifecycle Manager policy (EBS snapshot / AMI lifecycle — a common production backup-automation resource). What shipped: full SDK provider —create→CreateLifecyclePolicy(+ follow-upGetLifecyclePolicyfor theArnattribute; physicalId is the service-generated policy id, CFnRefparity),update→UpdateLifecyclePolicyfor policy fields plus explicitTagResource/UntagResourcefor tag diffs including FULLTagsremoval (the #981 ECR regression class — the update API has no Tags parameter),delete→DeleteLifecyclePolicy(NotFound-idempotent,assertRegionMatch-guarded),getAttribute→ArnviaGetLifecyclePolicy. ADefaultPolicychange throws the typedResourceUpdateNotSupportedError(create-only at the API level;--replacerecreates). The CFn property shape maps 1:1 onto the SDK's PascalCase inputs; the only conversion is CFnTagslist ↔ DLM tag map — all 12 writable schema properties are wired (handledProperties, zero silent-drop).readCurrentState(drift) surfacesDescription/State/ExecutionRoleArn/Tags;PolicyDetails+ the default-policy shorthand fields are declared drift-unknown (the service normalizes stored details with filled defaults — positional array comparison would fire phantom drift).importadopts by explicit--resourceid or byaws:cdk:pathtag via the single-callGetLifecyclePoliciessummary tag map. Tests: 22 unit tests (create field mapping + tag-map conversion; update tag add/change/remove incl. full-removal; DefaultPolicy-change rejection; delete idempotency + region-mismatch refusal; getAttribute; drift read-back; import explicit/tag/miss paths). New real-AWSdlm-lifecycle-policyinteg fixture: deploy (ENABLED policy + execution role, get-lifecycle-policy assertions, provisionedBy=sdk) → in-place UPDATE (Description + State ENABLED→DISABLED + tag value change AND tag removal; PolicyId unchanged) → destroy (policy + role gone, state removed).covers: AWS::DLM::LifecyclePolicy.✅ Fix: template Parameter names no longer persisted into state
dependencies(issue #1032) —src/deployment/deploy-engine.ts(extractAllDependencies),src/cli/commands/import.ts(buildStackState). Bug:TemplateParser.extractDependenciescaptures everyRef— including Refs to CFn Parameters — and both state-write sites persisted the set verbatim, so every parameter-referencing resource carried the parameter NAME in itsdependencies. Oncdkd destroythe graph is rebuilt from a state-derived pseudo-template with noParameterssection, soDagBuilder.buildGraphwarnedResource <X> depends on <Param>, but <Param> not found in templatefor every such resource on every destroy (cosmetic — ordering was unaffected — but it reads like a real problem, and any raw-CFn / CfnInclude / migrated stack with parameters triggers it). Found while running the #1027 fixture's destroy. Fix: both write sites filter names declared in the template'sParameterssection before persisting (a parameter is not a provisioning-order edge; the deploy-side DAG already skipped them silently). Legacy state entries self-heal when a resource is next created/updated; until then the destroy warn can still fire for stacks deployed by older binaries. Tests:tests/unit/deployment/deploy-engine-param-deps-filtered.test.ts(CREATE persists only resource edges; empty deps key omitted) + adependencies persisted to state (#1032)case intests/unit/cli/import.test.ts— both fail without the fix.✅ Fix: stacks carrying
AWS::CloudFormation::WaitConditionHandleare now deployable — new no-op SDK provider —src/provisioning/providers/wait-condition-handle-provider.ts(new),src/provisioning/register-providers.ts(issue #1020, user report). Bug: the type is NON_PROVISIONABLE (Cloud Control cannot manage it) and had no SDK provider, so cdkd's pre-flight rejected the whole stack. It is emitted bycdk-multi-region-stackas an empty-template placeholder (CloudFormation rejects zero-resource templates), so any app using that construct was undeployable. Fix: a no-op provider — in CloudFormation the handle's physical id is a pre-signed S3 signal URL backed by CloudFormation's internal bucket, which cannot exist outside a CFn deployment, so cdkd synthesizes an opaquecdkd-wait-condition-handle-<logicalId>-<uuid>placeholder (deliberately NOT URL-shaped so a straycfn-signalfails loudly), never calls AWS on create/update/delete, resolvesRefto the placeholder, throws onFn::GetAtt(the type has none), reports empty drift state, and imports verbatim by explicit id (CFn's pre-signed-URL id during--migrate-from-cloudformation) or synthesizes otherwise.AWS::CloudFormation::WaitCondition(the blocking signal-wait) remains unsupported — that semantic requires CloudFormation itself. New real-AWSwait-condition-handleinteg fixture pins the #1020 shape: bare handle + SSM sibling deploys (the old pre-flight failure),Refresolves into the stack output, sibling UPDATE keeps the handle's physical id stable, destroy leaves nothing.covers: AWS::CloudFormation::WaitConditionHandle.✅
cdkd diffbinds template Parameters + evaluates Conditions like deploy; condition-false Outputs skipped (issues #1027, #1028) —src/cli/commands/diff-recursive.ts,src/deployment/deploy-engine.ts,src/types/resource.ts. Why: raw CloudFormation templates ingested via CDK'sCfnInclude(the common CFn → CDK migration shape) carryParameterswith defaults,Mappings, andConditions— the deploy engine resolved all of them (steps 2.5–2.7) but the standalonecdkd diffdid not, so a no-op diff on a freshly deployed stack reported a phantom[requires replacement]on an unchanged create-only property (the unresolvedFn::Join/Fn::FindInMapnew side vs the resolved old side), a phantomto createfor a condition-false resource, spurious value changes forRef/Fn::Subover parameters, and aFn::Sub variable ... not foundwarn; separately,resolveOutputsiterated everyOutputsentry unconditionally, so an output whoseConditionis false warnedFailed to resolve output ...on EVERY deploy (CFn silently omits such outputs), and one whose value happened to resolve would even be wrongly published as an output/export. Found by the 2026-07-17 bug-hunt round (deploy-first:CfnIncludefixture on real AWS). What shipped:computeStackDiffnow mirrors the deploy engine's preprocessing best-effort — binds template parameter defaults viaresolveParameters(nested-stack input parameters act as user values), evaluatesConditions, prunes condition-false resources viafilterResourcesByCondition, and threadsparameters+conditionsinto the resolver context soFn::Ifresolves too; binding/evaluation failures degrade to the previous raw-template diff.resolveOutputsskips an output whoseConditionevaluated false — no resolution attempt, no warn, not persisted to state, not exported (unknown condition names are kept, matchingfilterResourcesByConditionsemantics);TemplateOutputgains theCondition?: stringfield. Tests: 6 newcomputeStackDiffcases (param-default NO_CHANGE, condition-false prune, condition-false-in-state DELETE,Fn::If, nested-input-satisfies-required-param, graceful fallback) intests/unit/cli/diff-recursive.test.ts+tests/unit/deployment/deploy-engine-condition-false-outputs.test.ts(skip/keep/unknown-condition matrix). Integ: newtests/integration/raw-cfn-conditions-params/fixture (scenario tagraw-cfn-template-diff-parity) — deploy (defaults +Fn::Subover param+GetAtt + condition-false resource/output absent, no warn) → no-opdiff --failexits 0 → inlined-parameter UPDATE diffs as exactly0 to create, 1 to update, 0 to deletewith no replacement and updates in place (same QueueUrl) → destroy clean.✅
cdkd gc— garbage-collect unreferenced objects/images from cdkd-owned asset storage (issue #1012) —src/cli/commands/gc.ts(new),src/cli/commands/state-file-keys.ts(new, extracted frombootstrap-destroy.ts),src/cli/index.ts. Why: cdkd-owned asset storage (issue #1002) is content-addressed and deliberately never deleted oncdkd destroy(another stack or a future rollback may reference the same hash), socdkd-assets-*/cdkd-container-assets-*grow without bound — andcdk gccannot reach them by design. What shipped: top-levelcdkd gc [--region <r>] [--older-than <dur>] [--dry-run] [-y](upstreamcdk gcparity naming; also--state-bucket/--profile/--role-arn/--verbose). Scope is ONE region's storage per invocation; bucket/repo names are read from the region's bootstrap marker (never recomputed — #1011 custom-name compatible); no marker → friendly no-op; CDK bootstrap storage is never touched. References are collected by scanning EVERY state file in the WHOLE state bucket (any--state-prefix; helper extracted into the sharedstate-file-keys.tsso gc andbootstrap --destroycannot drift), deep-walking each document for{S3Bucket, S3Key}pairs,s3://URIs, virtual-hosted / path-stylehttpsURLs (query strings stripped), and ECR image URIs by tag and/or digest. Deletion set = unreferenced AND older than--older-than(default 30d;<n>d/<n>h; missing timestamps treated as new = kept). Guards: anylock.jsonin the bucket aborts (in-flight deploy may have published assets whose state write has not landed); a state file that fails to JSON-parse aborts the whole run; every S3 call pinsExpectedBucketOwnerwith a 403 = foreign-bucket refusal. Reporting prints the per-item reclaim plan + byte totals;--dry-runexits without prompting or deleting; otherwiseContinue? (y/N)default-No confirm (--yesskips; non-TTY without--yeshard-errors); zero candidates → info + exit 0 with no prompt. Deletes via chunkedDeleteObjects(1,000/batch) andBatchDeleteImage(100/batch, by digest), surfacing per-item failures as hard errors. Tests:tests/unit/cli/gc.test.ts(21 cases: every reference-extraction shape incl. custom-prefix state files and outputs; referenced/old vs unreferenced/new keep-delete matrix; lock + malformed-state aborts; no-marker and missing-bucket no-ops; dry-run zero mutations; decline/empty-answer/non-TTY prompt paths;--older-thanparse rejects + cutoff honoring; ListObjectsV2 / DescribeImages pagination; 1,000/100 chunking; DeleteObjects / BatchDeleteImage failure surfacing; ExpectedBucketOwner on every call; marker-sourced names).✅
cdkd bootstrap --asset-bucket <name>/--container-repo <name>— custom asset storage names (issue #1011) —src/cli/commands/bootstrap.ts,src/assets/asset-storage.ts. Why: the cdkd-owned asset storage names were fixed by convention (cdkd-assets-{accountId}-{region}/cdkd-container-assets-{accountId}-{region}); since S3 bucket names are global, a squatted conventional name left--use-cdk-bootstrap-assetsas the region's only remaining option, and org-wide bucket-naming policies could not be met at all. What shipped: two new create-side flags oncdkd bootstrap, validated BEFORE any AWS call (S3: 3-63 lowercase chars/digits/dots/hyphens with letter-or-digit ends; ECR: 2-256 lowercase chars/digits with single._-/separators;validateAssetBucketName/validateContainerRepoNameinasset-storage.ts) and threaded intoensureAssetStorageas optionalassetBucketName/containerRepoNamefields — overriding the conventional names for the existence probe, the create calls, AND the values written into the bootstrap marker. The marker stays the single source of truth: every consumer (deploy redirect/rewrite viaAssetModeResolver, publish,verifyAssetStorageExists,state info, and the #1010 teardown) already reads the marker, so none needed changes.ensureAssetStoragenow reads any existing marker first: a plain re-bootstrap REUSES the marker's names (a custom-named region never grows a second, conventional set), same-names re-runs stay the idempotent verify path, and a requested name that DIFFERS from the marker's hard-errorsASSET_STORAGE_NAME_CONFLICTpointing atcdkd bootstrap --destroy --region <r>(changing names would strand the existing storage + its published assets); a malformed marker is rewritten (re-running bootstrap is its documented fix) while a newerassetSupportVersionstill hard-errors. Flag combos rejected: with--no-assets(contradictory — the flags name storage that--no-assetsskips) and with--destroy(teardown reads names from the marker). The #1007 deploy-time auto-create keeps using conventional names (custom names require the explicit bootstrap), and custom bucket names get the identical squatting defense (ExpectedBucketOwnerprobe, owned-elsewhere hard refusal). Tests: custom names in probe/create/marker, conventional defaults unchanged, differing-names conflict (no AWS calls made), same-names idempotence, plain-re-bootstrap name reuse, custom-name squatting refusal, corrupt/newer marker handling, name-validation accept/reject matrices, and command-level flag threading +--no-assets/--destroy/ invalid-name rejections before any AWS call (tests/unit/assets/asset-storage.test.ts,tests/unit/cli/bootstrap.test.ts).✅
cdkd bootstrap --destroy— teardown of cdkd-created account resources (issue #1010) —src/cli/commands/bootstrap-destroy.ts(new),src/cli/commands/bootstrap.ts. Why:cdkd bootstrapcreates up to four kinds of account resources (state bucket; per-region asset bucket + container-asset ECR repo + bootstrap marker) but there was no reverse command — the CDK CLI equivalent is deleting theCDKToolkitstack, while cdkd users had to hand-delete each piece (aws s3 rb --force,aws ecr delete-repository --force, marker delete). What shipped:--destroyon the existingcdkd bootstrapcommand (honoring--region) tears down ONE region's asset storage: empty (all versions + delete markers) + delete the asset bucket → force-delete the ECR repo → delete the per-region bootstrap marker LAST (mirror of the create side's marker-written-last ordering — a crash mid-teardown leaves the region consistently opted in, deploys hard-error with a re-bootstrap hint, never a silent legacy fallback). Bucket / repo names are read from the marker, never recomputed from the naming convention (compatible with the anticipated custom-name support, issue #1011). Before deleting, every state file in the state bucket is string-scanned for references to the region's asset bucket / repo (the listing spans the WHOLE bucket, so stacks deployed under a custom--state-prefixare covered); any deployed stack still referencing them refuses the teardown with a per-stack listing (--forceoverrides — under--destroythe flag means "skip the reference scan", not the create side's "reconfigure"). The state bucket is kept by default;--include-state-bucketopts it in and is refused (no--forceoverride) while ANY stack state exists (under any--state-prefix— the guard lists the whole bucket) or any OTHER region still holds a bootstrap marker in the bucket. Interactivey/N(default No) confirmation prints the full deletion plan;--yesskips; non-TTY stdin without--yesis a hard error (recreate-confirm-prompt convention). Idempotent: missing pieces are skipped with info lines (mirror ofensureAssetStorage), and every S3 call passesExpectedBucketOwner(a foreign bucket squatting the predictable name is refused, never deleted).--no-assets+--destroyand--include-state-bucketwithout--destroyare rejected as contradictory. Tests:tests/unit/cli/bootstrap-destroy.test.ts(21 tests — teardown order incl. marker-deleted-last, marker-driven custom names,ExpectedBucketOwneron every call, versioned emptying, reference-scan refusal +--forceoverride, no-marker no-op, idempotent skips, foreign-bucket 403 refusal, declined / empty-answer / non-TTY confirmation,--include-state-bucketrefusals + happy path + marker-less state-bucket-only path, flag validation). Docs:docs/cli-reference.mdbootstrap Teardown section,docs/state-management.mdmarker paragraph.
Recently Implemented (2026-07-15):
- ✅
cdkd local run-taskrecognizes cdkd-owned + custom-qualifier container-assets image URIs as CDK assets (PR 3 of 3 of issue #1002) —src/local/ecs-task-resolver.ts. Why: the two ECS container-image classification sites matched only the hardcoded literalcdk-hnb659fds-container-assets-. After PR 2,cdkd deploypublishes container assets into the cdkd-owned repocdkd-container-assets-{acct}-{region}and rewrites templates to point there, so a migrated stack's template (and its--from-statestate) carries the cdkd repo shape — which the local run-task resolver did NOT recognize, falling through to a plain ECR pull instead of resolving the image back to the on-diskcdk.outbuild. The same sites also missed anycdk bootstrap --qualifier <custom>account (a pre-existing gap — only thehnb659fdsdefault matched). What shipped (PR 3 of 3): both classification sites (the flat /Fn::Subpath and the sharedclassifyResolvedImageused by theFn::GetAtt/Fn::JoinfromEcrRepositorypaths) now go through one helperisCdkAssetImageUri, whose regex(?:cdk-[a-z0-9]+|cdkd)-container-assets-matches BOTH the generalized CDK-bootstrap repo (cdk-<qualifier>-container-assets-, any qualifier) and the cdkd-owned repo (cdkd-container-assets-). A recognized URI classifies askind: 'cdk-asset'(resolve viacdk.out) instead ofkind: 'ecr'(pull from ECR). No cdk-local upstream change is needed: cdkd keeps its OWN still-localecs-task-resolver.ts/lambda-resolver.ts(they are not shimmed from cdk-local), and cdk-local'sintrinsic-imagehelpers only do placeholder substitution +Fn::Join→URI resolution — they never classify the container-assets prefix, so all cdkd-side classification lives in this file. The Lambda local-invoke path is unaffected (it resolves container images by assetsourceHash+ ECR-pull fallback, not by repo-prefix classification). Tests: 3 newecs-task-resolverunit tests —cdkd-container-assets-Fn::Sub→cdk-assetwith the hash extracted; custom-qualifiercdk-myqual123-container-assets-Fn::Sub→cdk-asset; aFn::JoinfromEcrRepositorywhose state-resolved repo name iscdkd-container-assets-...→cdk-asset(exercising the sharedclassifyResolvedImagesite). - ✅ Asset publishing redirects to cdkd-owned storage + template references rewrite once a region is opted in (PR 2 of issue #1002) —
src/assets/asset-redirect.ts(new),src/assets/asset-publisher.ts,src/assets/asset-storage.ts,src/deployment/deploy-engine.ts,src/provisioning/providers/nested-stack-provider.ts+nested-stack-context.ts,src/cli/commands/{deploy,diff,diff-recursive,import,publish-assets}.ts,src/cli/{options,config-loader}.ts. Why: PR 1 shipped detection-only; assets still published to the CDK bootstrap storage thatcdk gcgarbage-collects. What shipped (PR 2 of 3): incdkd-assetsmode (bootstrap marker present), (1) a destination-driven §6 mapping table is built per stack from*.assets.json— only default-bootstrap-shaped destinations (cdk-<qualifier>-(container-)?assets-{acct}-{region}, exactly the gc-exposed population) map to the marker's bucket/repo; customfileAssetsBucketName/ staging buckets / cross-region destinations stay verbatim (§8), andobjectKey/imageTagflow through unchanged; (2) the publishers redirect through that table (AssetPublisher.addAssetsToGraph({redirect})— deploy ANDpublish-assets, which now reads the marker via the standard state-bucket chain and falls back to legacy with an info line when none resolves); (3) the §7 template rewrite replaces every boundary-matched source name (plain strings,Fn::Subtemplate strings, folded pseudo-parameter-onlyFn::Joinruns; lookalike names like<bucket>-backupare never corrupted) — applied bydeploy(top-level + every nested child template viaNestedStackProviderContext.assetRedirect),diff(incl.--recursivechildren, so the plan previews the one-time migration diff), andimport(top-level + the recursive CFn-migration child walk, so imported state matches the next deploy);synth/exportstay unrewritten by design (§7.1); (4) a post-resolution audit in the deploy engine (DeployEngineOptions.assetRedirect) fails any resource whose resolved properties still name a mapped source — a missed template shape becomes a loud pre-provisioning error, never a split-brain deploy; (5)--use-cdk-bootstrap-assets(deploy / diff / import / publish-assets) +cdk.json context.cdkd.useCdkBootstrapAssetspin legacy destinations per invocation / per app (skips the marker read and the gc notice) for CFn-co-deployed apps in a migration window. The first deploy after opt-in shows a one-time all-assets UPDATE repointingCode/ImageUri/ asset URLs in place (no replacement, no state schema change); an opt-out deploy repoints back — flip-flop is churn, never breakage (both storages hold the content-addressed objects). Tests:tests/unit/assets/asset-redirect.test.ts(every §8 destination-shape row, Fn::Sub / Fn::Join fold / boundary cases, audit, publish-time redirect, lazy STS gating),deploy-engine-asset-audit.test.ts, nested-provider + diff-recursive rewrite tests,resolveUseCdkBootstrapAssetsconfig tests; integtests/integration/asset-migration/(legacy deploy → bootstrap → migration diff → in-place repoint incl. nested child + env-var asset URL read back fromGetFunctionConfiguration→ opt-out round-trip → Docker/ECR leg → clean destroy). - ✅
cdkd bootstrapnow creates cdkd-owned asset storage + per-region marker; deploy detects the asset mode (PR 1 of issue #1002) —src/assets/asset-storage.ts(new),src/cli/commands/bootstrap.ts,src/cli/commands/deploy.ts,src/cli/commands/state.ts. Why:cdk gcdecides "in use" by scanning CloudFormation stack templates in the environment; cdkd-deployed stacks have no CFn stack, so every cdkd-published asset in the CDK bootstrap bucket/repo looks isolated to gc and gets deleted (ECS task launches fail to pull, runtimes3.Assetreaders break). Storage not referenced by the CDK bootstrap stack is structurally out of gc's reach — hence cdkd-owned asset storage (design atdocs/design/1002-cdkd-asset-storage.md, moved onto main by this PR). What shipped (PR 1 of 3): (1)cdkd bootstrapadditionally creates the asset bucketcdkd-assets-{accountId}-{region}(AES-256 + BucketKey, deny-external-account policy, deliberately NO versioning — immutable content-addressed blobs) and the container-asset ECR repocdkd-container-assets-{accountId}-{region}(IMMUTABLE tags), then writes the markers3://{stateBucket}/cdkd-bootstrap/{region}.jsonLAST (crash mid-bootstrap leaves no marker → deploys stay legacy).--no-assetsopts out. Bucket-squatting defense: refuses owned-elsewhere buckets (HeadBucket+ExpectedBucketOwner,BucketAlreadyExistshard error); the marker-verification path also passesExpectedBucketOwner. Re-running bootstrap on an existing account no longer early-returns — the state bucket is left as-is (no--forceneeded) and the asset storage + marker are added, which is the documented upgrade path. (2) Deploy-sideAssetModeResolverreads the marker once per (account, region) per invocation (cached, single-flight): absent → legacy mode, byte-identical publish behavior plus ONE info line about the gc hazard (only when the deploy actually publishes assets); present →cdkd-assetsmode with existence verification of bucket + repo (missing → hard error naming the resource +cdkd bootstrap --region <r>fix — never a silent fallback that would flip-flop properties); malformed marker → hard error. The resolved mode is detection-only in this PR — the publish redirection + template rewrite + post-resolution audit land in PR 2 of the phasing;cdkd localmatching lands in PR 3. (3)cdkd state infolists opted-in regions (Asset storage:line;assetStorage: [{region, assetBucket, containerRepo, createdAt}]in--json, empty array = legacy everywhere; malformed markers are skipped with a warning there since it is a cosmetic command). No state schema bump (v8 stays current); old-binary rollback is safe (old binaries ignore the marker). The asset bucket also gets a public-access block, every configuration PUT is owner-pinned viaExpectedBucketOwner, a marker withassetSupportVersionabove this binary's is hard-rejected ("upgrade cdkd"), and--profileis threaded into the verification clients (without it, the bucket's own deny-external policy turns the HeadBucket probe into a misleading foreign-bucket 403). Unit tests: asset-storage module (naming/marker parse+version guard/resolver legacy+cdkd-assets+missing-resources+cache/ensureAssetStorage idempotency+squatting+marker-last ordering), bootstrap command (asset creation,--no-assets, existing-bucket continue-past, custom-bucket marker targeting), state info (assetStoragein text + JSON). New real-AWS integ fixturetests/integration/asset-bootstrap/(verify.sh): legacy gc-notice exactly once -> bootstrap creates bucket/repo/marker with the exact hardening -> idempotent re-run -> cdkd-assets-mode deploy with no notice -> deleted-repo deploy hard error naming the repo + fix -> clean destroy + full storage cleanup. Docs: README prerequisites + quick start,docs/cli-reference.mdnewcdkd bootstrapsection,docs/state-management.mdmarker key layout.
Recently Implemented (2026-07-03):
✅ Fix: an in-place UPDATE that changes a
Fn::GetAtt-consumed derived attribute now propagates to aNO_CHANGEdependent in the SAME deploy —src/analyzer/diff-calculator.ts(issue #985, the in-place sibling of #807). Bug: when an in-place update bumps a computed read-only attribute a dependent consumes viaFn::GetAtt, the dependent stayed one deploy behind. Concrete case: anec2.LaunchTemplate+autoscaling.AutoScalingGroupwhere the ASG'sLaunchTemplate.VersionisFn::GetAtt [Lt, LatestVersionNumber]. Changing the LaunchTemplate'sinstanceTypebumpsLatestVersionNumber1 → 2, but the ASG was classifiedNO_CHANGE(its raw template did not change and diff-time resolution saw the pre-update version "1"), so it stayed pinned at version "1" — running the same deploy AGAIN updated it to "2", proving the value was merely one deploy behind. Unlike #807 (a replacement moves the physical id, so EVERY reference is affected), here there is NO replacement — an in-place update side-effects a derived attribute value. Root cause: the existing in-place propagation pass (promoteInPlaceAttributeDependents) only promotes a dependent when theFn::GetAttattribute NAME equals a template property that CHANGED on the upstream.LatestVersionNumberis a computed AWS-derived attribute (resolved live via theDescribeLaunchTemplatesspecial case inintrinsic-function-resolver.ts), never a template property, so that arm never matched. Fix: a second, allow-list-driven arm promotes aNO_CHANGEdependent when it reads — viaFn::GetAttorFn::Sub${Up.Attr}— a DERIVED read-only attribute (per-typeIN_PLACE_UPDATE_DERIVED_ATTRS; seeded withAWS::EC2::LaunchTemplate→LatestVersionNumber/DefaultVersionNumber) of an upstream that had an in-place (non-replacement) UPDATE — regardless of WHICH property changed, since anyLaunchTemplateDataedit bumps the version. Replaced upstreams are excluded from the new arm (they are already handled transitively bypromoteReplacementDependents, so #807 is neither regressed nor double-promoted); the fix deliberately does NOT subsume #807 — the two passes stay separate and complementary. Promotion is safe when speculative: the deploy engine re-resolves the promoted dependent against the fresh LIVE value and skips the provider call if it did not move, and the allow list keeps unrelated computed attributes (e.g. a LambdaArn) from ever triggering a spurious dependent update. 5 new unit tests (ASG promoted via GetAtt + viaFn::Sub; the synthetic change is an in-place UPDATE not a replacement; unchanged-LT staysNO_CHANGE; a non-allow-listed computed attr of an updated LT staysNO_CHANGE). New real-AWSlaunchtemplate-asg-inplaceinteg fixture (VPC + LaunchTemplate + ASG,desiredCapacity: 0): Phase 1 asserts ASGLaunchTemplate.Version == "1", the UPDATE phase changes onlyinstanceTypeand asserts the ASG re-points to"2"in the same deploy, then a clean destroy.covers: AWS::EC2::LaunchTemplate, AWS::AutoScaling::AutoScalingGroup.✅ Fix:
Fn::GetAtton CC-routedAWS::Backup::*resources returns the real ARN / VersionId / SelectionId instead of falling back to the physical id —src/provisioning/cloud-control-provider.ts(issue #984). Bug:AWS::Backup::*types have NO SDK provider (pure Cloud Control), and the CC CREATEResourceModelis sparse for Backup, soFn::GetAtt(<Vault>, 'BackupVaultArn')(the canonical CDK shape, emitted byvault.backupVaultArn) fell through cdkd's intrinsic-resolverconstructAttributedefault to the physicalId — which for aBackupVaultis the vault NAME, not the ARN — with only awarn. Deploy stayed green (a silent GetAtt divergence); any downstream consumer that needs the real ARN (an IAM policy, a cross-stack reference, aCfnOutput) got the bare name. Same systemic enrichment-gap bug class as #844 / #864 / #865 / #866 / the Events::Connection Arn fix. Fix: addAWS::Backup::BackupVault(BackupVaultArn/EncryptionKeyArn/BackupVaultName),AWS::Backup::BackupPlan(BackupPlanArn/VersionId/BackupPlanId), andAWS::Backup::BackupSelection(SelectionId/BackupPlanId, extracted from the compound<SelectionId>|<BackupPlanId>CC primaryIdentifier) cases toenrichResourceAttributes. Each overlays the readOnly attributes from a generic Cloud ControlGetResourceread-back on the physicalId (the cleanest source for a type with no SDK provider) via a new best-effortreadBackupResourceModelhelper — a failed read leaves the CC attribute shape unchanged and never fails the deploy, with physicalId-derived fallbacks (BackupVaultName/BackupPlanIdfrom the physicalId,SelectionId/BackupPlanIdfrom the compound-id split) covering the read-fails path. All three types are pure-CC (no SDK provider, so NO cfn-schemas fixture per the established pattern — theno stale fixture files for unregistered typestest would hard-fail otherwise); they joindocs/_generated/enrichment-coverage.json's auto-generatedenrichedWithoutCachedSchemalist. 6 new unit tests (Vault ARN/EncryptionKeyArn overlay + best-effort failure with the name fallback still landing; Plan ARN/VersionId overlay + best-effort failure with the BackupPlanId fallback; Selection compound-id extraction + read-fails compound-split fallback). New real-AWSbackupinteg fixture deploys aBackupVault(removalPolicy DESTROY) +BackupPlanreferencing the vault + a tag-basedBackupSelection, exposesFn::GetAtt(Vault, 'BackupVaultArn')as aCfnOutput('VaultArn'), and asserts the resolved output STARTS WITHarn:aws:backup:(not the bare vault name) — then destroys clean (the empty vault deletes cleanly, no recovery points created).covers: AWS::Backup::BackupVault, AWS::Backup::BackupPlan, AWS::Backup::BackupSelection.✅ Fix: inline
AWS::SNS::TopicSubscriptionlist is now created AND updated (was silently dropped end-to-end) —src/provisioning/providers/sns-topic-provider.ts(issue #980). Bug: the SNS Topic provider declaredSubscriptioninhandledProperties(keeping it on the SDK fast path) butcreate()deliberately skipped it,update()had no branch, andgetDriftUnknownPaths()excluded it — so any Topic carrying an inlineSubscriptionlist got NO subscriptions on AWS while cdkd reported success, and drift was blind to it. The old comment assumed CDK always manages subscriptions as separateAWS::SNS::Subscriptionresources — true for L2topic.addSubscription(), but NOT for L1CfnTopicwithsubscription: [...]or migrated CloudFormation templates, which declare them inline on the Topic (exactly as CloudFormation creates and updates them). Fix:create()issues oneSubscribeper entry (requiredProtocol+Endpoint, plus documented optional attributes —RawMessageDelivery/FilterPolicy/FilterPolicyScope/RedrivePolicy/DeliveryPolicy/ReplayPolicy/SubscriptionRoleArn— passed through the SubscribeAttributesmap, object-valued policies JSON-stringified via the exportedbuildSubscriptionAttributeshelper);update()diffs old vs new lists on(Protocol, Endpoint)identity — added entriesSubscribe, removed entries resolve their liveSubscriptionArnviaListSubscriptionsByTopic(paginated,PendingConfirmationskipped) thenUnsubscribe.getDriftUnknownPaths()now returns[];readCurrentState()reverse-maps the live(Protocol, Endpoint)pairs viaListSubscriptionsByTopic— but ONLY when state actually recorded an inlineSubscriptionlist, so a Topic whose subscriptions are separate resources (L2) does not surface them as phantom drift. 7 new provider unit tests (create Subscribes per entry + attribute JSON-stringify + no-op when absent; update add / remove-with-ARN-resolution / PendingConfirmation-skip / unchanged-no-op) + 2 readCurrentState tests (reverse-map when state carries the list / omit otherwise) + the drift-unknown assertion flipped to[]. Newsns-inline-subscriptioninteg fixture (L1CfnTopicinline-subscribed toCfnQueueA, UPDATE phase switches the endpoint to queue B and asserts B subscribed + A unsubscribed, clean destroy).covers: AWS::SNS::Topic.✅ Fix: removing
LoggingConfiguration/TracingConfiguration/EncryptionConfigurationfrom a StepFunctions StateMachine is now actually applied on UPDATE —src/provisioning/providers/stepfunctions-provider.ts(issue #978). Bug:UpdateStateMachineis patch-style — a field omitted from the request keeps its current AWS value. The provider mapped all three configs from the NEW properties only, so a config REMOVED from the template mapped toundefined, was omitted fromUpdateStateMachineCommand, and AWS silently kept the old config (the removal never reached AWS; cdkd state said "gone" while AWS kept logging / X-Ray tracing / a customer-managed KMS key). Fix: the same removal-needs-clear-sentinel pattern as Lambda ESM'sclearOnUpdateRemoval/ SQS'sSQS_ATTRIBUTE_REMOVAL_RESET— when a config is present+configured inpreviousPropertiesbut absent (or an emptyreadCurrentStateplaceholder) in the newproperties,update()sends the explicit disable payload instead ofundefined: tracing →{ enabled: false }, logging →{ level: OFF, includeExecutionData: false, destinations: [] }(SDKLogLevel.OFF; destinations only required when level is not OFF, so the empty list is accepted), encryption →{ type: AWS_OWNED_KEY }(SDKEncryptionType.AWS_OWNED_KEY, the AWS default — resets a customer-managed CMK config). AwasMeaningfullyConfiguredguard (discriminatorLevelfor logging,Typefor encryption,Enabled === truefor tracing) keeps thereadCurrentStateempty-placeholder round-trip ({}/{ Enabled: false }) a no-op socdkd drift --revertdoes not spuriously reaffirm the AWS default. 8 new unit tests (each config's disable shape; all three at once; never-configured-before → no sentinel; still-present → mapped through unchanged; placeholder round-trip → no-op). Thestepfunctions-logginginteg's UPDATE phase now REMOVES both logging and tracing (definition unchanged) and asserts AWS reports logging level OFF, 0 destinations, and tracing disabled — the removed logs + tracing also naturally shrink the role's default policy in the same deploy.covers: AWS::StepFunctions::StateMachine.✅ Fix: DynamoDB
StreamSpecificationchanges now apply on UPDATE — enabling / disabling / changing a stream is no longer silently dropped —src/provisioning/providers/dynamodb-table-provider.ts(issue #977). Bug:StreamSpecificationis classified updateable and stays on the SDK path (it is inhandledProperties), butupdate()had NO StreamSpecification branch — enabling a stream, changing theStreamViewType, and removing a stream were ALL silently dropped:cdkd deployreported success, state.json recorded the new spec, but AWS never got anUpdateTablewith the stream change, so the next diff saw no change and it could never self-heal (create() and readCurrentState() DID handle it, so a fresh-create table with a stream worked — only the update path was broken). Fix: a StreamSpecification branch mirroring the SSESpecification pattern (its ownUpdateTable, fired only whenJSON.stringify(new) !== JSON.stringify(old)), with three transitions keyed off whether the spec is present on each side: enable (StreamEnabled: true+ the newStreamViewType, wait ACTIVE, captureLatestStreamArn); disable (new absent, previous present →StreamEnabled: false, clear the stream ARN); and view-type change (both present, differentStreamViewType) — AWS REJECTS a direct StreamViewType switch on an enabled stream, so it is applied as disable → wait ACTIVE → re-enable with the new view type → wait ACTIVE (the wait between the two calls is load-bearing: DynamoDB also rejects a rapid disable-then-enable against a still-UPDATING table). The freshly-materializedLatestStreamArnis enriched back into the returnedStreamArnattribute (from the enableUpdateTable'sTableDescription, falling back to aDescribeTable) soFn::GetAtt [Table, StreamArn]resolves after an update-time enable rather than returning the pre-update value. 5 new unit tests (enable pinsStreamEnabled: true+ the fresh StreamArn; DescribeTable fallback when UpdateTable omits the ARN; removal pinsStreamEnabled: false+ cleared StreamArn; view-type change pins the disable → wait → re-enable sequence; unchanged → no UpdateTable). Thedynamodb-streamsinteg gained an enable-on-UPDATE phase (CDKD_TEST_UPDATE=true): Phase 1 deploys a stream-LESS table, the UPDATE phase addsstream: NEW_AND_OLD_IMAGES(+ a Lambda / EventSourceMapping consumer, which can only exist once a stream does) and assertsStreamSpecification.StreamEnabled == true+LatestStreamArnnon-null + the cdkdStreamArnoutput equals AWS's LatestStreamArn.covers: AWS::DynamoDB::Table.✅ Feature: ApiGateway Stage
MethodSettingsare now wired into the SDK provider — throttling/logging/metrics/caching stages stay on the SDK fast path —src/provisioning/providers/apigateway-provider.ts(issue #966, the defense-in-depth half of #963). Background:MethodSettings(CDK's everydaydeployOptions.throttlingRateLimit/metricsEnabled/loggingLevel/caching*) was a silent-drop property, so any stage carrying it CC-routed via #614 — paying CC polling latency on one of the most common resource shapes, and (pre-#963) breakingRef. Implementation: CreateStage does not accept method settings, socreate()issues one post-create UpdateStage withreplaceops per specified field under/{method_setting_key}/...(the key is{resource_path}/{http_method}with the leading slash stripped — CDK's stage-level options key as the star-slash-star wildcard; CFnResourcePathis already~1-escaped so no re-encoding).update()diffs old vs new entries on the same UpdateStage call: changed/new fields →replace; a whole dropped entry →remove /{key}(clears every override for that method path, CFn absent-entry semantics); a field dropped from a KEPT entry → reset-and-rebuild (remove /{key}+replaceof every remaining field) because API Gateway rejects field-level removes ("Cannot remove method setting ... because there is no method setting for this method") while whole-key remove followed by field replaces in the same call applies sequentially — both verified by live UpdateStage probes (2026-07-03). The root resource path (bare/) keys as~1(~1/GET, also live-verified) — plain slash-stripping would build the malformed//GET/...patch path. A post-create UpdateStage failure best-effort-deletes the just-created stage before rethrowing, so the corpse cannot hold the stage name and kill every retry with ConflictException (the PR #957 class). All 10 CFn MethodSetting fields are mapped (throttling rate/burst, metrics, logging level/dataTrace, caching enabled/ttl/encrypted/requireAuth/unauthorizedStrategy).readCurrentStateStage(drift) rebuilds the CFn list from the get-stagemethodSettingsmap, iterating the STATE's entries so list order matches the baseline and emitting only state-declared fields (get-stage fills every default, which would otherwise phantom-drift); entries still emit when AWS returns no methodSettings map at all, so out-of-band removal of every override surfaces as drift instead of dropping out of the comparison. 9 new unit tests (create post-patch; changed-field-only update; entry-drop + reset-and-rebuild; all-10-fields patch-path table incl. false/0 values + the root~1key; MethodSettings riding the same UpdateStage as other stage ops; post-create-failure stage cleanup; unchanged → no call; drift readback order/field filtering + missing-map emission). Theapigatewayinteg gaineddeployOptions.throttling*+ an assertion that the throttling reached AWS ANDprovisionedBy == sdk(the stage no longer CC-routes). Theapigw-stage-throttlingfixture (the #963 CC-route regression) swapped its trigger to the still-unwiredAccessLogSettingso it keeps exercising the CC path — its own guard message predicted exactly this swap. Both integs verified end-to-end against real AWS (0 orphans).covers: AWS::ApiGateway::Stage.✅ Fix:
Refon CC-routed ECS::Service / S3Tables Namespace + Table returns the CFnRefcomponent; WAFv2::WebACLRefreturns the CFnname|id|scopecompound on the SDK path —src/deployment/intrinsic-function-resolver.ts,src/provisioning/providers/wafv2-provider.ts(cross-family close-out of the #963 routing-triggered compound-Refclass). Audit: enumerated every SDK-registered type (116) and cross-checked each against its CC registryprimaryIdentifier; 16 are compound. The ApiGateway / ApiGatewayV2 families were fixed in PR #967 / #969; this PR closes out the remaining 7 after verifying each type's AWS-docs "Return values / Ref": (1)AWS::ECS::Service[ServiceArn, Cluster]—Refis the service ARN (FIRST segment), so a #614-routed service would have leakedarn:...|<cluster>into every ARN consumer; joins the before-first-pipe Set (the SDK path stores the bare ARN — no-op). (2)AWS::S3Tables::Namespace/::Table—Refis the namespace / table NAME, but their SDK provider ITSELF stores the compound (<bucketArn>|<ns>/<bucketArn>|<ns>|<table>), so the after-pipe extraction is load-bearing on the SDK path for both types (these were broken on every deploy thatRefs them). On the CC path it is load-bearing for Namespace only (compound[TableBucketARN, Namespace]primaryIdentifier); Table's CC primaryIdentifier is the bare single-segmentTableARN(describe-type-verified), so a #614-routed Table stores a pipe-free ARN whoseRefstill returns the ARN instead of the documented table name — a KNOWN RESIDUAL pinned by a pass-through unit test and the Set's maintenance note (not fixable from the physical id alone: Table ARNs end in a UUID, so a fix needs the storedTableNameattribute). (3)AWS::WAFv2::WebACLis the INVERSE divergence: CFn'sRefIS the pipe-joinedname|id|scopecompound (docs-explicit) — matching the CC identifier — while the SDK provider stores the ARN; a new special case reconstructs the compound from the ARN via the provider's now-exportedparseWebACLArn(global-scope ARNs map to theCLOUDFRONTscope word). Excluded with documented rationale (synthetic / undocumentedRef, no consuming API): EC2::Route, EC2::VPCGatewayAttachment, Lambda::Permission, Lambda::EventInvokeConfig; ApiGateway::Method / ::DocumentationVersion were already excluded. 9 new unit tests (ECS compound + ARN pass-through; S3Tables 2- and 3-segment + CC-routed-Table bare-ARN pass-through pinning the residual; WAFv2 ARN→compound regional/global + CC pass-through + malformed-ARN pass-through — 5 fail without the fix). Broadmulti-resourceinteg re-ran clean for the resolver change.✅ Fix:
Refon a Cloud-Control-routed ApiGatewayV2 Stage / Route / Integration / Model / Deployment / RouteResponse / IntegrationResponse / Authorizer / ApiMapping returns the CFnRefcomponent —src/deployment/intrinsic-function-resolver.ts(issue #963 follow-up family audit; the V2 sibling of the entry below). Background: the prior compound-id audit (the CognitoUserPoolUserentry, 2026-06-28) cleared the ApiGatewayV2 family under the "pure-CC types only" criterion — but #963 established the ROUTING-TRIGGERED variant: the V2 family has an SDK provider (pipe-free physical ids), yet any instance carrying a property the provider does not wire (e.g. a Stage withAccessLogSettings, an Integration withResponseParameters/TlsConfig) routes through Cloud Control via #614 and stores the compound primaryIdentifier — and every V2 compound type was missing from the extraction Sets, soRefleaked the compound id to consumers like a Route'sTarget: integrations/{Ref <Integration>}/AuthorizerId: {Ref: <Authorizer>}and an ApiMapping'sStage: {Ref: <Stage>}. Fix (all 9 types docs-verified +describe-type-verified):Stage/Route/Integration/Model/Deployment(2-segment[ApiId, <ref>]) andRouteResponse/IntegrationResponse(3-segment, Ref = trailing) join the after-pipe Set;Authorizer([AuthorizerId, ApiId]) andApiMapping([ApiMappingId, DomainName]) are REVERSED and join the before-first-pipe Set — which now exists (from the #963 fix), so the prior audit's "ApiMapping is a trap, adding it would create the inverse bug" carve-out is finally resolvable. The V1/V2 families are CROSS-WIRED (V1 Authorizer trailing / V2 Authorizer FIRST; V1 Deployment FIRST / V2 Deployment trailing) — a new WARNING block in the Set comment pins this so nobody pattern-matches one family from the other; the unit tests pin both directions.Api/VpcLink/DomainNamehave simple pipe-free primaryIdentifiers (no change). 13 new unit tests (each type CC-compound, per-branch SDK pass-through, and the daily Route-wiringFn::Join integrations/{Ref}+AuthorizerIdshapes — 10 fail without the fix). No new integ fixture: the extraction mechanism is byte-identical to the #963 fix, which was live-verified end-to-end (apigw-stage-throttling); the broadmulti-resourceinteg re-ran clean for the resolver change.✅ Fix:
Refon a Cloud-Control-routed ApiGateway Stage / Resource / Authorizer / Deployment / DocumentationPart returns the CFnRefcomponent, not the compound physical id —src/deployment/intrinsic-function-resolver.ts(issue #963, found by/hunt-bugssweep 11). Bug: a REST API whose Stage carriesMethodSettings(synthesized by CDK's everydaydeployOptions.throttlingRateLimit/metricsEnabled/loggingLevel) routes the Stage through Cloud Control via the #614 silent-drop routing, which stores the compound<restApiId>|<stageName>primaryIdentifier as the physical id.AWS::ApiGateway::Stagewas missing fromREF_RETURNS_SEGMENT_AFTER_PIPE, so the CDK-generated Lambda PermissionSourceArn(anFn::Joinover{Ref: <Stage>}) resolved toarn:...:execute-api:...:<apiId>/<apiId>|test/GET/hello— API Gateway could not invoke the Lambda and the deployed API returned 500 on every request while the deploy reported success. Causation proven live: a manually corrected SourceArn immediately returned 200. Fix + family audit (per the Set's maintenance note):Stage/Resource/Authorizerjoin the after-pipe Set (docs-verified:Refreturns the trailing stage name / resource id / authorizer id);Deployment/DocumentationParthave REVERSED primaryIdentifier order ([<refId>, <restApiId>]— theRefcomponent comes FIRST), so a new sibling SetREF_RETURNS_SEGMENT_BEFORE_FIRST_PIPEextracts the segment before the first pipe (Deployment matters in every CDK template: the Stage'sDeploymentIdis{Ref: <Deployment>}).Method/DocumentationVersionare excluded — their AWS-docs pages document noRefreturn value. SDK-provisioned instances of all five types store pipe-free ids, so the extraction is a no-op there. 12 new unit tests (each type CC-compound + SDK-pass-through + the exact Lambda-PermissionFn::Joinshape — 6 fail without the fix). New real-AWSapigw-stage-throttlinginteg fixture pins the full chain: Stage really takes the CC route (provisionedBy == cc-apiguard), the Lambda resource policy SourceArn carries the bare stage name, GET /hello functionally serves, and the UPDATE phase adds a route (hash-suffixed replacement Deployment swaps in, old one deleted). Verified end-to-end against real AWS (0 orphans).covers: AWS::ApiGateway::Stage.✅ Fix: replacing a custom-named resource now fails with an actionable error — or succeeds via
--replacedelete-first — instead of a rawAlreadyExists—src/deployment/deploy-engine.ts(issue #960 follow-up, surfaced by/hunt-bugssweep 10). Bug: a property-driven replacement uses CloudFormation's safe order (create the new resource BEFORE deleting the old), but a resource with a user-supplied physical name cannot reuse the occupied name — the create-first attempt always died with a confusingAlreadyExistsand no remediation. CloudFormation refuses the same shape with "cannot update a stack when a custom-named resource requires replacing" and offers only renaming. Fix: the create-first attempt catches an already-exists-shaped failure and (1) without--replace, throwsNAMED_REPLACEMENT_COLLISIONexplaining BOTH remediations — rename (CFn parity) or re-run withcdkd deploy --replacefor a delete-first recreation under the same name (a working one-command path CloudFormation lacks; brief unavailability); (2) WITH--replace, falls back to delete-first automatically (the property-driven stateful guard has already run at this point, so stateful types still require--force-stateful-recreation); (3) underUpdateReplacePolicy: Retain, always refuses with a Retain-specific message — the retained old resource pins the name, so renaming is the only option. Non-collision create failures pass through unchanged. 4 new unit tests (actionable error + old resource untouched; delete-between-the-two-creates ordering under --replace; Retain refusal; non-collision passthrough — 3 of 4 fail without the fix). Theeventbridge-pipesinteg gained two phases: aSourceswitch (length-1 createOnly) on the named pipe FAILS without--replacewith the actionable message AND leaves AWS unchanged, then succeeds WITH--replaceunder the same pipe name — verified end-to-end against real AWS (0 orphans).covers: AWS::Pipes::Pipe.
Recently Implemented (2026-07-02, second batch):
✅ Fix: nested
createOnlyPropertiesno longer over-trigger replacement — the schema fallback now compares at path granularity —src/provisioning/create-only-properties.ts,src/analyzer/diff-calculator.ts(issue #960, found by/hunt-bugssweep 10). Bug: the CFn-schema createOnly fallback reduced nested registry paths to their top-level containing property, so for types whose createOnly entries are ONLY nested sub-paths the whole top-level property became immutable.AWS::Pipes::Pipe:SourceParametersitself is mutable and only stream-source sub-paths under it (.../KinesisStreamParameters/StartingPositionetc.) are createOnly — an SQS pipe changingSourceParameters.SqsQueueParameters.BatchSize(CFn: "Update requires: No interruption") was classified as a REPLACEMENT, and because the pipe carried a user-suppliedNamethe create-first replacement immediately died withAlreadyExists, hard-failing the deploy. Fix:getCreateOnlyPropertyPathsnow returns the FULL segment paths (RFC 6901 pointers after/properties/), and the new purecreateOnlyChangeRequiresReplacement(paths, key, oldValue, newValue, valuesEqual)decides per changed top-level property: a length-1 path replaces on any change (unchanged behavior); a NESTED path replaces ONLY when the value AT that path differs between old and new — absent containers on both sides resolve equal (the SQS-pipe case), while unresolvable shapes (array/scalar mid-path,*wildcard segments) stay conservative (replacement, the pre-fix behavior). The diff calculator injects its ownvaluesEqualso nested comparisons use the same equality as the top-level diff. 15 new/updated unit tests (path extraction incl. RFC 6901 unescape; the pure comparator's 8 branches; diff-level Pipes BatchSize-stays-UPDATE + StartingPosition-replaces — 17 of 21 fail without the fix). Theeventbridge-pipesinteg gained an UPDATE phase (BatchSize 1 -> 2) that asserts the deploy log contains NOReplacing Pipeline AND the new BatchSize reached AWS in place — verified end-to-end against real AWS (0 orphans).covers: AWS::Pipes::Pipe.✅ Fix: schedules in a custom EventBridge Scheduler group are now manageable — new
AWS::Scheduler::ScheduleSDK provider —src/provisioning/providers/scheduler-schedule-provider.ts(new),src/provisioning/register-providers.ts(issue #961, found by/hunt-bugssweep 10). Bug: the type's registryprimaryIdentifieris/properties/Nameonly, but the AWS read/update/delete handlers resolve a bare Name against the DEFAULT schedule group — a schedule created with a customGroupNamewas unaddressable via Cloud Control by ANY identifier form (bare name → NotFound;grp|name→ ValidationException; ARN → name-pattern rejection; no additionalIdentifiers). Empirically: UPDATE failedNotFound, and DELETE landed FAILED/NotFound which the CC delete path swallowed as idempotent success — silently orphaning a LIVE schedule that keeps firing its target while cdkd state forgot it (a whole-stack destroy was masked by the group-delete cascade; a schedule-only removal orphaned for real). CloudFormation is unaffected (its handler invocations carry the full previous model incl. GroupName). Fix: a dedicated SDK provider (@aws-sdk/client-scheduler) threadsGroupNamefrom the resource properties through Create/Update/Delete/GetSchedule. physicalId stays the schedule NAME (CFnRefparity + zero-migration from CC-provisioned state).UpdateSchedulesends the full desired configuration (it is a full-replace API). AGroupNamechange throws the typedResourceUpdateNotSupportedError(GroupName ADDRESSES the schedule; the--replacefallback recreates it in the new group).readCurrentState(drift) +import(explicit id / templateName, no tag lookup — schedules are not taggable) included. CFnStartDate/EndDateISO strings are converted to SDKDates. 21 new unit tests (GroupName threading on all CRUD; typed GroupName-change rejection incl. custom→default; NotFound-idempotent delete with region check; drift read-back incl. default-group normalization; import paths). New real-AWSscheduler-custom-groupinteg fixture pins the two bug paths: in-place UPDATE of a custom-group schedule reaches AWS, and a schedule-only removal (group kept) actually deletes the schedule from AWS instead of silently orphaning it. The existing default-groupeventbridge-schedulerinteg re-run as the routing regression.covers: AWS::Scheduler::Schedule.✅ Fix: changing a log group's
KmsKeyIdon redeploy is no longer silently dropped —src/provisioning/providers/logs-loggroup-provider.ts. Bug (issue #958 item 1, found by the #954 review):ReplacementRulesRegistryclassifiesKmsKeyIdas updateable forAWS::Logs::LogGroup, butupdate()had NO KmsKeyId branch — associating (or removing) a KMS key on an existing log group reported deploy success while AWS kept the group unencrypted (or encrypted), and state recorded the new value so the next diff saw no change and it could never self-heal. CloudFormation applies it in place ("Update requires: No interruption") viaAssociateKmsKey/DisassociateKmsKey. Fix:update()now associates on set/change and disassociates on removal, placed after the LogGroupClass guard and before the retention branch. 4 new unit tests (add / change / remove / unchanged-no-call with unrelated updates proceeding). New real-AWSloggroup-kms-associateinteg fixture proves BOTH directions reach AWS (phase 2 associate, phase 3 disassociate) — note the fixture must grant the CloudWatch Logs service principal in the KEY POLICY itself (the logs L2 does NOT wire this automatically; without itAssociateKmsKeyfails with "The specified KMS key does not exist or is not allowed to be used", matching the CFn docs' requirement). Destroy leaves the KMS key in its AWS-mandated 7-day PendingDeletion window (not an orphan). Verified end-to-end against real AWS (0 orphans).covers: AWS::Logs::LogGroup, AWS::KMS::Key.
Recently Implemented (2026-07-02):
✅ Fix: standalone delete of a custom-bus EventBridge rule no longer no-ops against the default bus —
src/provisioning/providers/eventbridge-rule-provider.ts(issue #955, found by the PR #949 code review's family audit). Bug:delete()never passedEventBusNametoListTargetsByRule/RemoveTargets/DeleteRule— the Name-only form targets the DEFAULT bus, so a diff-driven deletion of just the rule (bus kept in the template) reportedResourceNotFound, logged "does not exist, skipping deletion", and SILENTLY ORPHANED the rule on AWS. Masked until now because whole-stack destroy also deletes the bus, whose provider sweeps its remaining rules. Live-reproduced with the released 0.230.21 binary: the rule-drop update deployed "successfully" whilelist-rules --event-bus-namestill showed the rule attached. A second defect inextractBusNameFromArnassumed exactlyrule/<bus>/<rule>(3 slash-parts) and returneddefaultfor partner-bus ARNs (rule/aws.partner/foo.com/123/<rule>, 5 parts) — wrong bus for drift readback too. Fix: the bus name is derived from the stored rule ARN as everything between the first and last slash (rule names cannot contain/; same last-slash rule ascfnRefValueFromPhysicalId), anddelete()+getAttribute()now threadEventBusNamethrough every call (omitted for default-bus rules, preserving the previous wire shape there). 4 new unit tests (custom-bus delete passesEventBusNameon all three calls / partner-bus name extraction / default-bus omission pin /getAttributecustom-bus addressing — 3 of 4 fail without the fix). Theeventbridgeinteg fixture gained a Phase 1.5 UPDATE that drops the rule while KEEPING the bus and assertslist-rules --event-bus-namereturns 0 rules (the exact orphan the bug produced) with the bus still present.covers: AWS::Events::Rule.✅ Fix: a failed async Cloud Control CREATE no longer strands a name-holding remnant that kills every retry with
AlreadyExists—src/provisioning/cloud-control-provider.ts. Bug (found by/hunt-bugssweep 10, 2026-07-02): some CC create handlers materialize the resource FIRST and stabilize it afterwards —AWS::Synthetics::Canarycreates the canary entity, then builds its backing Lambda; cdkd's fast path creates the execution role only ~1s earlier, so the IAM-propagation race routinely fails stabilization (The role defined for the function cannot be assumed by Lambda.) and the canary lands in ERROR state still occupying its name. The FAILED progress event's message matched the transient-retry patterns (correctly), but the deploy engine's outerwithRetryre-issuedCreateResourceagainst the occupied name, so every retry died withAlreadyExistsinstead of recovering — and the ERROR remnant was ALSO invisible to rollback (the create never returned, so it was never in state), leaving an orphan CloudFormation would have deleted on rollback. Live repro pinned both attempts viacloudcontrol list-resource-requests: attempt 1 FAILEDGeneralServiceExceptionwithIdentifier: cdkd-bh-syn, attempt 2 FAILEDAlreadyExists. Fix:waitForOperation's FAILED branch now throws the newCloudControlOperationFailedError(aProvisioningErrorsubclass carrying the progress event'sErrorCode+ the operation kind), andcreate()runscleanupFailedCreateRemnantbefore rethrowing: when a CREATE failed with anIdentifierandErrorCode !== 'AlreadyExists', the materialized remnant is best-effort deleted (reusing the provider's own NotFound-idempotentdelete()), so the outer retry starts with a free name and a final failure leaves nothing behind. Safety: theAlreadyExistsguard is load-bearing — anAlreadyExistsFAILED event ALSO carries the identifier, but it names a resource that pre-dates the create (deleting it would destroy a user's pre-existing resource); and handlers may stuff a SPECULATIVE identifier into a FAILED event without materializing anything (observed onAWS::CodeDeploy::DeploymentGroup) — the NotFound-idempotent delete absorbs that as a no-op. UPDATE/DELETE failures never trigger cleanup (ccOperationgate). 7 new unit tests (remnant deleted + original retry-classifiable error rethrown; AlreadyExists NEVER deletes; no-Identifier no-op; NotFound-on-cleanup silent; cleanup-failure warns but rethrows; FAILED UPDATE untouched — 4 of 7 fail without the fix). New real-AWSsynthetics-canaryinteg fixture (deploy → assert canary READY → in-place schedule UPDATE → destroy → cwsyn-* backing-Lambda / log-group / bucket orphan sweep). Verified live: the race fired on the fixed binary's deploy, the remnant was cleaned, and the retry re-created the canary successfully ~14s later (READY, clean UPDATE + destroy, 0 orphans).covers: AWS::Synthetics::Canary.✅ Fix:
RefonAWS::Events::Rule/AWS::CloudTrail::Trailreturns the resource name, not the ARN —src/deployment/intrinsic-function-resolver.ts,src/analyzer/orphan-rewriter.ts. Bug (found by/hunt-bugs, 2026-07-02): both SDK providers store the resource ARN as the physicalId (their delete / update paths need it), andresolveRefValuereturned the physicalId verbatim — but CloudFormation'sReffor these types returns the resource name (AWS::Events::Rule→ the rule name, or<busName>|<ruleName>for a custom-bus rule — thebus|nameform verified against real CloudFormation;AWS::CloudTrail::Trail→ the trail name, AWS docs explicit for both). Live repro: aCfnOutputofrule.ruleName(synthesizes to{Ref: <Rule>}) printed the full rule ARN. Any consumer composing the name into another string or callingevents:*/cloudtrail:*APIs by name got the ARN instead. Fix (three parts): (1) newREF_RETURNS_NAME_FROM_ARNmap in the resolver — the ARN-stored sibling ofREF_RETURNS_SEGMENT_AFTER_PIPE— extracts the CFnRefvalue from the stored ARN (custom-bus rule ARNsrule/<bus>/<name>map to CFn's<bus>|<name>physical id); (2) the constructed-attribute fallback for both types assumed a bare-name physicalId and would have produced a corrupted double ARN forFn::GetAtt Arn— it now returns an ARN-shaped physicalId verbatim; (3) the wholeRef-value derivation is extracted into the exported purecfnRefValueFromPhysicalId(resourceType, physicalId)and thecdkd orphanrewriter's{Ref: <orphan>}substitution now uses it too — previously the rewriter substituted the RAW physicalId, silently baking the ARN (or a Cognito-style<parent>|<child>compound id) into surviving siblings' state. Family audit (per the ARN-stored-physicalId class): every other provider storing an ARN physicalId (ACM::Certificate,ELBv2::*,IAM::ManagedPolicy,SNS::Topic/Subscription,SecretsManager::Secret,StepFunctions::StateMachine,Lambda::LayerVersion,Kinesis::StreamConsumer,ECS::Service/TaskDefinition,S3Tables,RDS::DBProxyTargetGroup) is correct — CFn'sReffor those types IS the ARN;ECS::Clusterstores the name (also correct). 7 new resolver unit tests (default-bus name, custom-busbus|name, trail name, double-ARN guards ×2 — all failing pre-fix) + 2 orphan-rewriter tests (Events::Rule + Cognito compound). Theeventbridgeinteg fixture now storesrule.ruleName(→{Ref}) in an SSM parameter and verify.sh asserts the value equals<busName>|<actual rule name>fromlist-rules. Verified live: pre-fix output printed the ARN, fixed binary prints the rule name; thebus|namecustom-bus form pinned by deploying a real CloudFormation stack and reading itsRefoutputs.covers: AWS::Events::Rule, AWS::CloudTrail::Trail.✅ Fix: Step Functions same-stack assume-role IAM-propagation race is now retried —
src/deployment/retryable-errors.ts. Bug (found by/hunt-bugs): a canonical Express state machine withLoggingConfiguration(StateMachine + fresh Role + DefaultPolicy + LogGroup) failed to deploy 100% of the time: cdkd's fast SDK path issuesCreateStateMachine~1s after the role's CREATE, before IAM propagates the trust policy, and AWS rejects it with "Neither the global service principal states.amazonaws.com, nor the regional one is authorized to assume the provided role." — a phrasing no retryable-error pattern matched ('not authorized to perform' is a different sentence; 'is unable to assume provided role' is Glue's wording), so the whole deploy hard-failed and rolled back. CloudFormation tolerates the race via its deployment latency. Fix: add the'authorized to assume the provided role'pattern (same class as the Glue / Firehose / CodeDeploy / RDS ENHANCED_MONITORING phrasings), anchored on the SFN-specific tail so a genuinely broken trust policy only burns the bounded retries before surfacing. 1 new unit test pins the exact wire message as retryable. New real-AWSstepfunctions-logginginteg fixture — first integ coverage for SFNLoggingConfiguration(CREATE under the race, in-place log-level ALL -> ERROR UPDATE, functionalstart-sync-execution, destroy). Verified end-to-end against real AWS: the retry fired 3x (1s/2s/4s) then the create succeeded; 0 orphans.covers: AWS::StepFunctions::StateMachine.✅ Fix: switching a DynamoDB table's
TableClasson redeploy is no longer silently dropped —src/provisioning/providers/dynamodb-table-provider.ts. Bug (found by/hunt-bugs): changing a table'stableClass(STANDARD <-> STANDARD_INFREQUENT_ACCESS, a common cost-optimization redeploy) reportedUpdated: 1success while AWS kept the OLD class —update()had no TableClass branch at all. Because the silently-dropped change was still written to cdkd state, the nextcdkd diffsaw no difference and the switch could never self-heal. CloudFormation applies it in place viaUpdateTable("Update requires: No interruption"). Fix: TableClass now rides the existing BillingMode/ProvisionedThroughputUpdateTablebranch, with the throughput/billing fields gated on their own change detection so a class-only change does not re-assert unchanged throughput (AWS rejects anUpdateTablewhose requested throughput equals the current value); a removed property reverts to the STANDARD default (CFn absent-property semantics). 4 new unit tests (class-only change sends TableClass without throughput; removal reverts to STANDARD; unchanged class issues no UpdateTable; combined class+billing switch rides one UpdateTable). New real-AWSdynamodb-tableclass-switchinteg fixture (fresh auto-named table per run; exactly one switch per run, inside AWS's two-per-30-days-per-table limit). Verified end-to-end against real AWS (the previously-dropped switch now reaches AWS; 0 orphans).covers: AWS::DynamoDB::Table.✅ Fix: switching a Lambda function's architecture (x86_64 <-> arm64) on redeploy is no longer silently dropped —
src/provisioning/providers/lambda-function-provider.ts. Bug (found by/hunt-bugs): changing a function'sarchitecturewith unchanged code reportedUpdated: 1success while AWS kept the OLD architecture.Architecturesrides onUpdateFunctionCode(the Lambda API ties the instruction set to a code deployment), butupdate()only firedUpdateFunctionCodewhen theCodeproperty changed — and never passedArchitecturesat all. Because the silently-dropped change was still written to cdkd state, the nextcdkd diffsaw no difference and the switch could never self-heal. CloudFormation applies it in place ("Update requires: No interruption"). Fix:update()now firesUpdateFunctionCodewhen the architecture changed even if the code is byte-identical, passingArchitectureson an architecture change; a removed property reverts to thex86_64default (CFn absent-property semantics), and unchanged-architecture code updates omit the field so existing behavior is untouched. 3 new unit tests (arch-only switch fires UpdateFunctionCode with Architectures + the same code; code-only change omits Architectures; removal reverts to x86_64). New real-AWSlambda-arch-switchinteg fixture asserts BOTH the function config AND the actual runtime (process.archvia a live invoke) after the switch, and sweeps the invoke-created log group on cleanup. Verified end-to-end against real AWS (config reports arm64 AND a live invoke returnsarm64; 0 orphans).covers: AWS::Lambda::Function.
Recently Implemented (2026-06-29):
✅ Fix: changing a log group's
LogGroupClasson redeploy now fails actionably instead of being silently dropped —src/provisioning/providers/logs-loggroup-provider.ts. Bug (found by/hunt-bugs): switching alogs.LogGroup'slogGroupClass(STANDARD <-> INFREQUENT_ACCESS, a common cost-optimization redeploy) reportedUpdated: 1success while AWS kept the OLD class. CloudFormation documents the property as "Update requires: Updates are not supported" — there is no CloudWatch Logs API to change a log group's class after creation, and a CFn stack update carrying the change FAILS — but cdkd'supdate()ignored the property entirely. Because the silently-dropped change was still written to cdkd state, the nextcdkd diffsaw no difference and it could never self-heal. Fix:update()now throws the typedResourceUpdateNotSupportedErrorBEFORE any other mutation when the class changed, with the remediation spelled out (--replacerecreates the group under the new class; the existing stateful guard additionally requires--force-stateful-recreationsince a log group retains data). An absent property is normalized to the STANDARD default, so an explicit-STANDARD <-> absent transition is NOT treated as a change. 5 new unit tests (class change throws before any mutation; absent==STANDARD normalization both directions; unrelated updates proceed when the class is unchanged; the message carries--replace). New real-AWSloggroup-class-guardinteg fixture: deploys STANDARD, re-deploys as INFREQUENT_ACCESS WITHOUT--replace(asserts the actionable failure AND that AWS is unchanged), re-deploys WITH--replace --force-stateful-recreation(asserts the group is recreated as INFREQUENT_ACCESS), then destroys clean. Verified end-to-end against real AWS (0 orphans).covers: AWS::Logs::LogGroup.✅ Fix: immutable (createOnly) property changes now correctly drive a replacement, and stateful replacements are guarded against silent data loss —
src/analyzer/diff-calculator.ts,src/analyzer/replacement-rules.ts,src/provisioning/create-only-properties.ts(new),src/deployment/deploy-engine.ts,src/provisioning/providers/efs-provider.ts. Bug (found by the immutable-type follow-up probe): cdkd's diff classifier (ReplacementRulesRegistry) only knew the ~25 types with a hand-authored rule, and it consulted ONLY that registry — never the CFn registry schema'screateOnlyProperties. So an immutable-property change on any OTHER type (e.g.AWS::EFS::FileSystem.PerformanceMode) was mis-classified as an in-place UPDATE (cdkd diffshowed "1 to update"); the provider'supdate()then either rejected it with a typed error (best case) or silently dropped it. A SECOND, deeper issue surfaced in the same area: the property-driven replacement path had NO stateful guard — an immutable change on a stateful type (RDS / EFS / Secret / SSM Parameter / Kinesis / S3-with-data / etc.) was DELETE+CREATEd without confirmation, silently destroying the data (the--replaceand--recreate-via-*paths already required--force-stateful-recreation, but the property-driven path did not). Fix (three parts): (1) the diff now resolves each type'screateOnlyPropertiesfrom the CFn registry schema viacloudformation:DescribeType(newgetTopLevelCreateOnlyProperties, cached + graceful-degradation, mirroringwrite-only-properties.ts) and uses it as a replacement fallback for any property the registry does not explicitly classify (ReplacementRulesRegistry.isClassifiedgates the fallback so a deliberateupdateableProperties/ conditional classification is never overridden) — so a createOnly change on ANY type correctly drives a replacement andcdkd diffreports it honestly; (2) the property-driven replacement path in the deploy engine now applies the same stateful guard as--replace(isStatefulRecreateTargetForReplace+--force-stateful-recreation), throwingSTATEFUL_REPLACE_BLOCKEDrather than silently destroying a stateful resource's data; (3)EFSProvider.createderives itsCreationTokenfrom a content hash of the create properties (cdkd-<logicalId>-<hash>) instead of a barecdkd-<logicalId>— a property-driven replacement creates the new FS while the old one still holds the deterministic token, so the bare token collided ("already exists with creation token"); the content hash makes a replacement's new FS use a different token while a retry of the same create stays idempotent. Behavior change: an immutable-property change on a stateful type now requires--force-stateful-recreation(it previously auto-replaced for registered-rule types, losing data without confirmation; thereplacement-immutable-nameinteg's rename phase was updated to pass the flag for its Kinesis / Secret / SSM resources). 12 new unit tests (createOnly resolver caching / degradation; diff fallback incl. explicit-updateable-not-overridden; stateful-replace guard fires for S3 but not for a non-stateful Lambda; EFS content-hash token stable-vs-replacement). New real-AWSefs-immutable-replacementinteg: deploy EFSmaxIO→cdkd diffreports a replacement → deploy WITHOUT the flag is blocked → deploy WITH--force-stateful-recreationperforms the DELETE+CREATE (new FileSystemId) → destroy clean. Verified end-to-end against real AWS (0 orphans), plus the updatedreplacement-immutable-nameand the broadbench-cdk-sampleregression.covers: AWS::EFS::FileSystem.✅ Fix: S3 replication combined
Andfilter (prefix + tags) no longer silently broadens to replicate-all —src/provisioning/providers/s3-bucket-provider.ts. Bug (found by the S3 replication probe in the 2026-06-29 follow-up sweep): CloudFormation / CDK express a combined prefix+tag replication filter ONLY via theFilter.Andoperator (Filter: { And: { Prefix, TagFilters[] } }— CDK's L2 emits this whenever a rule sets bothprefixandtags).applyReplicationConfigurationread only the top-levelFilter.Prefix/Filter.TagFilterand neverFilter.And, so a combined filter fell through to the empty-filter branch and producedFilter: {}— replicating EVERY object instead of the intended prefix+tag subset (a silent scope-broadening divergence; same class as the lifecycle V1/V2 bug). The symmetric readback (readReplication) had the mirror defect: it collapsed an AWSAnd { Prefix, Tags[] }to a non-canonical top-level{ Prefix, TagFilter }and dropped every tag past the first, which would surface as phantom drift against the template'sFilter.And.TagFilters. Fix: both sides now handle the canonicalAndshape — the write path translatesFilter.And { Prefix?, TagFilters[] }→ SDKAnd { Prefix?, Tags[] }, and the readback round-trips AWSAnd { Prefix, Tags[] }→ CFnAnd { Prefix, TagFilters[] }preserving all tags (the top-levelprefix/tagFilterbranches were also tightened from truthy to!== undefinedso an empty-string prefix round-trips instead of dropping to replicate-all). 4 new unit tests (write: combined And prefix+tags →And.Tagswith every tag; And tags-only; singleTagFilter→Tag; readback: AWSAnd→ CFnAnd.TagFilters). New real-AWSs3-replication-and-filterinteg fixture: deploys versioned source+dest buckets + a replication role with a rule usingFilter.And { Prefix: logs/, TagFilters: [replicate=yes] }, assertsGetBucketReplicationreturns the And filter verbatim (NOT replicate-all), re-deploys changing the And prefixlogs/→data/in place (no bucket replacement), then destroys clean. Verified end-to-end against real AWS (0 orphans).covers: AWS::S3::Bucket, AWS::IAM::Role.
Recently Implemented (2026-06-28):
✅ Fix: Cognito
UserPoolUserno longer leaks its Cloud Control compound id throughRef—src/deployment/intrinsic-function-resolver.ts. Bug (found by the compound-id-Reffamily audit — the missed Cognito sibling of the resource-server / group / IdP / domain fix below):AWS::Cognito::UserPoolUserhas no SDK provider, so it routes through Cloud Control, whose primaryIdentifier is the compound<userPoolId>|<username>; cdkd stored that compound as the physicalId. CloudFormation'sReffor the type returns ONLY the trailing<username>(AWS docs are explicit), but the type was missing fromREF_RETURNS_SEGMENT_AFTER_PIPE. Because there is no L2 forUserPoolUserandCfnUserPoolUserexposes noAttr*getter, the natural CDK patternnew CfnUserPoolUserToGroupAttachment(this, 'x', { username: user.ref, ... })fed the resolver{Ref: User}, which leaked<userPoolId>|admin; the attachment'sAdminAddUserToGroupcall then failed withUser does not exist(real-AWS repro confirmed: 3 of 4 resources created,AttachCREATE failed, full rollback). Fix: addAWS::Cognito::UserPoolUser(→ username) toREF_RETURNS_SEGMENT_AFTER_PIPE. 1 new parameterized unit test (Refreturns the bareadmin, not<poolId>|admin). New real-AWScognito-userpool-user-refinteg fixture deploys a UserPool + Group + UserPoolUser + aCfnUserPoolUserToGroupAttachmentwhoseusernameconsumesuser.ref, asserts the user lands in the group (admin-list-groups-for-user— only possible if the Ref resolved to the bare username), then destroys clean. Verified end-to-end: repro FAILED on the pre-fix binary (User does not exist), PASSED on the fixed binary (0 orphans). Same compound-id-Refbug class as the::UserPoolResourceServerfamily +AWS::ApiGateway::Model/::RequestValidator. The audit also cleared ApiGatewayV2::ApiMapping / ApiGateway::DocumentationPart as TRAPS (child-id is FIRST, not last — adding them would create the inverse bug) and found no other LIKELY candidates across the ApiGateway / ApiGatewayV2 / AppSync / IoT / ECS / ServiceCatalog families.✅ DX: actionable error when a DynamoDB
TimeToLiveSpecificationAttributeName changes between two enabled specs —src/provisioning/providers/dynamodb-table-provider.ts. Context (surfaced by/hunt-bugs, confirmed NON-bug — matches CloudFormation): changing a table's TTL attribute (e.g.ttlA→ttlB) in one deploy cannot work — AWS allows TTL on only one attribute and rejects enabling it on a new attribute while TTL is still active on the old one (TimeToLive is active on a different AttributeName), and DynamoDB rate-limitsUpdateTimeToLiveto one change per table per ~1 hour, so disable-then-re-enable in a single deploy is impossible too (CloudFormation hits the same wall and rolls back). Previously cdkd let the opaque raw AWS error bubble up. Change:update()now pre-emptively throws a clearProvisioningErrorBEFORE the doomedUpdateTimeToLivecall when both the old and new specs are enabled with a differentAttributeName, spelling out the two-deploy remediation (deploy once removing/disabling TTL on the old attribute, then after the ~1h disable settles deploy again enabling it on the new attribute). Only this exact case throws — enabling-from-absent, enabling-from-disabled (even on a new attribute), disabling, and same-attributeEnabledtoggles all pass through unchanged. Theupdate()catch now also passes aProvisioningErrorstraight through (mirroringcreate()) so the actionable message is not double-wrapped behind a generic "Failed to update" prefix (also de-double-wraps the pre-existing ResourcePolicy-ARN guard).applyTimeToLive+ the new guard share a singlereadTtlSpecnormalizer so theEnabled-absent-means-true default cannot drift between them. 5 new unit tests (attribute-name change throws + noUpdateTimeToLiveattempted; error not double-wrapped; enable-from-absent / enable-from-disabled / disable all pass through). New real-AWSdynamodb-ttl-attr-changeinteg fixture: deploys a PAY_PER_REQUEST table with TTL onttlA, re-deploys (CDKD_TEST_UPDATE=true) requesting TTL onttlB, asserts the deploy FAILS with the actionable message AND that AWS TTL is still onttlA(the guard fired before anyUpdateTimeToLivecall), then destroys clean. Verified end-to-end against real AWS (0 orphans).✅ Fix: Cognito UserPool-child resources (resource server / group / IdP / domain) no longer leak their Cloud Control compound id through
Ref—src/deployment/intrinsic-function-resolver.ts. Bug (found by/hunt-bugs): a standardcognito.UserPool+ResourceServer+ aUserPoolClientrequesting the resource-server scope (OAuthScope.resourceServer(rs, scope)) failed at client CREATE withInvalid scope requested: us-east-1_xxx|api/read.AWS::Cognito::UserPoolResourceServerhas no SDK provider, so it routes through Cloud Control, whose primaryIdentifier is the compound<userPoolId>|<identifier>; cdkd stores that compound as the physicalId. CloudFormation'sReffor the type returns ONLY the trailing<identifier>segment (per the AWS docs), but the type was missing fromREF_RETURNS_SEGMENT_AFTER_PIPE, so cdkd's resolver returned the whole compound. The CDK synth shapeAllowedOAuthScopes: [{Fn::Join: ["", [{Ref: ResourceServer}, "/read"]]}]then produced<userPoolId>|api/readinstead ofapi/read, which Cognito rejects. Fix: add the whole Cognito UserPool-child family whose CC primaryIdentifier is<userPoolId>|<child>and whose CFnRefreturns the trailing<child>—AWS::Cognito::UserPoolResourceServer(→ identifier),::UserPoolGroup(→ group name),::UserPoolIdentityProvider(→ provider name),::UserPoolDomain(→ domain) — toREF_RETURNS_SEGMENT_AFTER_PIPE(joining the already-present::UserPoolClient). The two attachment types (::UserPoolRiskConfigurationAttachment/::UserPoolUICustomizationAttachment) are intentionally NOT added — theirRefreturns a synthetic<TypeName>-<UserPoolId>-<ClientId>string, not the after-pipe segment, and neither is referenced in practice. 5 new unit tests (a parameterized case per added type assertingRefreturns the bare trailing segment, plus the end-to-endFn::Joinscope case{Ref: ResourceServer}/read→api/read). New real-AWScognito-resource-serverinteg fixture deploys a UserPool + ResourceServer + a client whoseAllowedOAuthScopesreferences the resource-server scope, plus a UserPoolGroup and a UserPoolDomain, asserts the client'sAllowedOAuthScopesreaches AWS as exactly["api/read"](a compound there would mean the Ref leaked the CC id), then destroys clean. Verified end-to-end against real AWS (the previously-undeployable client now deploys;AllowedOAuthScopes == ["api/read"]; 0 orphans). Same compound-id-Refbug class as theAWS::ApiGateway::Model/::RequestValidatorfixes.
Recently Implemented (2026-06-22):
✅ Follow-up: correct the Cognito immutable-Schema error message + two hardenings —
src/provisioning/providers/cognito-provider.ts. Follow-up to the add-custom-attribute fix below. (1) Accuracy: theResourceUpdateNotSupportedErrorthrown when an existing Schema attribute is removed / modified previously suggestedcdkd deploy --replacealone — butAWS::Cognito::UserPoolIS in cdkd'sSTATEFUL_TYPESguard, so a bare--replaceis refused withSTATEFUL_REPLACE_BLOCKED. The message now correctly sayscdkd deploy --replace --force-stateful-recreation. (2) Hardening (reviewer nit): a Schema entry with noNamewas silently skipped by the diff (neither added nor rejected); it now throws a clearProvisioningError(CDK synth always emitsName; this only fires on a malformed hand-written L1 template). (3) Hardening (reviewer nit): a comment onSTANDARD_USER_POOL_ATTRIBUTESdocuments that the OIDC-claim snapshot may lag AWS and that a future standard attribute would fail LOUDLY at AWS (never a silent drop). Theupdate()catch now also passes aProvisioningErrorstraight through (mirroringcreate()) so the malformed-Schema guard is not double-wrapped. 2 new unit tests (the rejection message names--force-stateful-recreation; a no-Name Schema entry throws).✅ Fix: adding a custom attribute to a Cognito User Pool on redeploy is no longer silently dropped —
src/provisioning/providers/cognito-provider.ts. Bug (found by/hunt-bugs): adding a custom attribute (e.g.customAttributes: { region: new cognito.StringAttribute() }) to an existingcognito.UserPooland re-deploying reportedUpdated: 1success while AWS kept the OLD schema — the new attribute never appeared.UpdateUserPooldoes not acceptSchema, andcognito-provider.update()ignored the property entirely (itspreviousPropertiesarg was unused), so the change reached neither AWS nor any error. AWS does support adding a custom attribute in place via the separateAddCustomAttributesAPI (CloudFormation uses it; adding a Schema attribute is "Update requires: No interruption"). Because the silent-dropped change was still written to cdkd state, the nextcdkd diffsaw no difference and the add could never self-heal. Fix: afterUpdateUserPool,update()now diffs the new vs previousSchemaby attribute name and callsAddCustomAttributesfor every NEWLY-added custom attribute. Removing or modifying an existing attribute, or adding a standard (OIDC) attribute, is not an in-place operation — those are rejected withResourceUpdateNotSupportedError(pointing atcdkd deploy --replace --force-stateful-recreation, which recreates the pool and deletes all users —UserPoolis a stateful-recreate-guarded type) rather than silently dropped; the typed error is re-thrown past the provider's genericProvisioningErrorwrapper so the deploy engine's--replacefallback can catch it. A byte-identical Schema is a no-op (noAddCustomAttributescall, so a no-drift redeploy never errors with "attribute already exists"). Custom vs standard attributes are told apart by aSTANDARD_USER_POOL_ATTRIBUTESset (the OIDC claim names);readCurrentStatealready emitsSchema, socdkd driftwas already correct. 4 new unit tests (add a new custom attribute -> AddCustomAttributes with only the added one; unchanged Schema -> no AddCustomAttributes; removing an existing attribute -> ResourceUpdateNotSupportedError; modifying an existing attribute -> ResourceUpdateNotSupportedError). New real-AWScognito-custom-attribute-addinteg fixture deploys a pool withtenantId+level(asserts AWS reports exactly 2 custom attributes), re-deploys addingregion(asserts AWS now reports all 3 — the add reached AWS, not just cdkd state), then destroys clean. Verified end-to-end against real AWS (the previously-dropped attribute add now reaches AWS; 0 orphans).✅ Fix: switching a Kinesis stream's capacity mode (PROVISIONED <-> ON_DEMAND) on redeploy is no longer silently dropped —
src/provisioning/providers/kinesis-provider.ts. Bug (found by/hunt-bugs): changing akinesis.Stream'sstreamMode(e.g. PROVISIONED -> ON_DEMAND, a common cost-optimization redeploy) and re-deploying reportedUpdated: 1success while AWS kept the OLD mode.kinesis-provider.update()had noUpdateStreamModecall — it only reconciledShardCount(and only when the NEW mode was PROVISIONED), so aStreamModeDetails.StreamModechange reached neither AWS nor any error. Worse, the change WAS written to cdkd state (properties.StreamModeDetails), so the nextcdkd diffsaw no difference (state == template) and the switch could never self-heal;cdkd driftwas also blind to it (itsobservedPropertiesbaseline captured the real AWS mode, which matched the AWS-current read). In CloudFormationStreamModeDetailsis "Update requires: No interruption", applied viaUpdateStreamMode. Fix:update()now switches the mode FIRST viaUpdateStreamMode(resolving the requiredStreamARNviaDescribeStreamSummary) whenoldMode !== newMode, then waits for ACTIVE. Ordering matters: PROVISIONED -> ON_DEMAND switches mode and skips the (invalid-on-on-demand)UpdateShardCountpath; ON_DEMAND -> PROVISIONED switches mode first, then reconciles the shard count against the LIVE open-shard count (DescribeStreamSummary.OpenShardCount) rather than the absent previous-properties value (AWS assigns its own shard count on the switch, so the previous-properties default of 1 would have skipped the reconcile and left the wrong count). 4 new unit tests (PROVISIONED -> ON_DEMAND calls UpdateStreamMode + skips UpdateShardCount; ON_DEMAND -> PROVISIONED calls UpdateStreamMode + reconciles shards from the live count; no UpdateStreamMode when the mode is unchanged; the existing shard-count update path unchanged). New real-AWSkinesis-stream-mode-switchinteg fixture deploys a PROVISIONED (1-shard) stream (asserts AWS reports PROVISIONED), re-deploys as ON_DEMAND (asserts AWS now reports ON_DEMAND — the switch reached AWS, not just cdkd state), then destroys clean (the assertion tolerates Kinesis's asyncDeleteStreamDELETING window). Verified end-to-end against real AWS (the previously-dropped mode switch now reaches AWS; 0 orphans).✅ Fix:
cdkd driftno longer reports phantom drift on an IAM Role right after deploy (siblingDefault Policyrace) —src/deployment/deploy-engine.ts. Bug (deferred from/hunt-bugs): runningcdkd driftimmediately aftercdkd deploycould report a false positive on anAWS::IAM::Role—- Policies:[{...DefaultPolicy...}] / + Policies:[]— for essentially every Lambda / L2 construct whose grant emits aDefault Policy, one of the most common CDK patterns. CDK emits a construct's grants as a SEPARATEAWS::IAM::Policyresource attached to the role viaRoles: [role], which AWS implements viaiam:PutRolePolicy, so the inline policy appears inListRolePolicies. Thecdkd driftAWS-current read already filters these sibling-managed inline policies (issue #323'scollectInlinePolicyNamesManagedBySiblings, fed bybuildReadCurrentStateContextfrom full state). But the deploy-timeobservedPropertiescapture passed no sibling context, so the same filter no-op'd — and the role'sListRolePoliciescapture RACED the sibling policy'sPutRolePolicy. When the read landed after the write, the sibling-managedDefaultPolicy*leaked intoobservedProperties.Policies; a later drift (correctly filtering it from the AWS-current side) then surfaced the baseline-vs-current mismatch as phantom drift. Timing-dependent, so it surfaced intermittently (it bit while adding acdkd driftassertion to thelambda-event-invoke-config-updatefixture). Fix: the deploy-time capture for an IAM principal (AWS::IAM::Role/::User/::Group) now builds a sibling context from the template (buildObservedCaptureSiblings) — deploy-order-independent, immune to the race — and passes it throughkickOffObservedCaptureat both the post-CREATE and post-UPDATE sites. EachAWS::IAM::PolicywhoseRoles/Users/Groupsreferences the captured principal (via{Ref: <logicalId>}or the literal physical name) is synthesized into the resolved-property shapecollectInlinePolicyNamesManagedBySiblingsconsumes, so the SAME sibling-policy filter that runs at drift time now runs at capture time. A role's declared inlinePolicies(non-sibling) are kept; only sibling-managed names are excluded. Non-IAM-principal captures are unaffected (the helper returns no context, leaving the pre-fix behavior). 4 new deploy-engine unit tests (Role + siblingDefault Policy→ capture context carries the resolved sibling; Role with no sibling → no context;Usersattachment field for anAWS::IAM::User; non-principal S3 bucket → no context). New real-AWSiam-role-policies-drift-cleaninteg fixture deploys a Lambda-with-grant (service-roleDefault Policysibling) plus a standalone role carrying BOTH a declared inline policy AND anaddToPolicy()Default Policysibling, runscdkd drifttwice and asserts NO drift on anyAWS::IAM::Role, then destroys clean. Verified end-to-end against real AWS (no phantom drift on either role; destroy 6/0 errors, 0 orphans), plus the broadlambdainteg for the cross-cuttingdeploy-engine.tschange.✅ Fix: EventBridge
ApiDestination(the daily webhook pattern) is no longer undeployable —AWS::Events::ConnectionArn GetAtt enrichment gap —src/provisioning/cloud-control-provider.ts. Bug (found by/hunt-bugs): a standardevents.Connection+events.ApiDestinationpair failed at ApiDestination CREATE withModel validation failed (#/ConnectionArn: failed validation constraint for keyword [pattern]).AWS::Events::Connectionis CC-API-provisioned (no SDK provider) and its primaryIdentifier isName, so the cdkd physicalId is the connection NAME, not the ARN. Its readOnlyArnattribute was NOT inenrichResourceAttributes, soAWS::Events::ApiDestination'sConnectionArn—Fn::GetAtt(Connection, 'Arn'), the canonical CDK shape — fell through the resolver'sconstructAttributeto the physicalId (the bare name) and AWS rejected the invalid ConnectionArn. The full connection ARN carries a random unique suffix (.../connection/<name>/<uuid>) so it cannot be string-constructed from account + region + name. Same systemic enrichment-gap bug class as #844 / #864 / #865 / #866. Fix: addAWS::Events::Connection(Arn/SecretArnviaDescribeConnection;ArnForPolicyderived by stripping the ARN's trailing unique segment) andAWS::Events::ApiDestination(ArnviaDescribeApiDestination;ArnForPolicyderived likewise) cases toenrichResourceAttributes, best-effort (a failed Describe leaves the CC attribute shape unchanged and never fails the deploy). 6 new unit tests (Connection Arn/SecretArn/ArnForPolicy overlay + the DescribeConnectionNameinput + already-present-attrs short-circuit + per-field-independence partial overlay + best-effort failure; ApiDestination Arn/ArnForPolicy overlay + best-effort failure). New real-AWSeventbridge-api-destinationinteg fixture deploys Connection + ApiDestination + a Rule whose target is the ApiDestination, asserts the resolvedConnectionArnAND the Rule's target Arn reaching AWS are real ARNs (not bare names), then destroys clean. Both types are pure-CC (no SDK provider, no cached CFn schema fixture per the established pattern that keeps fixtures 1:1 with registered SDK providers), so they joindocs/_generated/enrichment-coverage.json'senrichedWithoutCachedSchemalist alongside the other enriched pure-CC types (ElastiCache::ReplicationGroup / Redshift::Cluster / OpenSearchService::Domain). Verified end-to-end against real AWS (the previously-undeployable ApiDestination now deploys; 0 orphans).✅ Fix: updating an async Lambda's EventInvokeConfig (maxEventAge / retryAttempts) no longer hard-fails — new
src/provisioning/providers/lambda-event-invoke-config-provider.ts+ registered insrc/provisioning/register-providers.ts+src/analyzer/replacement-rules.ts. Bug (found by/hunt-bugs): an async Lambda configured with anonFailuredestination plusmaxEventAge/retryAttemptssynthesizes anAWS::Lambda::EventInvokeConfig. That type had no SDK provider, so it routed through Cloud Control. Cloud Control's UPDATE applies a JSON-patch read-modify-write, and Lambda's EventInvokeConfig read handler returns an AWS-injected emptyDestinationConfig.OnSuccess: {}even when onlyOnFailurewas configured — so every UPDATE that changedmaxEventAgeorretryAttemptshard-failed model validation withModel validation failed (#/DestinationConfig/OnSuccess: required key [Destination] not found)and rolled back. CREATE worked (the template carried noOnSuccess), so the break only surfaced on the very common second deploy that tweaks an async Lambda's retry / age settings. Fix: a dedicated SDK provider whosecreate()andupdate()both callPutFunctionEventInvokeConfig— a synchronous full-replace write, exactly what CloudFormation uses for this type — sending only the configuredOnFailureand never an emptyOnSuccess, sidestepping the CC read-modify-write merge entirely. The physical id keeps the Cloud Control primaryIdentifier shape<FunctionName>|<Qualifier>so import / migration stay consistent;delete()parses it back toDeleteFunctionEventInvokeConfig(ResourceNotFound = idempotent success with region check);readCurrentState()surfaces the mutable props forcdkd drift, dropping the AWS-injected emptyOnSuccessand always emittingQualifier(CDK synthesizes$LATESTinto state, so omitting it would phantom-drift a base async Lambda);update()is a logical no-op when nothing changed (drift--revertround-trip).replacement-rules.tsadds a rule marking the two CREATE-ONLY props (FunctionName/Qualifier) as replacement-triggers while keepingMaximumEventAgeInSeconds/MaximumRetryAttempts/DestinationConfigin-place-updateable. 20 new unit tests (create incl. OnSuccess destination + numeric coercion / the full-replace update regression / no-op update / delete id-parse + region-match + region-mismatch idempotency / readCurrentState OnSuccess-drop + Qualifier-always-emitted / import + the replacement-rule classification). New real-AWSlambda-event-invoke-config-updateinteg fixture deploys (maxEventAge 2 min / retryAttempts 1 / onFailure DLQ), asserts the EventInvokeConfig is drift-clean, redeploys with (5 min / 2) and asserts the UPDATE — undeployable pre-fix — reaches AWS, then destroys clean. Note: like every backfilled SDK provider, thecc-api→sdkrouting is sticky per the existingprovisionedByrule, so an EventInvokeConfig already created by a pre-fix cdkd binary stays on the (broken) CC update path; a fresh create (or destroy + redeploy) picks up the SDK provider. Verified end-to-end against real AWS (fresh deploy → the previously-failing update now succeeds with MaxAge 300 / Retries 2 on AWS; 0 orphans).✅ Fix: ECR
imageScanOnPush(and KMSKmsKey) silently dropped — CFn PascalCase not mapped to SDK camelCase —src/provisioning/providers/ecr-provider.ts. Bug (found by/hunt-bugs): an ECR repository created withimageScanOnPush: truedeployed "successfully" but AWS reportedscanOnPush: false. The ECR CFn properties are PascalCase (ImageScanningConfiguration: { ScanOnPush: true },EncryptionConfiguration: { EncryptionType, KmsKey }) but the AWS SDK input is camelCase ({ scanOnPush },{ encryptionType, kmsKey }).ECRProvider.create()forwarded the CFn-cased object toCreateRepositoryverbatim (castas ImageScanningConfiguration— compile-only, a runtime lie), so the SDK ignored the unknownScanOnPushkey and silently resetscanOnPushto false. The same casing trap silently dropped a KMS repo'sKmsKey(masked in most tests because AES256 is the SDK default). Theupdate()path'sPutImageScanningConfigurationhad the identical bug. Scan-on-push is a common security default, so cdkd silently shipped repos without image scanning. Fix: map CFn PascalCase → SDK camelCase viatoSdkScanningConfig({ScanOnPush}→{scanOnPush}) andtoSdkEncryptionConfig({EncryptionType, KmsKey}→{encryptionType, kmsKey}, kmsKey only onEncryptionType: KMS) in bothcreate()andupdate(). 4 new unit tests assert the create + update inputs carry the camelCase keys (and never forward the PascalCase key). New real-AWSecr-scanninginteg fixture deploys withimageScanOnPush: true(asserts AWS reportsscanOnPush=true), re-deploys with it false (asserts the update reaches AWS), then destroys clean. Verified end-to-end against real AWS (0 orphans). The existingecrfixture didn't catch it — it never set or assertedscanOnPush.✅ Feature:
cdkd deploy --replace(implements the flag ~20 error messages + docs already referenced) + Fix: Glue SecurityConfiguration silently dropped its S3 encryption —src/cli/options.ts+src/cli/commands/deploy.ts+src/deployment/deploy-engine.ts+src/provisioning/providers/glue-provider.ts. Background: a follow-up to the LayerVersion fix below found thatcdkd deploy --replacewas referenced as the escape hatch by ~20 provider error messages (Glue / EFS / ECS / ApiGatewayV2 / Kinesis / EC2 / Firehose / DynamoDB GlobalTable immutable-update rejections),src/utils/error-handler.ts,src/cli/commands/drift.ts, anddocs/cli-reference.md— but the flag did not exist (cdkd deploy --replace→ "unknown option"), so users hitting any immutable-property change on a type without a built-in replacement rule were stuck. Feature:--replaceis now a real deploy flag. The deploy engine's normal-update catch block already falls back to DELETE + CREATE for the Cloud ControlUnsupportedActionException/ "does not support UPDATE" signals (unconditional, pre-existing);--replaceextends that fallback to the typedResourceUpdateNotSupportedErroran SDK provider throws when an immutable property changed and AWS exposes no in-place update API. Without the flag the typed rejection propagates (the deploy fails, as before); with it the resource is replaced. Stateful guard: the replacement is a data-losing DELETE + CREATE, so a stateful target (RDS / DynamoDB / EFS / S3-with-data / Logs-with-retention / etc. — the sameisStatefulRecreateTargetSyncguard list--recreate-via-cc-apiuses) requires--force-stateful-recreationto be ALSO set; the guard is checked at the moment the rejection is caught (mid-deploy) and the error names the resource + the data-loss reason. Non-stateful immutable types (LayerVersion / Glue SecurityConfiguration / ECS TaskDefinition / ApiGatewayV2 sub-resources) replace with--replacealone. Threaded asDeployEngineOptions.replace+forceStatefulRecreation. 4 new deploy-engine unit tests (non-stateful replace = update→delete→create order; no-flag = the rejection propagates; stateful blocked without the force flag; stateful replaced with it). The integ fixture surfaced a second, latent bug it then also fixes:tests/integration/glue-securityconfig-replace(the--replacereal-AWS probe — an immutableAWS::Glue::SecurityConfigurationEncryptionConfiguration change) deployed an EMPTY EncryptionConfiguration on Phase 1 becauseglue-provider.ts'sbuildEncryptionConfigurationread the CFn propertyS3Encryption(singular) while CDK / CloudFormation emitS3Encryptions(plural —CloudWatchEncryption/JobBookmarksEncryptionARE singular in both CFn and the SDK, but the CFnS3Encryptionslist maps to the SDKS3Encryptionfield). So S3 encryption was silently dropped on every Glue SecurityConfiguration create; the existing unit test hid it by feeding the same wrong singular key the provider read (the classic "unit test used the wrong shape, only a real synth emits the right one" trap). Fix: readS3Encryptions(plural) on create, emitS3Encryptionson thereadCurrentStatereadback, fix the misleading "CFn names match the SDK verbatim" comment, and correct the unit test to the real plural CFn shape (it now fails against the old code, passes against the fix). Theglue-securityconfig-replaceverify.shruns all four phases: deploy SSE-S3 (asserts the mode actually reached AWS — the Glue fix), re-deploy the immutable DISABLED change WITHOUT--replace(asserts the deploy FAILS and the live config is unchanged — the flag is load-bearing), re-deploy WITH--replace(asserts the live config is now DISABLED — replacement applied), destroy clean. Verified end-to-end against real AWS (0 orphans), plus the broadlambdainteg for the cross-cuttingdeploy-engine.ts/deploy.tschange. The ~20 pre-existingcdkd deploy --replacereferences are now all accurate;docs/cli-reference.mdgains a## --replace (deploy)section. —src/cli/options.ts+src/cli/commands/deploy.ts+src/deployment/deploy-engine.ts+src/provisioning/providers/glue-provider.ts. Background: a follow-up to the LayerVersion fix below found thatcdkd deploy --replacewas referenced as the escape hatch by ~20 provider error messages (Glue / EFS / ECS / ApiGatewayV2 / Kinesis / EC2 / Firehose / DynamoDB GlobalTable immutable-update rejections),src/utils/error-handler.ts,src/cli/commands/drift.ts, anddocs/cli-reference.md— but the flag did not exist (cdkd deploy --replace→ "unknown option"), so users hitting any immutable-property change on a type without a built-in replacement rule were stuck. Feature:--replaceis now a real deploy flag. The deploy engine's normal-update catch block already falls back to DELETE + CREATE for the Cloud ControlUnsupportedActionException/ "does not support UPDATE" signals (unconditional, pre-existing);--replaceextends that fallback to the typedResourceUpdateNotSupportedErroran SDK provider throws when an immutable property changed and AWS exposes no in-place update API. Without the flag the typed rejection propagates (the deploy fails, as before); with it the resource is replaced. Stateful guard: the replacement is a data-losing DELETE + CREATE, so a stateful target (RDS / DynamoDB / EFS / S3-with-data / Logs-with-retention / etc. — the sameisStatefulRecreateTargetSyncguard list--recreate-via-cc-apiuses) requires--force-stateful-recreationto be ALSO set; the guard is checked at the moment the rejection is caught (mid-deploy) and the error names the resource + the data-loss reason. Non-stateful immutable types (LayerVersion / Glue SecurityConfiguration / ECS TaskDefinition / ApiGatewayV2 sub-resources) replace with--replacealone. Threaded asDeployEngineOptions.replace+forceStatefulRecreation. 4 new deploy-engine unit tests (non-stateful replace = update→delete→create order; no-flag = the rejection propagates; stateful blocked without the force flag; stateful replaced with it). The integ fixture surfaced a second, latent bug it then also fixes:tests/integration/glue-securityconfig-replace(the--replacereal-AWS probe — an immutableAWS::Glue::SecurityConfigurationEncryptionConfiguration change) deployed an EMPTY EncryptionConfiguration on Phase 1 becauseglue-provider.ts'sbuildEncryptionConfigurationread the CFn propertyS3Encryption(singular) while CDK / CloudFormation emitS3Encryptions(plural —CloudWatchEncryption/JobBookmarksEncryptionARE singular in both CFn and the SDK, but the CFnS3Encryptionslist maps to the SDKS3Encryptionfield). So S3 encryption was silently dropped on every Glue SecurityConfiguration create; the existing unit test hid it by feeding the same wrong singular key the provider read (the classic "unit test used the wrong shape, only a real synth emits the right one" trap). Fix: readS3Encryptions(plural) on create, emitS3Encryptionson thereadCurrentStatereadback, fix the misleading "CFn names match the SDK verbatim" comment, and correct the unit test to the real plural CFn shape (it now fails against the old code, passes against the fix). Theglue-securityconfig-replaceverify.shruns all four phases: deploy SSE-S3 (asserts the mode actually reached AWS — the Glue fix), re-deploy the immutable DISABLED change WITHOUT--replace(asserts the deploy FAILS and the live config is unchanged — the flag is load-bearing), re-deploy WITH--replace(asserts the live config is now DISABLED — replacement applied), destroy clean. Verified end-to-end against real AWS (0 orphans), plus the broadlambdainteg for the cross-cuttingdeploy-engine.ts/deploy.tschange. The ~20 pre-existingcdkd deploy --replacereferences are now all accurate;docs/cli-reference.mdgains a## --replace (deploy)section.✅ Fix: changing a Lambda LayerVersion's content is now a transparent replacement (was an undeployable error) —
src/analyzer/replacement-rules.ts+src/provisioning/providers/lambda-layer-provider.ts. Bug: editing a Lambda layer's content (e.g. bumping the code in alambda.LayerVersion) and redeploying the SAME logical id was misclassified as an in-place UPDATE.AWS::Lambda::LayerVersionis fully immutable on AWS — there is noUpdateLayerVersionAPI; every change requires a freshPublishLayerVersion— so the provider'supdate()hard-failed with an immutable-resource error that pointed users at acdkd deploy --replaceflag that does not exist, leaving a layer-content change completely undeployable. In CloudFormation everyAWS::Lambda::LayerVersionproperty is "Update requires: Replacement", socdk deploytransparently publishes a new version and re-points the consuming function — cdkd diverged from this on a daily-use path (any shared layer whose content changes between deploys). The misclassification stemmed fromReplacementRulesRegistryhaving no rule forAWS::Lambda::LayerVersion, sorequiresReplacementdefaulted to "updateable". Fix:replacement-rules.tsadds a replacement rule marking everyAWS::Lambda::LayerVersionproperty (Content/LayerName/Description/CompatibleRuntimes/CompatibleArchitectures/LicenseInfo) as a replacement-trigger, plus a sibling rule for the equally-immutableAWS::Lambda::Version. A content change now drives a DELETE + CREATE, and the existingpromoteReplacementDependents(issue #807) cascade re-points the consuming function at the new layer version ARN — matchingcdk deployexactly, with no manual flag. The provider'supdate()becomes a defensive fallback whose error message no longer references the non-existent--replaceflag. New unit tests cover the LayerVersion / Version replacement-rule classification and a regression guard that the provider message never reintroduces--replace. New real-AWSlambda-layer-version-updateinteg fixture deploys a layer (contentv1) + a consuming function, redeploys withv2content, and asserts a new layer version:2is published AND the function follows to:2(replacement propagated) before destroying clean. Verified end-to-end against real AWS (the content change auto-replaces; the function is re-pointed; 0 orphans). Note: the non-existentcdkd deploy --replaceflag this entry called out (referenced by ~20 provider messages +docs/cli-reference.mdas the escape hatch for other immutable-update /cdkd drift --revertcases) was implemented as a real flag in the follow-up entry above — those references are now all accurate.
Recently Implemented (2026-06-21):
✅ Fix:
cdkd deploy <consumer>no longer drags a weakly-referenced (Fn::GetStackOutput) producer into the deploy —src/analyzer/cross-stack-deps.ts+src/cli/commands/deploy.ts. Bug (regression from #751): #751 added cross-stack ordering inference (inferCrossStackStackDeps) and wired it into BOTH of deploy.ts's--allsites — the auto-include-dependency CLOSURE walk (which EXPANDS the deploy set) and the inter-stack DAG ORDERING edges. But it treatedFn::ImportValue(a strong reference — the export must exist, so the producer is a genuine deploy dependency) andFn::GetStackOutput(cdkd's weak reference — it reads the producer's output opportunistically from cdkd state) identically. Socdkd deploy <consumer>auto-included and silently RE-DEPLOYED any producer the consumer merely read viaFn::GetStackOutput, violating the documented weak-reference contract (see docs/cross-stack-references.md: GetStackOutput producer must be "deletable independently of consumers"). The visible symptom: a consumer-only redeploy mutated the producer'sstate.json(e.g. transparently upgrading its schema version), and the producer's resources were needlessly re-driven. Caught by theschema-v7-to-v8-migrationinteg (FAIL: producer state.version drifted ... during consumer-only Phase 3 deploy) during the 2026-06-21 staleness sweep. Fix:inferCrossStackStackDeps(stacks, opts?)gains anopts.kindsfilter (CrossStackRefKind = 'ImportValue' | 'GetStackOutput', default both). deploy.ts now computes TWO maps: a STRONG-only map (['ImportValue']) drives the closure walk (effectiveStrongStackDeps→addDependencies), so a weak GetStackOutput edge never expands the deploy set; the full (strong + weak) map still drives the DAG ORDERING edges (effectiveStackDeps), so when a producer IS already in the deploy set (--all, or explicitly named) it is still correctly ordered before its GetStackOutput consumer. CDK manifestaddDependencydeps remain in the closure (always strong).destroy.tsis unaffected — its use ofinferCrossStackStackDepsonly REORDERS the already-selected destroy set (it never expands it), so keeping both kinds there is correct. New unit tests cover thekindsfilter (default = both;['ImportValue']= strong only excludes the GetStackOutput producer;['GetStackOutput']= weak only). Verified end-to-end against real AWS:schema-v7-to-v8-migrationnow PASSES (Phase 3 deploys ONLY the consumer; producer state stays at its prior version) and the broadmulti-stack-depsinteg confirms cross-stack deploy ORDERING is non-regressed (producer-first deploy, consumer-first destroy, 0 orphans).✅ Feature:
cdkd destroy --purge-events(issue #885 follow-up) —src/cli/commands/destroy.ts. The #885 store-bounding work (auto-prune +cdkd events prune) left the issue's third proposed option unimplemented: a destroy-time flag to delete event history.cdkd destroy <stack> --purge-eventsadds it. By defaultcdkd destroykeeps thedeployments/event store as post-mortem context (so the bucket never returns fully empty after teardown);--purge-eventsopts into deleting that history immediately after a CLEAN, non-interrupted destroy of each target stack, so the bucket returns empty in one command. The gating is the exported, unit-testable helperpurgeEventsAfterDestroy(reader, stack, region, {purgeEvents, runResult, interrupted}, logger)— it purges (viaDeploymentEventsReader.pruneRuns(..., {all:true})) ONLY whenpurgeEvents===true && runResult==='SUCCEEDED' && !interrupted, and is deliberately skipped on a failed/interrupted destroy (those events are exactly the post-mortem the user wants on the retry). It is called AFTER the run'seventRecorder.finalize()so this run's own events are included in the purge (purging first would just be re-created by finalize). Best-effort: a purge failure warns but never fails the already-successful destroy (the resources are gone regardless).cdkd state destroydoes NOT take the flag —cdkd events prune <stack> --allis the CDK-app-free / already-destroyed equivalent. The flag is registered directly oncreateDestroyCommand(not the shareddestroyOptionsarray) so onlycdkd destroyadvertises it. 6 new unit tests cover the helper's gating matrix (clean purge / flag-off no-op / FAILED skip / interrupted skip / no-log-when-nothing-deleted / warn-not-throw on purge failure). Verified end-to-end against real AWS via a broad integ (deploy →destroy --purge-events→ assert state bucket fully empty incl.deployments/).✅ Fix: adding a Global Secondary Index to a DynamoDB table is now an in-place UPDATE (was a failed replacement) —
src/analyzer/replacement-rules.ts+src/provisioning/providers/dynamodb-table-provider.ts. Bug: adding a GSI to an existingAWS::DynamoDB::TablegrowsAttributeDefinitions(the new index's key attribute), and the replacement rule listedAttributeDefinitionsas a blanket replacement-trigger — socdkd deploytried to REPLACE the table (DELETE + CREATE on the same name), which failed at CREATE withTable already existsand rolled back. This diverged from CloudFormation, where adding a GSI is an in-placeUpdateTable(update behavior "No interruption"). Adding/removing a GSI is one of the most common DynamoDB redeploy operations, so this hit a daily-use path. Fix (two layers): (1)replacement-rules.tsremovesAttributeDefinitionsfrom the DynamoDBreplacementPropertiesset and adds aconditionalReplacementspredicate (attributeTypeChangedForSharedAttribute) that requires replacement ONLY when an attribute present on BOTH sides changes itsAttributeType(the one case DynamoDB rejects in place); a key-attribute NAME change still surfaces as aKeySchemadiff, which remains a blanket replacement. (2)dynamodb-table-provider.ts'supdate()now actually applies GSI add / remove / per-index throughput changes viaUpdateTable'sGlobalSecondaryIndexUpdates(previously a silent gap — the change would have been recorded in state as applied while never reaching AWS): deletes/creates/updates are serialized one op perUpdateTable(an AWS constraint), a Create carries the full desiredAttributeDefinitions, and a newwaitForTableAndIndexesActivewaits for both the table AND every GSI to beACTIVEbetween ops (a fresh index keeps BACKFILLING after the table returns to ACTIVE). New unit tests cover the conditional replacement predicate (add-only / remove-only / shared-type-change / KeySchema) and the provider GSI add / remove / throughput-update / no-op paths. New real-AWSdynamodb-gsi-updateinteg fixture deploys apk-only table, redeploys adding a GSI, and asserts the table'sCreationDateTimeis unchanged (no replacement) + the GSI reachesACTIVEbefore destroying clean. Verified end-to-end against real AWS (the GSI is added in place; the table keeps its identity).✅ Feature: bound + purge the deployment-events store (issue #885) —
src/state/deployment-events-store.ts+src/cli/commands/events.ts+src/state/s3-state-backend.ts. Bug: the structured deployment-events store (cdkd/{stack}/{region}/deployments/, issue #808) had no lifecycle management —index.jsonkept only the last 20 runs but the underlying{runId}.jsonlobjects were never deleted, so after 20 runs every older run's stream lingered in S3 forever, and there was nocdkdcommand to purge them (socdkd destroy, which deliberately keeps event history as post-mortem context, never returned the state bucket to empty — tripping the project's "state bucket empty after teardown" orphan-verification convention). Fix (two mechanisms): (1) Self-bounding at write time —DeploymentEventsStore.finalize()now calls a newpruneSupersededRunFiles(keptRunIds)after the index write, deleting{runId}.jsonlstreams that fell out of the 20-run (DEPLOYMENT_EVENTS_MAX_INDEX_RUNS) index window so the per-run files stay bounded to the same window as the index. It runs inside the same best-effort write-chain link (a failure warns once, never blocks the run) and is concurrency-tolerant: the cutoff is the OLDEST retained run id and only streams strictly below it are deleted — run ids are time-sortable, so a concurrent newer run's id sorts above every retained id and is never touched (an extreme earlier-started-still-writing edge self-heals on its next full-stream re-PUT). (2) Explicit purge — newcdkd events prune <stack>subcommand (createEventsPruneCommand/eventsPruneCommand) routed throughDeploymentEventsReader.pruneRuns:--allpurges every run + the index (mutually exclusive with the other retention flags),--keep <N>retains the newest N,--older-than <dur>deletes runs older than the cutoff (parsed viarunIdTimestampMsoff the run-id's compact-ISO prefix; an unparseable id is kept — the safe direction), both together delete only runs that are BOTH beyond newest-N AND older than the cutoff, and no flag defaults to keeping the newest 20. Prompts for confirmation unless-y/--yes(and refuses rather than hangs on a non-interactive terminal without--yes);--stack-regiondisambiguates a multi-region stack.pruneRunsdeletes the matching streams then rewritesindex.jsonto drop the pruned runs (or removes it entirely when none remain), soprune --allreturns the stack'sdeployments/prefix to empty. Both paths batch-delete through the newS3StateBackend.deleteRawObjects(keys)(chunked to the 1,000-keyDeleteObjectsceiling, idempotent, aggregating per-keyDeleteObjectserrors and throwing on the explicit-purge path). The default "events survive destroy" behavior is unchanged — this only adds a way to reclaim the space and bounds unbounded growth. Thecdkd events prunesubcommand reads its inherited options viacommand.optsWithGlobals()because the parenteventsalready declares the shared flags and a duplicate child declaration would mis-route post-subcommand flags to the parent. Unit tests cover the runId timestamp parser, the auto-prune (window edge + below-window no-op),pruneRuns(--all/--keep/--keep 0/--older-than/ keep+older conservative-AND / default-keep / no-match / index rewrite + delete-when-empty / delete-failure surfacing), and theevents prunecommand (purge / retention /--all+--keeprejection / non-TTY refusal / index-only removal). State-driven (no synth, no lock); not in any destroy / deploy integ-gate scope (the events store is a separatesrc/state/key family, never touches resource deletion). Verified end-to-end against real S3.
Recently Implemented (2026-06-15):
✅ Backfill:
AWS::StepFunctions::StateMachineDefinitionS3Location(silent-drop → handled, #609) —src/provisioning/providers/stepfunctions-provider.ts.DefinitionS3Location({ Bucket, Key, Version? }) has NO field on the SDKCreateStateMachineinput — CloudFormation reads the S3 object (the Amazon States Language definition) and inlines its contents as the state-machineDefinition, so cdkd was silently dropping it (a state machine deployed with an S3-sourced definition got an empty{}definition). This is a feasible deferred backfill rather thanunhandledByDesign: cdkd already does S3-fetch-and-inline forAWS::AppSync::GraphQLSchema.DefinitionS3Location. Fix:DefinitionS3Locationmoves from the StateMachinesilentDropset intohandledProperties;buildDefinitionString()becomes async — when no inlineDefinitionString/Definitionis present, it fetches the S3 object (GetObject, honoring an optionalVersion→VersionId) and uses the body as the definition. It then appliesDefinitionSubstitutions(the${name}token replacement CloudFormation does) to the fetched body, because the intrinsic resolver cannot reach into S3 content — unlike the inline path, where CDK folds substitutions intoFn::Suband cdkd's resolver pre-resolves them before the provider sees them. InlineDefinitionString/Definitionstill take precedence overDefinitionS3Location(CloudFormation parity); an empty S3 body throws beforeCreateStateMachineruns (no half-created state machine). Substitution values are coerced fromstring/number/booleanonly. New unit tests cover S3 fetch /VersionIdpassthrough / substitution application / inline-precedence (no S3 fetch) / empty-body throw. New real-AWSstepfunctions-s3-definitioninteg fixture (L1CfnStateMachine+ ans3_assets.AssetASL file + a${Greeting}substitution) asserts (viadescribe-state-machine) that the definition was sourced from S3 AND the substitution was applied on a real deploy before destroying clean. Also fixes a latent asset-layer bug the integ surfaced (src/assets/asset-publisher.ts+src/assets/asset-manifest-loader.ts): BOTH file-asset-selection sites —AssetPublisher.addAssetsToGraph(deploy /publish-assets) ANDAssetManifestLoader.getFileAssets(cdkd local invoke-agentcore) — excluded EVERY.jsonfile asset (endsWith('.json')) to skip the CFn template, which wrongly dropped legitimate user.jsonfile assets — so the ASL document (asset.<hash>.json) was never published to the bootstrap bucket and the deploy failed withThe specified key does not exist. Both now exclude ONLY.template.json(top-level +.nested.template.json) via a new sharedisCfnTemplateAssetPath()predicate so the two sites cannot drift. The S3-fetch path also now throws on a zero-byte object (a presentBodywhosetransformToString()is'') beforeCreateStateMachine, rather than sending an empty definition. Unit tests cover the predicate, both selection sites (non-template.jsonpublished, top-level + nested templates skipped), and the empty / no-body throws.✅ Cleanup: reclassify
AWS::CloudWatch::AlarmEvaluationCriteria/EvaluationIntervalasunhandledByDesign(#609) —src/provisioning/providers/cloudwatch-alarm-provider.ts. Both properties exist in the CloudFormationAWS::CloudWatch::Alarmregistry schema but are absent from BOTH the AWS SDKPutMetricAlarminput AND the aws-cdk-libCfnAlarmL1 (a newer CFn-schema surface ahead of SDK / CDK support), so per the #609 backfillability rule they are not backfillable. They previously carried the genericsilentDrop"not yet implemented by cdkd" placeholder (falsely implying pending work); a new providerunhandledByDesignmap now records the real rationale, which the property-coverage codegen surfaces inproperty-coverage.generated.ts. No behavior change (theunhandledByDesignmap is consumed only by the property-coverage check). Sibling reclassification candidates (CognitoWebAuthnFactorConfiguration, ECSInferenceAccelerators) were already classified in prior work.✅ Backfill:
AWS::ServiceDiscovery::ServiceServiceAttributes(silent-drop → handled, #609) —src/provisioning/providers/servicediscovery-provider.ts.CreateServicedoes NOT acceptServiceAttributes(a key→value map); they ride on a separate post-createUpdateServiceAttributescontrol-plane call, so cdkd was silently dropping them. Fix:ServiceAttributesmoves from the ServicesilentDropset intohandledProperties;createService()issues anUpdateServiceAttributes(wrapped in the sharedwithRetrytransient helper) afterCreateServicesucceeds, and on failure issues a best-effortDeleteService+ rethrows (create atomicity — otherwise a half-configured service strands and the next CREATE hits a name collision).updateService()diffs old vs new attributes and submits the delta viaUpdateServiceAttributes(changed/added keys) andDeleteServiceAttributes(keys present only in the previous state), throwing on failure so state is not written as-if-applied; the attribute diff runs even when theServiceChangebody is empty.readCurrentStatefor the Service gains aGetServiceAttributesreadback (emits the full key→value map; best-effort on permission error). Map values are coerced fromstring/number/booleanonly (neverString()an object). New unit tests cover create-apply / create-atomicity / update-upsert / update-remove / no-op / update-throw and readback emit/empty. A new real-AWSservicediscoveryinteg fixture (bare VPC +PrivateDnsNamespace+ L1CfnServicewithserviceAttributes) asserts (viaget-service-attributes) the attributes reached AWS on a real deploy before destroying clean.✅ Backfill:
AWS::ApiGatewayV2::AuthorizerAuthorizerCredentialsArn(silent-drop → handled, #609) —src/provisioning/providers/apigatewayv2-provider.ts.AuthorizerCredentialsArnis the REQUEST-authorizer-only IAM role ARN API Gateway assumes to invoke the REQUEST Lambda authorizer; it is a simple create-time scalar onCreateAuthorizer/UpdateAuthorizer/GetAuthorizer(no post-create control-plane call, no atomicity handling). Fix:AuthorizerCredentialsArnmoves from the AuthorizersilentDropset intohandledProperties;createAuthorizer()passes it through onCreateAuthorizerCommand(ungated — AWS rejects it on JWT authorizers and CDK only emits it for REQUEST, so passingundefinedwhen absent is a no-op);updateAuthorizer()adds it to the diffedUpdateAuthorizerCommandinput alongside the other REQUEST-only fields;readAuthorizer()emits it (gated!== undefined) inside the existingAuthorizerType === 'REQUEST'discriminator branch so a JWT authorizer never surfaces it. New unit tests cover create-send / update-diff / unchanged-no-op / readback emit-omit-and-JWT-guard. Theserverless-apiinteg fixture gains aniam.Role(assumed byapigateway.amazonaws.com) and setsauthorizerCredentialsArnon its standalone REQUEST authorizer; itsverify.shAssertion 5 now also asserts (viaget-authorizers) thatAuthorizerCredentialsArnis an IAM role ARN that reached AWS on a real deploy before destroying clean.✅ Backfill:
AWS::ElasticLoadBalancingV2::ListenerListenerAttributes(silent-drop → handled, #609) —src/provisioning/providers/elbv2-provider.ts.CreateListenerdoes NOT acceptListenerAttributes(e.g.routing.http.response.server.enabled,tcp.idle_timeout.seconds) — they ride on a separate post-createModifyListenerAttributescontrol-plane call, so cdkd was silently dropping them. Fix:ListenerAttributesmoves from the ListenersilentDropset intohandledProperties;create()issues aModifyListenerAttributes(wrapped in the sharedwithRetrytransient/throttle helper) afterCreateListenersucceeds, and on failure issues a best-effortDeleteListener+ rethrows (create atomicity — otherwise a half-configured listener strands and the next CREATE hitsDuplicateListener).update()diffs old vs new attributes and submits only the delta (keys removed from the template are pushed back as the empty-string default to clear the override), throwing on a Modify failure so state is not written as-if-applied. The existing ListenerreadCurrentStategains aDescribeListenerAttributesreadback (sorted byKeyfor stable positional drift compare). Attribute Values are coerced fromstring/number/booleanonly (neverString()an object). New unit tests cover create-apply / update-diff / clear-on-remove / no-op / create-atomicity / string-passthrough / readback. Thealbinteg fixture setsrouting.http.response.server.enabled=falsevia the L1CfnListener.listenerAttributesescape hatch, and its newverify.shasserts (viadescribe-listener-attributes) the value reached AWS on a real deploy before destroying clean.✅ Polish: call-site warn when a no-change-path Outputs resolution fails (follow-up to #875) —
src/deployment/deploy-engine.ts. The #875 no-change Outputs-persist path keeps the previously-persisted outputs (rather than overwriting with a partial map) whenresolveOutputscould not resolve every output.resolveOutputsalready logs a per-outputwarnon each unresolved value, but the no-change path now ALSO emits a one-line call-site summary when the resolved outputs DID differ yet a resolution failure suppressed the persist — so the "deploy reportsNo changes detectedwhile a newly-added export silently failed to land" path is explicit in the log instead of leaving a later downstreamFn::ImportValuefailure with no obvious link back. Also adds an explanatory comment on why resolving againsteffectiveTemplatevs the rawtemplateis equivalent in the no-change path (condition pruning only touchesResources; outputs resolve againstcurrentState.resources). No behavior change beyond the added log line; theresolutionFailed-guard branch it sits in is already covered by the #875 unit test.✅ Fix: persist an Outputs-only change on a no-resource-diff deploy (closes the cross-stack
Fn::ImportValuebreak, issue #875) —src/deployment/deploy-engine.ts. Bug:DiffCalculator.calculateDiffcompares onlyResources, so when a downstream stack newly references an already-deployed producer, CDK synth adds a newOutput(withExport.Name) to the producer's template WITHOUT changing any of its resources — and the diff yieldshasChanges = false. The deploy engine's no-change early-return then never calledresolveOutputs()/exportIndexStore.updateForStack(), so the new export was never written to the producer's state or the exports index, and the consumer's subsequentFn::ImportValuefailed withexport '...' not found in any stack. (Deploying both stacks together for the FIRST time worked, because the producer's resources are allCREATEand the normal path persists outputs; the bug only fired when the producer was already deployed and the reference was added afterwards.) This is a behavior divergence from the CDK CLI / CloudFormation, where an Outputs-only change IS detected as a changeset diff and the stack is updated. Fix: the no-change path now (skipped under--dry-run) resolves the template outputs against current state and, when they differ fromstate.outputs, persists the new outputs (outputs-only state save) and callsexportIndexStore.updateForStack()— alongside the existing observed-properties refresh, in a single state save. A resolution that produces anyundefinedvalue (a resolver failure — should not happen in the no-change path since every resource is already in state) keeps the existing good outputs rather than overwriting them with a partial map;imports[]/outputReads[]are preserved as before. Output-map equality is a new module-private deep compare (outputMapsEqual). 5 unit tests (new export persisted + index updated; unchanged outputs → no save / no index touch; output removal persisted + dropped from index;--dry-rundoes nothing;imports[]/outputReads[]preserved). Newoutputs-only-exportinteg fixture reproduces the exact chain end-to-end against real AWS: deploy producer alone (no export) → consumer now exists so the producer redeploy is a no-op resource diff that still persists the export to state + index (bucket NOT recreated) → consumer deployed with--exclusively(producer NOT redeployed) resolves itsFn::ImportValueto the producer bucket ARN. New canonical scenariomulti-stack-outputs-only-export.✅ Fix: stop unwrapping
AWS::CloudFront::Distribution.OriginGroupsin drift readback (closes the OriginGroups phantom drift, issue #873) —src/provisioning/providers/cloudfront-distribution-provider.ts. Bug:OriginGroupswas listed inQUANTITY_ITEM_FIELDS(the top-level fields that are a bare array in CFn and a{ Quantity, Items }wrapper in the SDK, so cdkd wraps them on write and unwraps them on read). But unlikeOrigins/CacheBehaviors/Aliases/CustomErrorResponses(genuinely bare lists in CFn), the CFnDistributionConfig.OriginGroupsproperty is ITSELF a{ Quantity, Items }object — confirmed by synthesizing a distribution with an origin group (Quantity+Items[], each item carrying inner{ Quantity, Items }MembersandFailoverCriteria.StatusCodes). SoconvertToCfnFormatwas unwrapping the top-level OriginGroups to a bare array, which no longer matched the template's{ Quantity, Items }, firing phantom drift on everycdkd driftfor any distribution with an origin group. (#871 had suppressed OriginGroups viagetDriftUnknownPathsas a stopgap, under the mistaken assumption — from that PR's review — that the inner Members/StatusCodes were bare arrays needing arevertOriginGroup; the synth evidence shows the whole subtree is{ Quantity, Items }, so the real fix is the opposite: don't touch it at all.) Fix: removeOriginGroupsfromQUANTITY_ITEM_FIELDSso it passes through UNTOUCHED in both directions (create still works —wrapWithQuantitywas a no-op on the already-{ Quantity, Items }value), and removeDistributionConfig.OriginGroupsfromgetDriftUnknownPathsso origin-group drift is now actually reported. 1 new unit test (OriginGroups{ Quantity, Items }with inner Members/StatusCodes passes through byte-equal, not unwrapped);getDriftUnknownPathstest updated. Validated against real AWS: thes3-cloudfrontfixture gains anOriginGroup(primary S3-OAI origin + HTTP fallback + failover status codes), andverify.shasserts the distribution carries exactly 1 OriginGroup AND that the whole distribution (OriginGroups now included) reports clean on a fresh deploy.✅ Fix: canonicalize CloudFront OAI grant principals in
S3BucketPolicyProvider.readCurrentState(closes the phantom drift on an S3 BucketPolicy granting an OAI, issue #872) —src/provisioning/providers/s3-bucket-policy-provider.ts. Bug: a bucket policy statement that grants a CloudFront Origin Access Identity (OAI) read access stores its principal as the OAI's S3 canonical user id ({ CanonicalUser: <64-hex> }, what CDK'sFn::GetAtt [<OAI>, S3CanonicalUserId]resolves to). Buts3:GetBucketPolicyreturns that same principal in TWO other unstable forms over the policy's lifetime: a transient{ AWS: <IAM-unique-id> }(e.g.AIDA…) right afterPutBucketPolicy, then the settled{ AWS: arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <id> }. The drift comparator saw these three equivalent representations as different and fired a guaranteed false positive on everycdkd driftrun for any stack with an OAI + S3 bucket policy (the deploy-timeobservedPropertiescapture caught the transient unique-id form; the later drift read got the ARN form — neither matched the template's canonical user id). Fix:readCurrentStatenow canonicalizes every recognizable OAI principal back to{ CanonicalUser: <id> }. For the settledarn:…:cloudfront:user/…<oaiId>form it maps the OAI id to itsS3CanonicalUserId, preferring the same-stack sibling OAI resource's already-read state attribute (zero AWS call / nocloudfront:GetCloudFrontOriginAccessIdentityIAM grant — the OAI'sS3CanonicalUserIdis a readOnly attribute cdkd already resolved at deploy time, threaded into the drift read via the newReadCurrentStateContext.siblings[].attributes+.physicalIdfields) and falling back toGetCloudFrontOriginAccessIdentity(<oaiId>)only when the OAI is NOT a same-stack sibling (an imported / external OAI). The transient bare-IAM-unique-id form (which carries no recoverable link to the OAI) is canonicalized only by matching the corresponding template statement (same Effect / Action / Resource) carrying a{ CanonicalUser }principal — safe because a user cannot author a bare IAM unique id as a bucket-policy principal, so it only ever fires for AWS's transient rendering. Both the deploy-time capture and the drift read run through the same normalization, so both converge to the canonical user id and compare equal; a genuinely-different OAI resolves to a different canonical id so real drift is still detected (strict reconcile — never a blanket suppress). Non-OAI principals (*, service principals, normal role ARNs) are untouched. TheReadCurrentStateContext.siblingsshape gained optionalphysicalId+attributes(populated bybuildReadCurrentStateContextinsrc/cli/commands/drift.ts) so any provider can reconcile a sibling's computed identity from already-read state without an extra AWS call. 13 unit tests (ARN→CanonicalUser via sibling state attribute with NO CloudFront call; strict-reconcile: a DIFFERENT OAI resolves to a DIFFERENT canonical id so real drift is NOT suppressed; AWS-array principal + ambiguous-template-match left unchanged; ARN→CanonicalUser viaGetCloudFrontOriginAccessIdentityfallback when not a sibling; bare-unique-id→template-match; no-template-match left unchanged; best-effort ARN-lookup-failure left unchanged; non-OAI principals untouched; one CloudFront call cached across statements). Validated against real AWS by extending thes3-cloudfrontinteg'sverify.shto assert theAWS::S3::BucketPolicyreports clean (in thecleandrift bucket, notdrifted) on a fresh deploy — the distribution-scoping caveat the CloudFront PR (#871) added is removed now that the bucket policy no longer phantom-drifts. (Approach mirrors the sibling cdk-real-drift project's fix per the #872 cross-note: read the OAI'sS3CanonicalUserIdfrom already-read state rather than re-fetching it.)
Recently Implemented (2026-06-14):
✅ Fix:
AWS::DynamoDB::Tablein-placeBillingMode/ProvisionedThroughputUPDATE (was a silent drop) —src/provisioning/providers/dynamodb-table-provider.ts. Bug:update()handled OnDemandThroughput / WarmThroughput / PITR / TTL / ResourcePolicy / Kinesis / ContributorInsights / Tags but issued NOUpdateTableforBillingModeorProvisionedThroughput, even though both are declared inhandledPropertiesand both are mutable (CFn createOnly = onlyTableName+ImportSourceSpecification). So a pure capacity change (e.g. RCU 5→100, mode unchanged) or a pure billing-mode switch (PROVISIONED↔PAY_PER_REQUEST) was silently dropped —update()returned{ wasReplaced: false }with no AWS call while cdkd recorded the new value into state as if applied, so the next deploy saw no diff and the AWS-side capacity / mode stayed stale forever (the silent-drift failure mode documented infeedback_tags_on_update_must_throw). Fix: a new branch, ordered BEFORE the OnDemand/Warm throughput branches, fires a SINGLEUpdateTablewheneverBillingModeORProvisionedThroughputchanged. It forwardsBillingModewhen present andProvisionedThroughputonly when the (desired) mode is notPAY_PER_REQUEST(AWS rejects caps on on-demand; PROVISIONED requires them), coercing the string-typed capacity values to numbers viaNumber()(matchingcreate(); CFn emits numerics as strings). A combined switch-to-PROVISIONED-with-caps now works because both fields ride one call before the OnDemand branch (closing the pre-existing fail-loud caveat the old comment documented). After the call it waits for ACTIVE via the existingwaitForTableActiveAfterUpdatehelper so later branches don't race a still-UPDATING table. Per-indexGlobalSecondaryIndexesProvisionedThroughput is explicitly NOT handled (a documented deferred gap, not a silent one — needsGlobalSecondaryIndexUpdates). 6 unit tests (pure capacity change PROVISIONED→PROVISIONED; string-numeric coercion; switch to PAY_PER_REQUEST drops caps; PAY_PER_REQUEST switch ignores stale template caps; switch to PROVISIONED sends both in one call; no-change makes no billing UpdateTable). Integ: thedynamodb-ondemandfixture gains a standalone PROVISIONED table whose capacity flips RCU 5→20 / WCU 5→10 underCDKD_TEST_UPDATE=true, andverify.shadds a Phase-1.5 re-deploy +describe-tableassertion that AWS reflects the new ProvisionedThroughput (plus a destroy-cleanup poll for the new table).✅ Feature: CC-API enrichment-coverage completeness matrix + CI critic (makes the enrichment-gap bug class non-regressing) —
scripts/gen-enrichment-coverage.ts+docs/_generated/enrichment-coverage.{json,md}+vp run gen:enrichment-coverage/vp run audit:enrichment-coverage:check+scripts/refresh-cfn-schemas.mjs. Motivation:CloudControlProvider.enrichResourceAttributes(the hand-maintained switch that overlays computedFn::GetAttattributes onto the flat-key shape cdkd's intrinsic resolver expects for CC-routed resources) had no mechanism preventing a new CC-routed type with a computedreadOnlyattribute from silently falling throughconstructAttributeto the physicalId — the exact bug class fixed one-off in #844 / #864 / #865 / #866. Deliverable: a codegen + CI critic modeled on the existinggen-property-coverage/gen-unsupported-typespattern (TS-Compiler-API parse of the source, drift-on-git diffin CI). The generator parses theenrichResourceAttributesswitch via the TypeScript Compiler API (extracting, percase, theenriched['Attr'] = ...keys — flat-keys likeEndpoint.Addressmatched against their nested readOnly propEndpoint), cross-references each type'sreadOnlyPropertiesfrom the cached CFn schema fixtures, and classifies every type into:enriched,no-computed-attr(Ref == physicalId is correct),sdk-fallback-gap(gap on an SDK-backed type — only exposed on the #614 silent-drop CC-fallback path, informational), orunenriched-computed(gap on a pure-CC type with no SDK provider — the real bug class). Theaudit:enrichment-coverage:checkcritic hard-fails ONLY onunenriched-computed. A readOnly prop that IS the type'sprimaryIdentifieris auto-classified not-a-gap (the resolver's physicalId fallback resolves it);refresh-cfn-schemas.mjsis extended to captureprimaryIdentifierinto the fixtures so this is data-driven going forward. SeedENRICHMENT_ALLOW_LISTcarves outAWS::MSK::Cluster(Arn == primaryIdentifier == physicalId, not-a-bug) +AWS::Elasticsearch::Domain(Tier-3 non-provisionable, rejected pre-flight). Current classification (offline, cached fixtures): 114 CC-routable types classified — 3 fully enriched, 13 no-computed-attr, 0 pure-CC latent gaps (every cached schema today is an SDK-backed Tier 1 type — the fixture refresh only fetches registered providers — so the critic passes by construction and becomes load-bearing the moment a pure-CC type's schema is cached, e.g. ElastiCache::ReplicationGroup / Redshift::Cluster / OpenSearchService::Domain). 98 SDK-fallback gaps are reported informationally (computed attrs only exposed on the #614 path; the SDK provider populates them on the primary path) for a future #614-hardening pass. 8 enrichment cases (ApiGateway::RestApi, CloudFront::OriginAccessControl, EC2::EIP, ElastiCache::ReplicationGroup, Lambda::Version, OpenSearchService::Domain, Redshift::Cluster, Route53::HealthCheck) lack a cached schema and are listed as needing a fixture refresh. 20 unit tests (tests/unit/scripts/gen-enrichment-coverage.test.ts) covering the switch parser (plain / try-catch / fall-through / empty-body / non-enrichedassignment), the classifier (every bucket, flat-key-to-nested matching, primaryIdentifier auto-allow-list, enrichment-precedence, partial coverage), andbuildReport(bucketing + summary + cache-less-enrichment-case reporting). No AWS integ (pure static analysis / codegen); only/check+/check-docsgates apply.✅ Fix: Glue Job/Crawler/Trigger/Workflow update + delete hardening (4 bugs) + new
glue-update-hardeninginteg —src/provisioning/providers/glue-provider.ts+tests/integration/glue-update-hardening/. Four confirmed bugs in the Glue providers, all in one file:- Glue Job stringly-typed numeric coercion.
buildJobCommonFields(shared by create + update) copiedMaxRetries/AllocatedCapacity/Timeout/MaxCapacity/NumberOfWorkersverbatim, plus nestedExecutionProperty.MaxConcurrentRuns/NotificationProperty.NotifyDelayAfter. CFn can deliver these as STRINGS ("60") while the Glue SDK types them int/double;as numberis compile-only and does not coerce at runtime, so a string would reach the SDK for a number-typed field (thefeedback_cfn_stringly_typed_numerics_need_coercepattern). Fix: newcoerceNumber()helper wraps each numeric field at the wire boundary (already-numeric values pass through; non-finite / unparseable inputs are left unchanged so AWS surfaces a clear validation error rather thanNaN). - Glue Crawler
CrawlerRunningExceptionon delete/update.delete()/update()only caughtEntityNotFoundException; a mid-run crawler throwsCrawlerRunningExceptionon DeleteCrawler / UpdateCrawler → uncaught → destroy/update failed. Fix: onCrawlerRunningException, the newstopCrawlerAndWait()helper issuesStopCrawlerand pollsGetCrawleruntil the crawler leaves RUNNING/STOPPING (tolerates the already-stopping race), then the delete/update is retried. - Glue Trigger update state-machine + delete.
update()did StopTrigger → UpdateTrigger → StartTrigger but (a) did not wait for the trigger to leave ACTIVATED after the async StopTrigger before UpdateTrigger, and (b) skipped StartTrigger if UpdateTrigger threw — leaving a previously-ACTIVATED trigger stuck DEACTIVATED.delete()did not stop an ACTIVATED trigger first. Fix: newwaitForTriggerDeactivated()polls GetTrigger after StopTrigger; the StartTrigger re-activation always runs even when UpdateTrigger throws, but is now sequenced via capture-and-rethrow (NOT a barefinally) so that if BOTH the update and the re-activation fail, the root-cause UpdateTrigger error wins instead of being masked by a secondary "failed to re-activate" error (a re-activation failure on an otherwise-successful update still surfaces);delete()stops + waits for an ACTIVATED trigger before DeleteTrigger. - Glue Workflow Tags silently dropped for the MAP shape.
workflowTagsForCreate()only acceptedArray.isArray(tags)and returnedundefinedfor the tag-map shape CDK'sCfnWorkflow.tagssynths — silently dropping every tag. Fix: Workflow create now uses the map-tolerantcfnTagsToMap()(the helper every other Glue type already uses); the deadworkflowTagsForCreatehelper is removed. WorkflowMaxConcurrentRuns(create + update) is also now run throughcoerceNumber(). - Glue Crawler create raced IAM trust-policy propagation. The first real-AWS run of the integ surfaced a 5th bug NOT in
glue-provider.ts: cdkd's fast SDK path creates the Crawler's IAM role and the Crawler ~1s apart, before the role's trust policy propagates to Glue's assume layer, soCreateCrawlerwas rejected with "Service is unable to assume provided role. Please verify role's TrustPolicy." and the deploy failed at CREATE. CloudFormation never hits this (its deployment latency lets IAM settle) but cdkd does — the same just-created-role propagation race the RDS Enhanced Monitoring (#794) / ECS CapacityProvider (#805) / SNS+SQS resource-policy (#839) fixes handle for other services. Fix: add'is unable to assume provided role'toRETRYABLE_ERROR_MESSAGE_PATTERNSinsrc/deployment/retryable-errors.ts(anchored on the Glue-specific phrase — the existing lower-case'trust policy'pattern does NOT match Glue's"TrustPolicy"), so cdkd's outerwithRetry(1s/2s/4s/8s backoff) absorbs the propagation window the way CloudFormation's deployment latency does. 1 unit test added. Unit tests cover: string→number coercion on Job create + update + already-numeric pass-through + non-finite/unparseable left-unchanged (noNaN); Workflow MAP + list tag shapes + no-tags elision; Crawler stop-and-retry on CrawlerRunningException for both delete + update + the already-stopping-race StopCrawler-rejection tolerance; Trigger restore-on-UpdateTrigger-failure, wait-for-DEACTIVATED ordering, no stop/restart when already DEACTIVATED, stop-before-delete, no-stop-when-not-ACTIVATED; Glue assume-role propagation message is retryable AND a non-matching Glue error is not. Newglue-update-hardeninginteg fixture (L1CfnJobwith numeric props + idleCfnCrawler+ ON_DEMANDCfnTriggerrunning the Job +CfnWorkflowwith MAP-shape tags);verify.shasserts the Job numeric props reach AWS as JSON numbers (aws glue get-job), the Workflow MAP-shape tags reach AWS (aws glue get-tags), the crawler + trigger exist, and all four are gone after a clean destroy (0 orphans);CDKD_TEST_UPDATE=truere-deploys to exercise the update paths. (The string-INPUT coercion path is unit-tested rather than integ-tested because CDK's L1 validator rejects stringly-typed numerics at synth time.)
- Glue Job stringly-typed numeric coercion.
✅ Feature:
CloudFrontDistributionProvider.readCurrentState+getDriftUnknownPaths(closes theAWS::CloudFront::Distributiondrift blind spot) —src/provisioning/providers/cloudfront-distribution-provider.ts. Gap: the provider was 1 of only 3 (the other two — custom-resource / nested-stack — are by-design N/A) lackingreadCurrentState, socdkd driftfell back to the generic CC-APIGetResourcepath. CloudFront's deeply-nestedDistributionConfigcarries{ Quantity, Items }wrappers + AWS-injected defaults whose shape diverges from the CFn shape cdkd state stores, so the generic strip pass surfaced phantom drift on a no-change distribution (and could miss real drift). CloudFront is SDK-provisioned, config-heavy, and frequently console-edited (cache behaviors, TTLs, origins, aliases, certs) — the highest-value drift gap. Fix:readCurrentStatecalls the read-onlyGetDistributionConfigCommandand inverts the provider's existingconvertToSdkFormatvia a newconvertToCfnFormat(unwraps every top-level + nested-cache-behavior + origin{ Quantity, Items }wrapper back to bare arrays, hoists the AWS-nestedCachedMethodsout ofAllowedMethodsto the CFn sibling shape, and drops theCallerReferenceidempotency token), then overlays AWS-current tags (GetDistributionARN →ListTagsForResource, filteringaws:cdk:*auto-tags; best-effort — a tag read failure still returns the config).getDriftUnknownPathsdeclaresDistributionConfig.CallerReference(cdkd-generated, never templated) +DistributionConfig.Logging.Bucket(AWS normalizes to a regional domain on read) +DistributionConfig.OriginGroups(the per-origin-group inner{ Quantity, Items }wrappers —Members/FailoverCriteria.StatusCodes— are not yet unwrapped byconvertToCfnFormat, andconvertToSdkFormatis symmetrically unaware of them, so OriginGroups is suppressed to avoid aproperties-fallback-baseline false positive until a dedicatedrevertOriginGroup+ a real OriginGroups integ fixture lands; PR #871 review) as drift-unknown. Because the deploy-time observed baseline is produced by the SAMEreadCurrentState, a fresh distribution compares byte-equal against itself → no false positive. 9 unit tests (non-CloudFront type returns undefined; NoSuchDistribution → undefined; full Quantity-unwrap + CachedMethods hoist + CallerReference drop + tag surface incl. aws:cdk filter; no Tags key when no user tags; config still returned when tag read fails; byte-stable across two reads; getDriftUnknownPaths). Integ:tests/integration/s3-cloudfront/verify.shextended to assert (viacdkd drift --json+ jq, scoped to theAWS::CloudFront::Distributionresource) that the distribution is in thecleanbucket and NOT indriftedon a freshly-deployed stack, then mutatesCommentout-of-band viaaws cloudfront update-distributionand asserts the distribution now appears indriftedwith aCommentchange, then reverts before the clean destroy. The assertion is distribution-scoped rather than whole-stack-clean because the fixture's S3 BucketPolicy granting the OAI carries a SEPARATE, pre-existing latent false-positive (cdkd stores the OAI principal as the S3 canonical user id at deploy time butGetBucketPolicyreturns it asarn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <id>, so the two equivalent principal forms compare unequal) — an S3 BucketPolicy provider gap unrelated to this PR, tracked as a follow-up. Lossiness note:convertToCfnFormatis not perfectly lossless (SDK-required defaultsconvertToSdkFormatinjects —Comment: '',Logging.*,CustomOriginConfig.HTTP{,S}Port— are preserved on the inverted side), but this is correct for the comparator: the observed baseline carries the same defaults, and theproperties-fallback baseline only walks state keys, so the extra defaults can never fire false drift. NOTE:drift.ts/drift-calculator.tsare unchanged (not ininteg-destroyscope), but the provider file IS, so the merge still requires a real-AWS deploy+destroy (/run-integ s3-cloudfront).✅ Fix:
AWS::S3Vectors::VectorBucketin-place Tags UPDATE (was a silent no-op) +S3VectorsProvider.update()immutable-property guard —src/provisioning/providers/s3-vectors-provider.ts. Bug:update()was a silent no-op (return { wasReplaced: false }with no AWS call), so aTagschange was dropped while cdkd recorded the new Tags into state as if applied — the next deploy then saw no diff and the AWS-side tags stayed stale forever (the silent-drift failure mode documented infeedback_tags_on_update_must_throw).Tagsis the ONLY in-place-updatable property of a VectorBucket (the CFn registry schema marksVectorBucketName+EncryptionConfigurationcreate-only, so a change to either drives a replacement, not an update). Fix:updateVectorBucket()diffs old vs new tags and applies the delta viaTagResource(added / changed keys) +UntagResource(removed keys), resolving the bucket ARN viaGetVectorBucketfirst (theupdate()contract only hands the physicalId). A tag-API failure THROWS (state is NOT written → next deploy retries) rather than being swallowed. A create-only property change that somehow reachesupdate()is surfaced asResourceUpdateNotSupportedError(cdkd deploy --replace) instead of silently leaving AWS unchanged. 5 unit tests (no-delta no-op; TagResource add/change; UntagResource remove; tag-API failure throws; immutable-property change rejects before any AWS call). Validated against real AWS (/run-integ s3-vectors— the fixture now re-deploys underCDKD_TEST_UPDATE=trueto changeenv, addowner, removeteam;verify.shasserts the AWS-side tags reflect all three viaListTagsForResource; deploy + update + destroy clean, 0 orphans).✅ Fix: enrich
AWS::OpenSearchService::Domainendpoint/ARN attributes on the Cloud Control path + newopensearch-domain-getattinteg —src/provisioning/cloud-control-provider.ts+tests/integration/opensearch-domain-getatt/. Bug: OpenSearch Service Domain has no SDK provider, so it always routes through Cloud Control, and the CC-API GetResource model does not surface the search endpoint / ARN in the flat-key shape cdkd's intrinsic resolver expects.Fn::GetAtt(<Domain>, 'DomainEndpoint')(thehttps://search-...es.amazonaws.comURL clients connect to) andFn::GetAtt(<Domain>, 'Arn')/DomainArntherefore fell through the resolver'sconstructAttributeto the physicalId (the domain NAME, NOT the endpoint hostname / ARN) — so a connection string or IAM resource statement built from it pointed at garbage with a silent deploy success. Same systemic CC-enrichment GetAtt gap as the Redshift (#865) / ElastiCache RG (#864) / RDS DBInstance (#844) fixes. Fix: add anAWS::OpenSearchService::Domaincase toenrichResourceAttributesthat callsDescribeDomainand overlays the flat-keyDomainEndpoint(fromDomainStatus.Endpoint, falling back to thevpcentry ofDomainStatus.Endpointsfor a VPC domain) +Arn/DomainArn(fromDomainStatus.ARN) +Id(fromDomainStatus.DomainId). Best-effort: a failed Describe leaves the CC-API attribute shape unchanged and never fails the deploy. Adds@aws-sdk/client-opensearchas a dependency. 4 unit tests (happy-path public-domain DomainEndpoint/Arn/DomainArn/Id; VPC-domain Endpoints.vpc fallback; best-effort failure; no-Endpoint-yet tolerance). Validated against real AWS (/run-integ opensearch-domain-getatt— the smallest public single-nodet3.small.searchdomain (10 GiB gp3, no VPC);Fn::GetAtt(DomainEndpoint)/Arnstored into SSM Parameters resolved to the real*.es.amazonaws.comhostname /arn:aws:es:...:domain/...ARN, not the domain name; deploy + destroy clean, 0 orphans). New scenario tagcc-api-getatt-enrichment-opensearch-domain. Third fix of the CC-API enrichment-gap batch (remaining: MSKBootstrapBrokers). NOTE: the legacyAWS::Elasticsearch::Domainis a Tier-3 NON_PROVISIONABLE type (cdkd rejects it pre-flight), so it needs no enrichment — the modernAWS::OpenSearchService::Domainis the only provisionable variant.✅ Fix: enrich
AWS::Redshift::Clusterendpoint attributes on the Cloud Control path + newredshift-cluster-getattinteg —src/provisioning/cloud-control-provider.ts+tests/integration/redshift-cluster-getatt/. Bug: Redshift Cluster has no SDK provider, so it always routes through Cloud Control, and the CC-API GetResource model does not reliably surface the cluster endpoint.Fn::GetAtt(<Cluster>, 'Endpoint.Address')/Endpoint.Port(the JDBC/ODBC connection coordinates) therefore fell through the resolver'sconstructAttributeto the physicalId (the cluster identifier, NOT the endpoint hostname). Same systemic CC-enrichment GetAtt gap as the ElastiCache RG (#864) / RDS DBInstance (#844) fixes. Fix: add anAWS::Redshift::Clustercase toenrichResourceAttributesthat callsDescribeClustersand overlays the flat-keyEndpoint.Address/Endpoint.Port(the SDKCluster.Endpointobject uses the SAME names as the CFn return values — no casing quirk, unlike ElastiCache). Best-effort: a failed Describe leaves the CC-API attribute shape unchanged and never fails the deploy. Adds@aws-sdk/client-redshiftas a dependency. 3 unit tests (happy-path Endpoint.Address/Port string-coercion; best-effort failure; no-Endpoint-yet tolerance). Validated against real AWS (/run-integ redshift-cluster-getatt— a single-nodera3.largecluster (the smallest orderable node type — the legacydc2.largeis no longer orderable) with AWS-managed master password;Fn::GetAtt(Endpoint.Address)stored into an SSM Parameter resolved to the real*.redshift.amazonaws.comhostname, not the cluster id; deploy + destroy clean, 0 orphans). New scenario tagcc-api-getatt-enrichment-redshift-cluster. Second fix of the CC-API enrichment-gap batch (remaining: OpenSearch / ElasticsearchDomainEndpoint, MSKBootstrapBrokers).✅ Fix: enrich
AWS::ElastiCache::ReplicationGroupendpoint attributes on the Cloud Control path + newelasticache-replicationgroup-getattinteg —src/provisioning/cloud-control-provider.ts+tests/integration/elasticache-replicationgroup-getatt/. Bug: ElastiCache ReplicationGroup has no SDK provider, so it always routes through Cloud Control, and the CC-API GetResource model does not surface the connection endpoints in the flat-key shape cdkd's intrinsic resolver expects.Fn::GetAtt(<RG>, 'PrimaryEndPoint.Address')(and the Reader / Configuration variants) therefore fell through the resolver'sconstructAttributeto the physicalId (the replication-group id, NOT the Redis hostname) — so a SecurityGroup rule or client connection string built from the endpoint pointed at garbage with a silent deploy success. This is the same systemic CC-enrichment GetAtt gap as the RDS DBInstance (#844) / CompositeAlarm / EC2 PrivateIp fixes, surfaced by the 2026-06-14 enrichment-coverage audit. Fix: add anAWS::ElastiCache::ReplicationGroupcase toenrichResourceAttributesthat callsDescribeReplicationGroupsand overlays the flat-key endpoint attributes with the CFn casing (PrimaryEndPoint.Address/PrimaryEndPoint.Port/ReaderEndPoint.*/ConfigurationEndPoint.*for cluster-mode + the comma-delimitedReadEndPoint.Addresses/ReadEndPoint.Portslist across node groups) — note the SDK fields areEndpoint(lower p) while the CFn GetAtt names areEndPoint(capital P). Best-effort: a failed Describe leaves the CC-API attribute shape unchanged and never fails the deploy (matches the RDS DBCluster/DBInstance enrichment branches). 9 unit tests intests/unit/provisioning/cloud-control-provider.test.ts(cluster-mode disabled → Primary/Reader; cluster-mode enabled → Configuration + multi-shard ReadEndPoint list; best-effort failure). Validated against real AWS (/run-integ elasticache-replicationgroup-getatt— a cluster-mode-disabled Redis RG;Fn::GetAtt(PrimaryEndPoint.Address)stored into an SSM Parameter resolved to the real*.cache.amazonaws.comhostname, not the RG id; deploy + destroy clean, 0 orphans). New scenario tagcc-api-getatt-enrichment-elasticache-replicationgroup. First fix of the CC-API enrichment-gap batch (remaining latent gaps the audit found: OpenSearch / ElasticsearchDomainEndpoint, MSKBootstrapBrokers, RedshiftEndpoint).✅ New
deletion-ordering-complexfailure-seeking integ stresses cdkd's ELBv2 destroy-ordering web (test-only — nosrc/change) —tests/integration/deletion-ordering-complex/**. Extends destroy-ordering coverage beyond the SG/IGW/NAT cases (vpc-nat-gateway, issue #817) into the richer ElasticLoadBalancingV2 dependency web, which has noimplicit-delete-depsedge today. TheCdkdDeletionOrderingComplexExamplestack is a minimal VPC (natGateways:0, 2 public subnets) with anApplicationLoadBalancer+TargetGroup(TargetType: IP) +Listener(:80 forward) + a non-defaultListenerRule(/app/*) + at3.nanoEC2 instance registered as the TG's IP target. It exercises five AWS-enforced delete-ordering constraints that are not trivially visible as forwardRef/DependsOn: (1) Listener / ListenerRule before TargetGroup (elseDeleteTargetGroup→ResourceInUse); (2) ListenerRule before Listener; (3) TargetGroup + Listener before the LoadBalancer (the ListenerRefs the LB so reverse-DAG handles it, but the TG does NOTRefthe LB — TG-vs-LB rides on the Listener-vs-TG edge alone); (4) the high-risk one — the LB's hyperplane ENIs must be released before the Subnet / SecurityGroup delete (elseDependencyViolation), butELBv2Provider.deleteLoadBalancerfiresDeleteLoadBalancerCommandand returns immediately (nowaitUntilLoadBalancersDeleted), so the reverse-DAG can race the async ENI teardown; (5) the EC2 target's ENI must release before the Subnet / SG delete.verify.shdeploys, asserts the ALB + TargetGroup + Listener + non-default ListenerRule exist, then runs the KEY test —cdkd destroy --forceMUST exit 0 (a wrong delete order surfaces as a non-zero exit, and the script prints the exact AWSDependencyViolation/ResourceInUseerror + full destroy log for triage) — and finally asserts 0 orphans (LB / TG / SG / subnets / IGW / VPC / EC2 instance all gone, located by the fixture's owncdkd:integ-fixture=deletion-ordering-complextag since AWS reserves theaws:prefix so cdkd cannot setaws:cdk:path; state-empty alone is not trusted per the #796 lesson). On any failure exit theEXITtrap tears the leftovers down in AWS-safe order (listener → TG → LB + wait → EC2 instance + wait → leftover ENIs → SG → subnets → IGW → VPC) so a failing run never orphans the cost-bearing ALB / EC2 / VPC. The script is BSD/macOS-portable (nogrep -P/date -d), captures real exit codes, and prints[verify] PASSonly on full success. New canonical scenario tagelbv2-listener-tg-lb-deletion-orderinscripts/build-scenario-coverage-matrix.ts; integ-coverage + scenario-coverage matrices regenerated (the fixture register-coversAWS::ElasticLoadBalancingV2::{LoadBalancer,TargetGroup,Listener}+AWS::EC2::{VPC,Subnet,SecurityGroup,Instance,InternetGateway}). Validated green against real AWS (/run-integ deletion-ordering-complex— deploy + destroy both exit 0, 0 orphans). The constraint-#4 race (LB hyperplane ENIs not yet released when the Subnet / SecurityGroup delete fires, sinceELBv2Provider.deleteLoadBalancerdoes notwaitUntilLoadBalancersDeleted) was predicted to fail but does NOT on current cdkd: the would-beDependencyViolationis absorbed by cdkd's existing transient-error retry ('DependencyViolation'/'has dependencies and cannot be deleted'are retryable patterns insrc/deployment/retryable-errors.ts), so the Subnet / SG delete simply retries on the 1s/2s/4s/8s backoff until the async ENI teardown completes — the destroy converges WITHOUT an explicitimplicit-delete-depsELBv2→Subnet edge (the retry is the safety net). The deploy side needs PR #851's EC2PrivateIpGetAtt fix to register the EC2 instance as the TargetGroup IP target, which is why this fixture could only run once #851 landed on main.✅ New
multi-assetinteg stresses the asset-publishing layer when MANY assets of TWO kinds publish concurrently in one deploy —tests/integration/multi-asset/**. The existingdocker-image-assetands3-asset-deployfixtures each exercise ONE publisher in isolation (ECR build+push vs a single S3 zip upload), leaving the CONCURRENT multi-asset case —FileAssetPublisher+DockerAssetPublisherinterleaved, ECR + S3 in one run, and several distinct S3 uploads in flight together — without a dedicated regression. The newCdkdMultiAssetExamplestack (no VPC) publishes 1 ECR image + 4 S3 objects in a single deploy: alambda.DockerImageFunction(DockerImageCode.fromImageAsset(docker/, { platform: LINUX_ARM64 })+architecture: ARM_64, matched, to avoid the cross-archRuntime.InvalidEntrypoint: ProcessSpawnFailedtrap on Apple-Silicon hosts) goes throughDockerAssetPublisher; threelambda.Functions (AlphaHandler/BetaHandler/GammaHandler, Python 3.12) eachCode.fromAsset('<distinct multi-file dir>')produce three DISTINCTFileAssetPublisherS3 uploads (distinct content -> distinct content-addressed asset hash, confirmed at synth: 5 file assets + 1 docker image in the manifest); and a generics3_assets.Asset(asset-data/) is a 4th S3 upload whose resolveds3BucketName/s3ObjectKeyare threaded into the alpha Lambda viaCONFIG_BUCKET/CONFIG_KEYenv (asset-ref intrinsic resolution). The load-bearing correctness proof is that each Lambda returns its OWN distinct marker (...-docker/...-alpha/...-beta/...-gamma): a cross-wired asset (e.g. the beta ZIP uploaded but cdkd pointed the alpha Lambda'sCode.S3Bucket/S3Keyat it) would return the WRONG marker and FAIL — so the markers prove not just that all assets uploaded but that each Lambda was wired to the RIGHT one.verify.sh(BSD/macOS-portable, real-rc capture + explicit[verify] PASS) gracefully SKIPs (exit 0) whendocker infofails so it is robust on a Docker-less box but runs in a Docker env; it then deploys (printing which asset/resource failed for triage on a deploy error), asserts the Docker Lambda isPackageType=Imagewith OUR pushed image present in ECR by content-tag (parsed fromCode.ImageUri), asserts each zip Lambda'sCodeSize > 500bytes (ran from an uploaded ZIP, not inline), invokes all 4 Lambdas and asserts each distinct marker, asserts the alpha Lambda's generic-asset read-back (configBytes > 0), then destroys and asserts clean (all 4 Lambdas gone, OUR pushed ECR image by tag gone via a sweep fallback, state file gone). The shared bootstrap container-assets ECR repo + the bootstrap asset bucket OBJECTS persist by design (cdkd does not own CDK bootstrap infra) and are NOT treated as orphans; the EXIT trap sweeps leftover state, the deployment-events sidecar, and (by tag) the pushed image. New scenario tagmulti-assetin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ multi-asset(in a Docker env) before merge.)✅ New
sg-circular-dependencyinteg surfaces create/destroy DAG-ordering bugs with a circular Security Group reference (test-only — nosrc/change) —tests/integration/sg-circular-dependency/**. Models the classic CloudFormation cycle the CFn-safe way: SG-A allows ingress from SG-B AND SG-B allows ingress from SG-A, where each rule is a STANDALONEAWS::EC2::SecurityGroupIngressresource (not inline) so the two SGs can exist before the cross-references are added. In CDK,sgA.addIngressRule(sgB, ...)+sgB.addIngressRule(sgA, ...)against two distinct SG constructs makes CDK emit standalone ingress resources (eachFn::GetAttsGroupIdon the SG it attaches to andSourceSecurityGroupIdon the OTHER SG), breaking what would otherwise be a genuine SG-to-SG cycle. The stack is anatGateways: 0single-AZ VPC + SG-A + SG-B + the two cross-referencing ingress resources (no EC2 instances).verify.sh(BSD/macOS-portable, captures real exit codes, prints an explicit=== PASS ===only on full success): Phase 0 runscdkd synthand asserts >= 2 standaloneAWS::EC2::SecurityGroupIngressresources each carrying aSourceSecurityGroupId(true SG-to-SG cross-ref) so the cycle-breaking shape is confirmed before any AWS call; Phase 1 deploys (the DAG builder insrc/analyzer/dag-builder.tsmust NOT raise a falseDependencyError— the standalone ingress resources break the would-be cycle) and asserts both SGs exist with the live cross-reference (SG-A'sIpPermissions[].UserIdGroupPairs[].GroupIdcontains SG-B and vice versa); Phase 2 (the key test) destroys and asserts 0 errors — if cdkd deletes an SG while its cross-referencing ingress rule is still live, AWS rejectsDeleteSecurityGroupwithDependencyViolation: resource sg-xxx has a dependent object, so a wrong delete order fails / orphans here. The post-destroy assertions confirm both SGs + the VPC + the state file are gone. Resources are located by thecdkd:integ-fixture=sg-circular-dependencytag (NOTaws:cdk:path, which AWS reserves), and the EXIT-trap cleanup revokes-then-deletes both SGs directly (the SAME ordering cdkd must perform) so a destroy-ordering bug never leaks billing resources. The existingAWS::EC2::SecurityGroup -> AWS::EC2::SecurityGroupIngressimplicit-delete-dep edge insrc/analyzer/implicit-delete-deps.tsis what this fixture exercises end-to-end on real AWS. New scenario tagsg-circular-dependencyin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); integ-coverage + scenario-coverage matrices regenerated. One fix surfaced while authoring: AWS rejects non-ASCII characters (em-dash U+2014) in a SecurityGroupGroupDescription, so the twoGroupDescriptionstrings are ASCII-only (hyphen, not em-dash). Validated green against real AWS (deploy + destroy clean, 0 orphans).✅ Retry SNS/SQS resource-policy create on fresh-role IAM propagation, plus a new
iam-propagation-stressinteg that is a race detector for IAM-propagation bugs on cdkd's fast SDK path (issue #839) —src/deployment/retryable-errors.ts+tests/integration/iam-propagation-stress/**. cdkd creates an IAM role and has a service assume it within ~1s, before IAM finishes propagating the role / its trust policy; CloudFormation never hits this (deployment latency lets IAM settle) but cdkd does, so every "role created -> assumed within ~1s" edge is a potential failure. The race is handled NARROWLY today for RDS Enhanced Monitoring (#794), ECS CapacityProvider (#805), and Custom Resource (#756) — but MANY other consumers are unprotected, and no integ deliberately stresses the breadth of fresh-role edges. The newCdkdIamPropagationStressExamplestack creates FOUR brand-new IAM roles, each consumed IMMEDIATELY by a DIFFERENT service in ONE deploy so the DAG carries many independent race edges at once: (1) a fresh Lambda exec role ->AWS::Lambda::Function(CreateFunctionvalidateslambda.amazonaws.comcan assume it); (2) a fresh SFN role ->AWS::StepFunctions::StateMachine(CreateStateMachinevalidates the role — the SFN provider has NO propagation retry of its own); (3) a fresh EventBridge target role ->AWS::Events::Rulewith an SFN target (PutTargetsvalidates the rule can assume the role toStartExecution); (4) a fresh principal role ->AWS::SQS::QueuePolicy+AWS::SNS::TopicPolicy(the resource-policy PUT validates the principal). Synth confirms each edge references its role viaFn::GetAtt <Role>.Arn. Everything is cheap (no VPC, no NAT, no long-lived compute); the EventBridge rule isenabled: falseon a 365-day schedule so it never fires (no cost) butPutTargetsstill validates the fresh role at create time. The pass condition is: deploy SUCCEEDS — a deploy failure is a real cdkd finding (an unprotected consumer racing IAM propagation), soverify.shprints WHICH resource failed plus the error (deploy-log tail +cdkd eventsper-resourceRESOURCE_FAILEDlines + the partial state'slogicalId -> typemap) for trivial triage, then still attempts destroy / cleanup. On success it also asserts each role consumer works (invoke the Lambda + assert its marker;start-execution-> polldescribe-executiontoSUCCEEDED, proving both the fresh SFN role AND the SFN->Lambda invoke grant;list-targets-by-ruleshows the SFN target bound to a role; the SQS queue + SNS topic each carry a non-empty resource policy), then destroys and asserts the Lambda / state machine / rule / queue / topic / state file are each GONE from AWS (perfeedback_protection_integ_must_instantiate_resource— state-empty alone can miss an orphan carrying no stack name).verify.shis BSD/macOS-portable (nogrep -P/date -d), recovers the real deploy exit code fromPIPESTATUSso atee'd non-zero is not masked, traps aggressively (state + lock + deployment-events sidecar), and prints[verify] PASSonly on full success. New scenario tagiam-fresh-role-immediate-assumein the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. The first real-AWS run of this fixture surfaced exactly the race it was built to find: edge (4) — the SQSQueuePolicy+ SNSTopicPolicyPUT against a brand-new principal role — failed because cdkd's fast SDK path issued the resource-policy PUT before IAM finished propagating the just-created role. CloudFormation never hits this (its deployment latency lets IAM settle), but cdkd does. The fix adds two narrowly-anchored retry patterns toRETRYABLE_ERROR_MESSAGE_PATTERNSinsrc/deployment/retryable-errors.ts:Policy Error: PrincipalNotFound(the SNSSetTopicAttributesrejection wording) andInvalid value for the parameter Policy(the SQSSetQueueAttributesrejection — the SQS QueuePolicy in the fixture is byte-for-byte the same fresh-principal document, so the less-specific SQS phrasing is the SAME propagation race, not a malformed document). Both are anchored on the full vendor phrase so a genuinely malformed / non-existent principal still burns only the bounded retries before surfacing — they do not false-positive unrelated SNS/SQS parameter errors. Twoverify.shassertion fixes landed alongside: the post-destroy StepFunctions check now polls forDeleteStateMachine's async deletion (the API returns before the state machine is gone), and the edge-2 SFN execution assertion retriesstart-execution->describe-executionacross the IAM-propagation window (the fresh SFN role can take a moment to be assumable for the SFN->Lambda invoke). Validated GREEN end-to-end against real AWS (/run-integ iam-propagation-stress): deploy clean across all four race edges, all four post-deploy consumer assertions OK, destroy reported 13 deleted / 0 errors, and the post-destroy orphan sweep was empty (0 orphans).✅ New
rds-full-stackinteg stresses a realistic single-instance RDS deployment +Fn::GetAttof a computed DB endpoint, plus a realsrc/fix enrichingAWS::RDS::DBInstanceEndpoint attributes on the Cloud Control path —tests/integration/rds-full-stack/**. The two existing RDS fixtures both use L1 (CfnDBInstance/CfnDBCluster) and target #609 silent-drop closure + theprovisionedBy=sdkrouting guard; neither uses an explicit DBSubnetGroup + DBParameterGroup pair on an L2rds.DatabaseInstance, and neither consumes a DBInstance's COMPUTED endpoint via a downstream reference. This fixture (CdkdRdsFullStackExample, 15 synthesized resources) closes that gap: a VPC (natGateways: 0, isolated subnets) + explicitrds.SubnetGroup+ explicitrds.ParameterGroup(Postgres 17.4, non-defaultapplication_name = cdkd-rds-full-stack) + explicit SecurityGroup + a smallrds.DatabaseInstance(db.t3.micro, single-AZ, 20 GiB gp2, CDK-managed Secrets Manager credentials,deletionProtection: false,RemovalPolicy.DESTROY, no final snapshot) + anssm.StringParameter(/cdkd/rds-full-stack/db-endpoint) whose value isFn::GetAtt(<Database>, Endpoint.Address)(synth-confirmed). The angle being stressed is cdkd's event-driven DAG + intrinsic resolution under a slow-create resource: cdkd must create the sub-groups + SG before the instance (Ref edges), wait ~5-10 min for the instance to become available, read its computedEndpoint.Addressattribute back, and only THEN create the SSM Parameter with the resolved value.verify.sh(BSD/macOS-portable — nogrep -P/date -d, real-rc capture, explicit[verify] PASS) deploys (dumping state on a deploy failure for triage), asserts the instance uses OUR custom subnet group + parameter group (DescribeDBInstancesDBSubnetGroup.DBSubnetGroupName/DBParameterGroups[]) and the group carries the non-defaultapplication_name(DescribeDBParameters), asserts the SSM parameter value equals the LIVEDescribeDBInstancesendpoint address (proving the computedFn::GetAttresolved post-create — an empty value would mean cdkd read the attribute before the instance was available), then destroys and asserts the instance, subnet group, parameter group, SSM parameter, and state file are all gone with 0 orphans. The cleanup trap deletes in the RDS-safe order (instance first +aws rds wait db-instance-deleted, then subnet / param groups, then SG / VPC viastate destroy). Physical ids are resolved from cdkd state (the auto-named groups) + the explicit SSM name. This integ is SLOW by RDS nature (~10-20 min end-to-end) — acceptable + expected. New scenario tagrds-full-stackin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. It also carries a real cdkdsrc/fix:src/provisioning/cloud-control-provider.tsenrichResourceAttributeshad noAWS::RDS::DBInstancecase, so a CC-API-routed DBInstance (routed via a #614 silent-drop property) never had its computedEndpoint.Address/Endpoint.Port/Endpoint.HostedZoneId/Arnattributes enriched after create — a downstreamFn::GetAtt(<db>, 'Endpoint.Address')resolved to the raw physicalId instead of the real endpoint. OnlyAWS::RDS::DBClusterwas enriched (per #381). The fix adds a parallel DBInstance case (DescribeDBInstances-> flat keysEndpoint.Address,Endpoint.Port,Endpoint.HostedZoneId,Arn; the DBInstanceEndpointis a nested{Address, Port, HostedZoneId}object, distinct from the DBCluster's flatEndpoint/ReaderEndpointscalars). Unit tests intests/unit/provisioning/cloud-control-provider.test.tscover the new case. Validated GREEN end-to-end against real AWS (/run-integ rds-full-stack): deploy clean, the computed-endpointFn::GetAttresolved to the live endpoint address, destroy reported 15 deleted / 0 errors, and the post-destroy orphan sweep was empty (0 orphans).✅
Fn::Joinover a list-returning intrinsic +AWS::NotificationARNspseudo-parameter resolves to empty string inFn::Sub/Ref(issue #838), plus a newintrinsics-tortureinteg that stress-tests cdkd's hand-rolled intrinsic-function resolver against the less-common + deeply-nested intrinsics —src/deployment/intrinsic-function-resolver.ts+tests/integration/intrinsics-torture/**. cdkd resolves EVERY CloudFormation intrinsic itself insrc/deployment/intrinsic-function-resolver.ts(unlike the CDK CLI, which defers them to CloudFormation), so the less-common intrinsics + deep nesting are exactly where cdkd is most likely to diverge — and the existingintrinsic-functionsfixture only exercised Ref / Fn::GetAtt / Fn::Join / Fn::Sub. The new failure-seekingintrinsics-tortureinteg surfaced two real bugs that the fix in this PR closes: (a) issue #838 —Fn::Joinover a list-returning intrinsic (e.g.Fn::Join['', Fn::Cidr[...]]): the resolver mapped each element of theFn::Joinsecond argument expecting it to already be an array, but when the second argument is itself a list-returning intrinsic (Fn::Cidr/Fn::GetAZs/Fn::Split) it had not yet been resolved to an array, so the.map()ran over the unresolved intrinsic object. The fix resolves the second argument FIRST and only then maps over the resulting list. (b)AWS::NotificationARNspseudo-parameter now resolves to an empty string inFn::Sub/Refinstead of leaving the literal${AWS::NotificationARNs}placeholder in the output — cdkd carries no CloudFormation notification-ARN list, and CloudFormation itself resolves this pseudo-parameter to an empty list (joined to an empty string in aFn::Subcontext), so emitting an empty string matches CloudFormation's own behavior rather than leaking an unresolved placeholder downstream. The newCdkdIntrinsicsTortureExamplestack is cheap (an SNS topic + SQS queue + tenAWS::SSM::Parameters; no VPC / NAT / Lambda) and computes each SSM parameter'sValuevia a harder intrinsic, built with the raw CFn escape hatch (new ssm.CfnParameter+addPropertyOverride('Value', <intrinsic>)) so the synth template carries the EXACT intrinsic shape under test. Coverage that goes BEYONDintrinsic-functions:Fn::Cidr(['10.0.0.0/16', 8, 8]carved into eight /24 blocks — bothFn::Select[3]=10.0.3.0/24and the fullFn::Joined list asserted — the list-returning-Fn::Joincase that exercises fix (a)),Fn::FindInMap(aMappingssection with a{Ref: AWS::Region}top-level key + a region-independentDEFAULTrow),Fn::GetAZs+Fn::Select[0](first AZ, computed the same way cdkd sorts the list),Fn::Base64, nestedFn::Split+Fn::Select+Fn::Join(expecta|c|e), deeply-nested two-argFn::Sub(literal-map var via a nestedFn::Join+${AWS::Region}+ a${<Queue>.Arn}GetAtt), and ALL pseudo-parameters (AWS::AccountId/AWS::Region/AWS::Partition/AWS::StackName/AWS::URLSuffix/AWS::NotificationARNs).verify.sh(BSD/macOS-portable — nogrep -P/date -d, real deploy-rc capture, explicit[verify] PASS) deploys, reads each parameter back viaaws ssm get-parameter, and asserts it equals an expected value computed independently in the script from the account / region — so a wrong resolution produces a wrong parameter value that pinpoints which intrinsic cdkd got wrong; a failed deploy prints the failing resource + error for triage. It then destroys and asserts clean (state.json gone + zero orphan SSM parameters). Thepseudoassertion pins the new behavior thatAWS::NotificationARNsresolves to an empty string insideFn::Sub(fix (b)), so a regression that changed it would flip the assertion. Unit tests intests/unit/deployment/intrinsic-functions.test.tscover both fixes (Fn::JoinoverFn::Cidr/Fn::GetAZs/Fn::Split, andAWS::NotificationARNs-> empty string inFn::Sub+Ref). New scenario tagintrinsics-torturein the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated; testing-guide entry added to docs/testing.md. Validated GREEN end-to-end against real AWS (/run-integ intrinsics-torture) and thebench-cdk-samplebroad integ passed clean in the same worktree.✅ An SQS Queue attribute that resolves away on an in-place UPDATE (e.g.
Fn::If→AWS::NoValue) is now CLEARED on AWS instead of leaving the stale value, plus the newconditions-update-2integ that surfaced it —src/provisioning/providers/sqs-queue-provider.ts+tests/integration/conditions-update-2/**. The siblingconditions-and-ifinteg surfaced bug #840 (a resource whoseCondition:flippedtrue → falseon redeploy was never deleted), and the fix (TemplateParser.filterResourcesByCondition) prunes condition-false resources from the effective template before the diff. But that narrow flip leaves the surrounding condition-on-UPDATE semantics — each a distinct way the prune step can still be wrong — without an end-to-end backstop. The newCdkdConditionsUpdate2Examplestack (cheap: 4xAWS::SSM::Parameter+ 2xAWS::SQS::Queue, no VPC / NAT) redeploys the SAME stack in place across a CDK-context phase flip (-c phase=a|b, the same param-flip mechanismconditions-and-ifuses since cdkd has no deploy-time--parameterflag) and asserts five cases against real AWS: (1) a resource that MOVES gating conditions —MoverParam(Condition: IsPhaseA, present in phase a) goes condition-false in phase b and must be DELETED (the #840 case re-asserted), plus its reverseAppearParam(Condition: IsPhaseB, ABSENT in phase a) is CREATED on the phase-b redeploy (absent → present); (2)Fn::If→AWS::NoValueremoving a NESTED property block on an in-place UPDATE — the case that surfaced THIS PR'ssrc/bug —WorkQueue.RedrivePolicy(a DLQ-target +maxReceiveCountJSON block) is SET in phase a and GONE in phase b on the SAME physical queue (QueueNameunchanged). cdkd's diff layer correctly classified the removed property as a change (itscomparePropertiesunions current + desired keys, so a key present in state but absent from the resolved desired template is detected), but the SQS provider'supdate()loop only acted on keys PRESENT in the new properties — so the staleRedrivePolicywas never cleared on AWS (the classic "providers only act on keys present in newProperties" gap fromfeedback_internal_contract_audit_first). The fix adds a removal branch toSQSQueueProvider.update(): a CDK-managed attribute present inpreviousPropertiesbut absent from the resolved desired properties is reset to its default viaSetQueueAttributes(a newSQS_ATTRIBUTE_REMOVAL_RESETmap — the JSON policy attributesRedrivePolicy/RedriveAllowPolicy+KmsMasterKeyIdclear to the empty string SQS documents for removal; numeric attributes reset to their documentedSetQueueAttributesdefaults), mirroring CloudFormation's reset-to-default-on-removal behavior. The branch is gated on the attribute being present inpreviousPropertiesAND in the reset map, so it never spuriously clears an attribute that was never set (e.g. a tag-only update issues noSetQueueAttributes), and immutable / FIFO-discriminated attributes (FifoQueue/DeduplicationScope/FifoThroughputLimit) are deliberately excluded. The fix is confined to the SQS provider — no shared / cross-provider code path changed, so other providers' update semantics are untouched. Unit tests intests/unit/provisioning/sqs-queue-provider-update.test.tscover the clear-on-removal (RedrivePolicy →""), the numeric-reset-on-removal (VisibilityTimeout →30), and the no-over-clear guard (attribute absent on both sides → noSetQueueAttributes). This exercises theprovider.update()drop-a-block path, not the create-time omissionconditions-and-if's SNSDisplayNamecovered; (3) a condition-gated OUTPUT — theMoverParamNameoutput (condition: IsPhaseA) is present in the cdkd stateoutputsmap in phase a and absent in phase b (asserted by reading the state file directly); (4) aDependsOnto a condition-EXCLUDED resource —KeeperParam(always present)DependsOn MoverParam, which is pruned in phase b, so cdkd must DROP the danglingDependsOn(like CloudFormation) and still deploy/updateKeeperParamin place (itsFn::Ifvalue flipskeeper-phase-a→keeper-phase-b); (5) aRefto a condition-excluded resource INSIDE a condition-false resource —RefHolderParam(Condition: IsPhaseA,ValueRefsMoverParamwhich is alsoIsPhaseA-gated) is pruned together withMoverParamin phase b, so the surviving template carries no danglingRefand the deploy must not crash.verify.sh(BSD/macOS-portable — nogrep -P/date -d, real exit codes, explicitAll N passedpass line) runs three phases (Phase A-c phase=apresence/SET/PRESENT, Phase B-c phase=bDELETE/CREATE/REMOVED/ABSENT, Phase C destroy + assert every named SSM parameter and SQS queue is NOT-FOUND on AWS and the state file is gone). New scenario tagconditions-update-semanticsinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ conditions-update-2before merge.)✅ A resource whose
Condition:flipstrue → falseon a redeploy is now DELETED (issue #840) —src/analyzer/template-parser.ts(newfilterResourcesByCondition) +src/deployment/deploy-engine.ts. The newconditions-and-ifinteg surfaced a real cdkd gap: CloudFormation does not strip condition-gated resources at synth time — CDK emits a resource carrying aCondition:key intoResourcesregardless of the condition's value (verified by synthing the fixture both ways:PremiumOnlyParamis present inResourceswithCondition: IsPremiumin BOTH-c tier=premiumand-c tier=basic, differing only in theTierparameter default that flips the condition). cdkd evaluated theConditionssection forFn::Ifresolution but NEVER consulted the resource-levelCondition:key — so a condition-false resource was created on first deploy AND never deleted when its condition flipped (it stayed in the desired set and diffed asNO_CHANGE). The deploy engine now prunes every resource whoseCondition:resolved tofalse(viaTemplateParser.filterResourcesByCondition) immediately after evaluating theConditionssection, so the whole downstream pipeline (type/property validation, DAG build, diff, provisioning) operates on the CFn-effective resource set: a condition-false resource is never created, and one present in prior state but condition-excluded from the effective template falls through the diff's existing "present in state, absent from desired → DELETE" path exactly as CloudFormation removes it. A resource whoseCondition:names an unevaluated/unknown condition is kept (treated as present, not silently dropped). Tests: 5 newfilterResourcesByConditionunit tests (tests/unit/analyzer/template-parser.test.ts— false→removed / true→kept / no-Condition→kept / unknown-condition→kept / preserves Conditions+Outputs and does not mutate input) + 1 new diff-calculator test (tests/unit/analyzer/diff-calculator.test.ts— resource in state but absent from the pruned template →DELETE). Closes the FAIL theconditions-and-ifinteg's Phase 2 asserts (PremiumOnlyParamABSENT after the basic redeploy). (NOTE: needs/run-integ conditions-and-ifagainst real AWS before merge.)✅ New
conditions-and-ifinteg stresses cdkd's own CloudFormation Conditions +Fn::Ifevaluation (test-only — nosrc/change) —tests/integration/conditions-and-if/**. cdkd evaluates theConditionssection + the resource-levelCondition:key +Fn::If/Fn::Equals/Fn::And/Fn::Or/Fn::NotITSELF (no CloudFormation engine underneath), but the existingconditionsfixture has noverify.shand only exercised a singleFn::And+ one conditionally-created S3 bucket + anFn::Ifbucket name — leaving condition-gated resource CREATION,Fn::If->AWS::NoValueproperty OMISSION, andFn::Or/Fn::Notwithout an end-to-end real-AWS backstop. The newCdkdConditionsIfExamplestack (cheap: 3xAWS::SSM::Parameter+ 1xAWS::SNS::Topic, no VPC / NAT) closes the gap. ItsConditionssection combinesFn::Equalson aCfnParameterwithFn::And(IsPremiumPrimary),Fn::Or(IsPremiumOrSecondary), andFn::Not(IsSecondaryRegion). It carries TWO resources with aCondition:key (PremiumOnlyParamon the bareFn::Equalscondition,PremiumPrimaryParamon theFn::Andcondition), an always-created parameter whoseValueis anFn::Ifbranch (TierLabelParam), and an SNS topic whoseDisplayNameisFn::If(IsPremium, 'Premium Notifications', AWS::NoValue)plus two tag values driven byFn::If. TheTierCfnParameterdefault is read from CDK context (-c tier=premium|basic) at synth — cdkd has no deploy-time--parameterflag, so flipping the context is the param-flip mechanism.verify.sh(BSD/macOS-portable — nogrep -P/date -d, real exit codes, explicit pass line) runs three phases against real AWS: Phase 1 (-c tier=premium) — asserts theFn::Ifproperty branch reached AWS (TierLabelParamValue ==tier-is-premium), both condition-gated parameters are PRESENT (aws ssm get-parameter), the SNSDisplayNameis SET toPremium Notifications, and theFn::Iftag values /Fn::Ortag value reached AWS; Phase 2 (-c tier=basic, redeploy in place) — asserts theFn::Ifbranch FLIPPED on AWS (tier-is-basic), both condition-gated parameters are now ABSENT (resource removed because itsCondition:went false), the SNSDisplayNameis genuinely OMITTED on AWS (Fn::If->AWS::NoValue), and the tag values flipped; Phase 3 — destroy + assert every named resource (each SSM parameter, the SNS topic) is NOT-FOUND on AWS and the state file is gone. 14 assertions total. New scenario tagconditions-and-ifinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ conditions-and-ifbefore merge.)✅
cdkd diff --recursiveno longer reports spurious changes on a freshly-deployed nested-stack tree, plus a newnested-stack-3levelinteg that surfaced it —src/cli/commands/diff-recursive.ts+tests/integration/nested-stack-3level/**. The new deep+bidirectional fixture surfaced a real recursive-diff bug: the walker built each nested child's intrinsic-resolver context from the child's deployedresourcesbut omitted the resolved inputParametersthat the deploy engine forwards to the child (NestedStackProvider.extractParameters->DeployEngineOptions.parameters). So a child property whose value derives from a DOWN-passed nested-stackParameter(CDK's synthesizedreferenceto<Parent>…input — e.g. the great-grandchild'sValue: Fn::Join['', ['…:', {Ref: referenceto…RootTopicName}]]) kept its raw intrinsic at diff time (theRefresolved to neither a resource nor a parameter), while cdkd state held the resolved string; the diff calculator'svaluesEqualthen saw "intrinsic vs concrete" and reported a phantomUPDATEon every freshly-deployed tree. The fix threads aparametersmap throughcomputeStackDiff(into the resolverResolverContext.parameters) and addsresolveChildStackParameters, which resolves each childAWS::CloudFormation::Stackrow'sProperties.Parametersagainst the PARENT's deployed state + already-resolved parameters (best-effort: an unresolvable value is omitted so the existing intrinsic-vs-resolved fallback still applies), then forwards the resolved scalar map as the child node'sparameters— exactly mirroring the deploy engine's parent->child forwarding so the recursive diff matches what deploy wrote to state. The bottom-upFn::GetAttoutput chain was already correct (it resolves againstattributes['Outputs.<key>'], which the diff path already supplied). Tests: 4 new unit tests intests/unit/cli/diff-recursive.test.ts(a freshly-deployed down-passed-parameter child diffs NO_CHANGE; a genuinely-changed resolved value still diffs UPDATE;computeStackDiffwith the parameter -> NO_CHANGE; without it -> the pre-fix spurious UPDATE, proving the resolution is load-bearing). The existing nested-stack fixtures stop short of the depth + breadth + reference-direction combinations where cdkd'sNestedStackProviderrecursion, the v6<parent>~<childLogicalId>state-key derivation, anddiff --recursive/state list --treeare most likely to drift:nested-stackis 1 level, andnested-stack-deepis 3 levels with exactly one SSM Parameter per level and bottom-upFn::GetAttoutputs only. This fixture (CdkdNestedStack3LevelExample) is a strict superset along three axes at once: (1) deeper — a 4-level tree (root →Child→Grandchild→GreatGrandchild, depth 3, one deeper thannested-stack-deep), so the${parentStackName}~${nestedLogicalId}join is exercised at theCdkdNestedStack3LevelExample~Child~Grandchild~GreatGrandchildkey; (2) wider — the grandchild is a BRANCHING node owning two own resources (anAWS::SNS::TopicAND anAWS::SSM::Parameter) alongside its nested-stack child, so the per-level DAG must order a sibling resource next to the nested-stack node, and a second resource TYPE participates; (3) bidirectional cross-level references — in addition to the bottom-upFn::GetAttoutput chain (great-grandchild param name bubbles up through grandchild → child → root'sRootRef), the rootRootTopicname is threaded DOWN all three boundaries as a synthesized nested-stackParameter(verified in synth: eachAWS::CloudFormation::Stackcarries areferenceto…RootTopic…TopicNameentry and the great-grandchild template declares the matching inputParameter), exercising cdkd'sNestedStackProviderParametersextraction +DeployEngineOptions.parametersforwarding — a pathnested-stack-deepnever touches.verify.sh(BSD/macOS-portable — nogrep -P/date -d, real exit codes captured to variables, explicitPASSonly on full success) asserts what the existing deep fixture does NOT: it reads each of the four level state files directly from S3 and asserts the v6parentStack/parentLogicalIdpoint one level up (root has neither), collects every SSM Parameter name + SNS Topic ARN from state and asserts each REAL AWS resource exists post-deploy (and that the downward root-topic name actually reached the great-grandchild's parameter value), runscdkd diff --recursiveclean (plus a changed great-grandchild value surfacing a[~]UPDATE under theNested stack: …~GreatGrandchildheader), assertscdkd state list --treerenders the 4-level hierarchy with box-drawing branches (great-grandchild nested under a branch, not a root row), then destroys and asserts the full cascade — every level's SSM Parameter / SNS Topic gone on AWS and every level's state file removed. New scenario tagnested-stack-deep-deploy-cascadein the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ nested-stack-3levelbefore merge.)✅
Fn::FindInMapnow supports the optional 4th-argument{ DefaultValue: <x> }(returns the default when the requested key is absent instead of throwing), plus the newintrinsics-torture-2integ that targets the HARDER intrinsic-resolution arg-shapes (sibling ofintrinsics-torture, which found bug #838) —src/deployment/intrinsic-function-resolver.ts+tests/integration/intrinsics-torture-2/. The first torture fixture surfaced thatFn::Joinover a list-returning intrinsic crashed (#838); this second fixture goes after the next tier of less-common / harder arg shapes insrc/deployment/intrinsic-function-resolver.ts, each feeding a REALAWS::SSM::Parameter.Value(written via the raw L1ssm.CfnParameter+addPropertyOverride('Value', <intrinsic>)escape hatch so the synthesized template carries the LITERAL intrinsic, not a CDK-pre-folded value), so a wrong / failed resolution is caught by reading the parameter back. Thesrc/fix it surfaced:resolveFindInMapdestructured only the 3 classic args ([MapName, TopLevelKey, SecondLevelKey]) and threwFn::FindInMap: top-level key '...' not found in mapping '...'whenever a requested key was absent — but CloudFormation's modern grammar accepts an optional 4th element{ "DefaultValue": <value> }and returns that default when the top-level OR second-level key is missing (validated against real AWS via theFindInMapDefaultresource in this fixture, which uses a deliberately-missing top-level key). The handler now accepts the 4th arg, resolves theDefaultValue(which may itself be an intrinsic, e.g.{Ref: AWS::Region}) lazily only when a key is missing, and returns it for every not-found case (no Mappings section / mapping / top-level key / second-level key absent); when noDefaultValueis supplied the existing throws are preserved byte-for-byte (backward compatible), and the present-key 3-arg path is unchanged. TheCdkdIntrinsicsTorture2Examplestack (1 SNS Topic + 10 SSM String parameters, no VPC / Lambda) exercises:Fn::Select[1, Fn::GetAZs('')]andFn::Select[0, Fn::Split(',', {Ref})](the Select analogue of the #838 resolveJoin-over-list-intrinsic bug class);Fn::FindInMapwith the enhanced 4th-arg{DefaultValue}AND a{Ref: AWS::Region}-driven top-level key (the default-value path this fix adds);Fn::GetAtt: [Topic, {Ref: AttrNameParam}]with aRef-valued attribute name (CFn allows it;resolveGetAtttreatsgetAtt[1]as a literal string); theFn::Sub${!Literal}escape (renders a literal${Literal});Fn::Base64of an intrinsic ({Ref}→ string → base64); a triple-nestedFn::If-in-Fn::Sub-in-Fn::Join; andFn::CidrIPv6 (2001:db8::/56→Select[0]=2001:db8:0:0:0:0:0:0/64, the resolver's uncompressed-group form) plus a 2nd IPv4 edge with a differentcidrBits(10.0.0.0/24, 4, 4→Select[2]=10.0.0.32/28).verify.sh(BSD/macOS-portable — nogrep -P/date -d, captures real exit codes, prints an explicitAll N checks passed) computes each expected concrete value in-script from account / region (AZ list viadescribe-availability-zones, base64 viabase64, topic ARN read from deploy outputs), deploys, asserts every SSM parameter equals its expected value FAILing by intrinsic name on mismatch, then destroys and asserts cdkd state + all SSM parameters are gone (no orphans); on a deploy failure (the likely outcome when a real resolver bug is hit) it prints triage context (cdkd state + the synth template's intrinsic blocks) before exiting non-zero. Skipped on purpose:Fn::ImportValuewith anFn::Subexport name (would require a sibling producer stack / export, making this a multi-stack fixture — out of scope for this single-stack torture fixture; the strong-ref import path is covered byimport-value-strong-ref);Fn::CidrIPv6 is INCLUDED (the resolver supports IPv6 viaresolveCidr'sisIpv6branch), so the IPv4-only-fallback alternative was unnecessary. Unit tests intests/unit/deployment/intrinsic-functions.test.tscover the 4th-arg fix (present key ignores DefaultValue / top-level-key-absent returns default / second-level-key-absent returns default / intrinsic-valued DefaultValue resolves / 3-arg key-absent still throws). New scenario tagintrinsic-hard-arg-shapesinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ intrinsics-torture-2.)✅ Retry KMS Key / key-policy create on fresh-principal IAM propagation, surfaced by the new
propagation-races-2integ —src/deployment/retryable-errors.ts+tests/integration/propagation-races-2/. cdkd's fast SDK path creates an IAM role and then, within ~1s, issues aKMS:CreateKeywhose key policy references that brand-new role principal; KMS rejects it withMalformedPolicyDocumentException: Policy contains a statement with one or more invalid principalsbecause IAM has not finished propagating the principal yet (CloudFormation never hits this — its deployment latency lets IAM settle, but cdkd does). Issue #839 added retry patterns for the SNS/SQS resource-policy consumers, but KMSCreateKey/PutKeyPolicyis a DIFFERENT consumer those patterns do not cover, so the KMS create failed hard instead of retrying through the propagation window. The fix adds the KMS invalid-principal phrase (Policy contains a statement with one or more invalid principals) toRETRYABLE_ERROR_MESSAGE_PATTERNS— the same array #839 extended. The KMS Key provider is a normal SDK provider whosecreate()runs under the deploy engine'swithRetry(it does NOT opt out viadisableOuterRetrylike the Custom Resource provider), so the message pattern alone is sufficient —withRetrycatches the classified-retryable error and retries the create through the propagation window. The phrase is anchored on the full KMS/IAM policy-document wording so it does not false-positive on unrelated KMS errors (a genuinely malformed key policy still burns only the bounded retries before surfacing). Unit tests intests/unit/deployment/retryable-errors.test.tscover the new pattern (the KMS invalid-principal message classifies retryable; a clearly-different KMS error stays non-retryable). Thepropagation-races-2fixture is the race detector that surfaced this (deploy SUCCESS is the pass condition). (NOTE: needs/run-integ propagation-races-2against real AWS before merge.)✅ New
propagation-races-2integ surfaces more fresh-principal / propagation-race edges (sibling to the original IAM-propagation stress integ that found #839) —tests/integration/propagation-races-2/. The original IAM-propagation stress integ exercised Lambda exec role / SFN role / EventBridge target / SQS+SNS resource policy and surfaced #839 (an SNS/SQS policy PUT not retried on a fresh-rolePrincipalNotFound); many sibling AWS APIs share the same race. This fixture (CdkdPropagationRaces2Example, 20 synthesized resources) probes four DISTINCT race edges the prior integ did not cover, each a NEW consumer of a resource created moments earlier in the SAME deploy: (1) IAM InstanceProfile -> EC2 Instance —RunInstancesvalidates the instance profile at launch (instance-profile propagation is the slowest IAM surface, often 5-10s+, so this is the highest-probability edge); the instance is a RAWec2.CfnInstance(L1) emitting only cdkd-handled top-level props (ImageId/InstanceType/SubnetId/SecurityGroupIds/IamInstanceProfile/Tags) so it stays on the SDK provider path (an L2 instance emitsAvailabilityZone, a silent-drop that flips it onto Cloud Control) in a minimal single-AZ no-NAT VPC; (2)AWS::Lambda::Permissiongranting a fresh S3 bucket source —AddPermissionvalidates the just-createdSourceArn+ function in one call; (3)AWS::S3::BucketPolicyreferencing a fresh IAM role principal —PutBucketPolicyvalidates the role principal (the classicInvalid principal in policyS3 race); (4)AWS::KMS::Keykey policy referencing a fresh IAM role principal —CreateKeyvalidates every principal in the key policy (this edge surfaced the KMS retry fix insrc/deployment/retryable-errors.tsabove). The PASS CONDITION is thatcdkd deploySUCCEEDS — the fixture is a RACE DETECTOR: if cdkd does not retry the fresh-principal propagation error for one of these edges, the deploy fails andverify.shprints which resource failed + the AWS error + thecdkd events --format jsonRESOURCE_FAILEDlines for triage. On success it asserts each resource actually works (instance running with the profile attached, Lambda invokable + resource policy grants the S3 source, bucket policy present + references the fresh role, KMS key Enabled + usable + policy references the fresh role), then destroys and asserts every NAMED resource is gone (EC2 instance terminated, instance profile / Lambda / both S3 buckets gone, KMS key PendingDeletion) by the fixture-ownedcdkd:integ-fixture=propagation-races-2tag / state-resolved physical ids — NOT the AWS-reservedaws:cdk:pathtag — plus a tag-scoped orphan-instance sweep that catches an orphan a state-resolved-id check would miss. The cleanup trap deletes in EC2-before-VPC order (terminate-instances+instance-terminatedwait so a lingering ENI does not block the SG/subnet) and is BSD/macOS-portable (nogrep -P/date -d, real exit codes captured, explicit=== PASS ===only on full success). New scenario tagfresh-principal-consumer-raceinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); the.scenarios.jsonalso tags the existingiam-policy-propagation-retry; coverage matrices regenerated. (NOTE: needs/run-integ propagation-races-2against real AWS before merge; the deploy step is the bug-finder.)✅
Fn::GetAttof an unenriched computed attribute resolved to the raw physicalId instead of the real attribute, plus a new failure-seekingdeep-getatt-chainsinteg for LONGFn::GetAttchains —src/deployment/intrinsic-function-resolver.ts+tests/integration/deep-getatt-chains/**. The fixture surfaced a real cdkd bug shared by two resource classes:intrinsic-function-resolver.ts'sconstructAttribute(the per-type fallback that synthesizes an attribute when the provider never populated it — e.g. on the Cloud-Control-API path, where attributes are not always captured) had no case forAWS::CloudWatch::CompositeAlarm(soFn::GetAtt(<CompositeAlarm>, 'Arn')fell through to the physicalId default and resolved to the alarm NAME, not its ARN) and no case forAWS::EC2::Instance(soFn::GetAtt(<Instance>, 'PrivateIp')resolved to the instance id, not the IP — breaking an ELBv2 IP-target registration withnot a valid IPv4 address). The fix adds both: CompositeAlarmArnis constructed deterministically from the physicalId (arn:<partition>:cloudwatch:<region>:<accountId>:alarm:<AlarmName>— the same:alarm:ARN shape as a metric alarm, so no AWS call is needed); EC2 InstancePrivateIp/PublicIp/PrivateDnsName/PublicDnsName/AvailabilityZonerequire a LIVEDescribeInstanceslookup (the IP is not derivable from the id), cached per (physicalId, attribute) like the file's other live-lookup cases. The resolver-level fix works regardless of SDK-vs-CC-API routing. The newCdkdDeepGetAttChainsExamplestack is a 5-deep chain (cheap; SNS / CloudWatch / SSM / IAM / Lambda only, no VPC, inline LambdaZipFile):A AWS::SNS::Topic(SDK)--TopicArn-->B AWS::CloudWatch::Alarm(SDK,AlarmActions[0])--AlarmName (Ref)-->C AWS::CloudWatch::CompositeAlarm(CC-API, no SDK provider registered;AlarmRule)--Arn-->D AWS::SSM::Parameter(SDK,Value=Fn::Subjoining C.Arn + B.Arn)--Name (Ref)-->E AWS::Lambda::Function(SDK, terminal multi-attributeFn::Subenv pulling A.TopicArn + C.Arn + D(Ref) at once). The critical link is C:AWS::CloudWatch::CompositeAlarmis unregistered, so cdkd routes it via Cloud Control API and itsArnnow comes from the newconstructAttributecase rather than falling through to the physicalId default.verify.sh(BSD/macOS-portable — nogrep -P/date -d— real rc capture viaPIPESTATUS, explicit[verify] PASS) deploys, reads each upstream resource's REAL attribute back from AWS as ground truth, and asserts per-link: alarm B'sAlarmActions[0]== A's realTopicArn; composite C'sAlarmRulereferences B's real name; SSM param D'sValue==composite=<C.Arn>;alarm=<B.Arn>built from the REAL ARNs (the CC-API → SDK hop); and Lambda E's env resolvesUPSTREAM_TOPIC_ARN/UPSTREAM_COMPOSITE_ARN/UPSTREAM_PARAM_NAME/UPSTREAM_JOINEDto the real upstream attributes — a mismatch fails with a message naming the broken link. It then destroys with--forceand asserts every named resource is gone (each carries an OWNcdkd:integ-fixturetag; assertions resolve by name / state — neveraws:cdk:path) plus the S3 state file removed. The EC2 Instance live-lookup path is integ-covered by the siblingdeletion-ordering-complexfixture (an ELBv2 IP-target group registered with a Lambda-less EC2 instance'sPrivateIp). Unit tests intests/unit/deployment/intrinsic-functions.test.tscover the CompositeAlarm ARN construction and the EC2PrivateIplive-lookup (mockedDescribeInstances). New scenario tagdeep-getatt-chain-resolutioninKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated (the fixture also newly registersAWS::CloudWatch::CompositeAlarmin the integ-coverage matrix). (NOTE: needs/run-integ deep-getatt-chainsagainst real AWS before merge.)✅ New
sdk-ccapi-crossrefinteg surfaces bugs at the SDK-Provider <-> Cloud Control API cross-reference boundary (test-only — nosrc/change) —tests/integration/sdk-ccapi-crossref/. cdkd's #614 routing flips an SDK-registered resource to the generic Cloud Control API path the moment its template sets a top-level property the SDK Provider would silently drop, which bypasses the SDK Provider'screate()/delete()entirely — typed attribute writes never happen and the physical id becomes whatever CC API returns.Fn::GetAtt/Refreferences crossing that SDK <-> CC seam (and CC API's physical-id shapes) are a documented fragile area (memoryfeedback_silent_drop_forces_cc_api_routing+feedback_cc_api_routing_bypasses_sdk_delete_logic), but no integ exercised it directly. The newCdkdSdkCcApiCrossrefExamplestack forces a heterogeneous routing mix in ONE stack (no VPC / NAT):KinesisStream(AWS::Kinesis::Stream, silent-dropDesiredShardLevelMetrics) andCcLambda(AWS::Lambda::Function, silent-dropRuntimeManagementConfig) auto-route via CC API, whileExecRole(AWS::IAM::Role) andStreamArnParam(AWS::SSM::Parameter) stay on the SDK path. The routing was confirmed against this cdkd version with thefindActionableSilentDropsregistry helper on the synthesized template before finalizing. It crosses the boundary withFn::GetAttin both directions: (A) SDK -> CC —StreamArnParam.Value = Fn::GetAtt(KinesisStream, 'Arn')(an SDK-routed consumer reading a CC-routed producer's attribute, whose CC physical id is the stream NAME not the Arn); (B) CC -> SDK —CcLambda.Role = Fn::GetAtt(ExecRole, 'Arn')(a CC-routed consumer reading an SDK-routed producer's attribute).verify.sh(BSD/macOS-portable, real-rc capture + explicit[verify] PASS) deploys, asserts from state that the two silent-drop resources areprovisionedBy: 'cc-api'and the other two are'sdk'(proves the mixed routing), asserts cross-ref A (the SSM parameter's value on AWS equals the real Kinesis stream ARN), asserts cross-ref B (the Lambda's configured role on AWS equals the real IAM role ARN), asserts the silent-dropRuntimeManagementConfig.UpdateRuntimeOnreached AWS (FunctionUpdate— the CC route forwarded the full property map), thencdkd destroy --force(which exercises the CC delete path that bypasses the SDK providerdelete()for the stream + Lambda) and asserts every named resource (stream / function / role / parameter) and the state file are gone. New scenario tagsdk-ccapi-crossref-boundaryin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ sdk-ccapi-crossrefbefore merge.)✅ New
custom-resource-getatt-datafailure-seeking integ for the Custom Resource response-Dataattribute path consumed viaFn::GetAttinto a dependent resource (refs #756 / #804, test-only — nosrc/change) —tests/integration/custom-resource-getatt-data/**. The existingcustom-resource-providerfixture exercises the CDK Provider framework's asyncisCompleteHandlerpolling path but only asserts the CR's own output; it does NOT prove that a CR's responseDataflows THROUGH cdkd's intrinsic resolver INTO another resource's property — the fragile path (#756 / #804: CRDataattributes only exist after the CR's backing Lambda runs and returns SUCCESS, so a resolver / DAG-ordering bug there is silent unless a dependent's value is read back). The newCdkdCrGetAttDataExamplestack closes that gap with the cheapest shape: an inline NodeJS Lambda-backedAWS::CloudFormation::CustomResourcewhose handler returnsData: { ComputedValue: 'computed-integ', Another: 'another-<region>', NumericValue: '42' }directly in its response payload (the simple synchronous path, no Provider framework / VPC), consumed by THREEAWS::SSM::Parameterdependents whoseValueisFn::GetAtt(MyCustomResource, '<key>')— one per Data key (multiple keys catch a resolver that only wires the first attribute; the stringified-number key catches non-text-Data mishandling), with an explicitaddDependency(cr)on one parameter to exercise the DAG ordering (CR must complete + haveattributespopulated before the dependent provisions).verify.sh(BSD/macOS-portable, real-rc capture + explicit=== PASS ===) deploys, then reads each SSM parameter back from AWS (aws ssm get-parameter) and asserts itsValueequals the value the CR handler returned — the load-bearing check that the CRData.<key>attribute resolved into the dependent property (a blank/wrong value would otherwise pass unnoticed). It also cross-checks the resolvedComputedValueinstate.outputs, then destroys and asserts the state file, all three SSM parameters, and the backing Lambda (resolved by physical id from state) are gone. New scenario tagcustom-resource-getatt-datainKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ custom-resource-getatt-databefore merge.)✅ Fix: retry AWS throttling rejections that surface as HTTP 400 (not 429) by error NAME + new
throttle-wide-dagfailure-seeking integ —src/deployment/retryable-errors.ts+tests/integration/throttle-wide-dag/. Bug: cdkd's transient-error classifier (isRetryableTransientError) only treated HTTP 429 / 503 + a fixed message-pattern table as retryable. Most AWS throttles, however, surface as HTTP 400 with the throttling signal carried only in the error code / name (e.g. SSMPutParameterrejects a burst withThrottlingException/Rate exceeded. Ensure you have the high-throughput setting enabled ...at status 400), so cdkd'swithRetrynever retried them and the deploy failed. Because cdkd's event-driven DAG dispatches with no level barrier, a wide stack at a high--concurrencyfires a large create burst that trips a per-service rate limit — a NON-DETERMINISTIC subset of resources fails each run (the AWS SDK's own 3 fast internal retries are not enough to drain the burst). Fix: add name-based throttle detection — a newTHROTTLING_ERROR_NAMESset (mirrors the AWS SDK v3@aws-sdk/service-error-classificationTHROTTLING_ERROR_CODES) checked against the error AND its wrapped.causechain (the original SDK error is one cause-link deep under cdkd'sProvisioningError), plus aRate exceededmessage-pattern backstop. cdkd's outerwithRetry(1s/2s/4s/8s backoff, deeper than the SDK's) then spreads the remaining creates out until the rate window clears. The newthrottle-wide-dagfixture deploys a ~100-resource stack (80 SSM Parameters + a 10-deepFn::Subchain + 10 IAM Roles + 10 SNS Topics) at--concurrency 40to force the burst, and asserts the deploy SUCCEEDS (throttles retried, not fatal), all 100 resources reach AWS, the chained parameters are created in DAG order, and destroy is clean with 0 orphans. The fixture's own post-deploy AWS assertion calls runAWS_RETRY_MODE=adaptive/AWS_MAX_ATTEMPTS=10so they survive the same SSM rate window the test deliberately induces. 6 new unit tests intests/unit/deployment/retryable-errors.test.ts(name on the error; name one cause-link deep;Rate exceededmessage backstop; other canonical names; non-throttling 400 not retried; cyclic-cause-chain safety). Validated against real AWS (/run-integ throttle-wide-dag— deploy + destroy clean, 0 orphans; the pre-fix binary failed the same deploy non-deterministically). Surfaced by the 2026-06-13/14 failure-seeking integ campaign.✅ New
tags-propagationfailure-seeking integ verifies STACK-LEVEL tag propagation across many taggable types on BOTH the SDK-provider and Cloud Control API paths (test-only — nosrc/change) —tests/integration/tags-propagation/**.cdk.Tags.of(app).add(k, v)injects the same tags into the CFnTagsproperty of every taggable resource, and cdkd must forward them to AWS correctly per type — but each AWS type accepts tags in a DIFFERENT wire shape ({Key,Value}[]list vs{ k: v }map vs the CC-API forwarder), and the per-type tag handling lives in each provider independently, so a bug in one type's tag handling is invisible to the others. NotablyAWS::SSM::Parameter.Tagsis a CFn MAP (not the list almost every other type uses) — the historicalproperties.Tags.map()deploy-crash type recorded infeedback_ssm_parameter_tags_is_a_map, which hid because the unit tests + the only SSM-tag fixtures used the wrong (list) shape. The newCdkdTagsPropagationExamplestack applies 3 stack-level tags (CdkdTagOwner/CdkdTagEnv/CdkdTagCostCenter, set viacdk.Tags.of(app)inbin/app.ts) to 9 taggable resources spanning both provisioning paths: SDK path — S3 Bucket, SNS Topic, SQS Queue, SSM Parameter (the MAP type), IAM Role, Logs LogGroup, Lambda Function, DynamoDB Table; Cloud Control API path — Athena WorkGroup (no SDK provider, so cdkd routes it through CC, which forwards the full CFnTagsproperty). No VPC / NAT / instances — every resource is control-plane-only and cheap.verify.sh(BSD/macOS-portable — nogrep -P/date -d, realcdkdexit codes captured,jqfield extraction,[verify] PASSonly on full success): (1) deploys (a wrong-Tags-shape crash on any type fails here with specifics); (2) readsstate.jsonand asserts the routing split — Athena WorkGroupprovisionedBy == 'cc-api', the other 8'sdk'; (3) for EACH of the 9 types reads the live AWS-side tags via that type's type-specific list/describe API (s3api get-bucket-tagging/sns list-tags-for-resource/sqs list-queue-tags/ssm list-tags-for-resource --resource-type Parameter/iam list-role-tags/logs list-tags-for-resource/lambda list-tags/dynamodb list-tags-of-resource/athena list-tags-for-resource) and asserts ALL 3 stack-level tags landed with the correct value — a type missing a tag (a dropped tag) FAILs the run NAMING the type; (4)cdkd driftimmediately after deploy must report exit 0 — a tag-list reorder from AWS must not show as a false-positive drift (issue #802canonicalizeTagListsDeep), nor a map-vs-list readback-shape mismatch; (5)cdkd destroy --forcecleans up. New scenario tagstack-level-tag-propagation-multitypein the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ tags-propagationbefore merge.)✅ New
getstackoutput-crossregioninteg exercises cdkd's UNIQUE same-account / CROSS-REGIONFn::GetStackOutputintrinsic (test-only — nosrc/change) —tests/integration/getstackoutput-crossregion/**. cdkd'sFn::GetStackOutputcan read a producer stack's output from a DIFFERENT region than the consumer's deploy region (the architecture doc claims this works out of the box because the cdkd state bucket is account-scoped, not region-scoped — the resolver readscdkd/{Producer}/{producerRegion}/state.jsonfrom the same bucket the consumer state lives in), but that path was under-tested: the existingcross-stack-references/schema-v7-to-v8-migrationfixtures both resolveFn::GetStackOutputonly WITHIN one region (noRegionargument). The new fixture is a two-stack, one-app pair pinned to different regions viaenv.region: a PRODUCER (CdkdGsoProducer,us-west-2) SSM parameter whose ARN is exported viaCfnOutputProducerArn, and a CONSUMER (CdkdGsoConsumer,us-east-1) SSM parameter whoseValueis{Fn::GetStackOutput: {StackName, OutputName: ProducerArn, Region: us-west-2}}(injected viaaddPropertyOverride—aws-cdk-libships no typed helper). The producer/consumer are SEPARATEcdkd deploycalls with different--region.verify.sh(BSD/macOS-portable — nogrep -P/date -d, captures real exit codes, prints an explicit final PASS line): (1) deploys the producer inus-west-2, asserts its state lands at theus-west-2region-prefixed key and itsProducerArnoutput ARN carries the:us-west-2:segment; (2) deploys the consumer inus-east-1, asserts its state lands at theus-east-1key (a failed cross-region read fails the deploy here with "stack not found in region 'us-west-2'"); (3) the LOAD-BEARING assertion — the consumer's SSM parameter on AWS (inus-east-1) must equal the producer's REAL output value (fromus-west-2), proving the cross-region read worked AND resolved the CORRECT value, plus a belt-and-suspenders check that the resolved value names the PRODUCER's region (guards against a silent same-region fallback); (4) destroys consumer-first then producer (Fn::GetStackOutputis a weak reference, so order is not strictly required — mirrors recommended order), asserting both AWS resources AND both region-prefixedstate.jsonfiles are gone. The cleanup trap drops both regions' SSM parameters + both region-prefixedstate.json/lock.jsonsidecars from the account-scoped bucket, so a failed run leaves nothing behind in either region. New scenario taggetstackoutput-cross-regionin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); the fixture's.scenarios.jsonalso tags the existingmulti-stack-getstackoutput; coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ getstackoutput-crossregionbefore merge.)✅ New
eventsourcemapping-racefailure-seeking integ forAWS::Lambda::EventSourceMappingcreated against a FRESH source + role in the same deploy (test-only — nosrc/change) —tests/integration/eventsourcemapping-race/**. cdkd's event-driven DAG dispatches each resource the instant its own deps complete with NO level barrier, so anAWS::Lambda::EventSourceMappingthat references a queue, function, and execution-role all created in the SAMEcdkd deploycan fireCreateEventSourceMappingbefore AWS has propagated them — surfacing asInvalidParameterValueException("Cannot access queue" / "provided role ... does not have permissions" / "Function not found"). No existing fixture exercised an ESM against a fresh SQS source on the create-race-prone path with a delivery assertion + a redeploy-orphan guard. The newCdkdEsmRaceExamplestack is cheap (onesqs.Queue+ one inline-Pythonlambda.Function+ the synthesized ESM wiring them viaSqsEventSource; no VPC, no KMS; synth confirms exactly oneAWS::Lambda::EventSourceMappingwithEventSourceArn: Fn::GetAtt[queue, Arn],FunctionName: Ref[fn],Enabled: true).verify.sh(BSD/macOS-portable — nogrep -P, nodate -d; captures the real deploy exit code and prints an explicitPASSEDline) runs: (1) a PRE-FLIGHT orphan scan per therun-integskill —list-event-source-mappingsfiltered by the stack name aborts with cleanup commands if a prior killed run left an out-of-state ESM that would collide on the next CREATE (ResourceConflictException), the orphan-ESM-on-redeploy class; (2) deploy succeeds (no fresh-source/role race) — on failure it prints the deploy output and greps the ESM-specific error lines (InvalidParameterValue/does not have permissions/Cannot access/Function not found/ResourceConflict); (3) the ESM exists + reachesState=Enabled(get-event-source-mappingpoll + a cross-check thatlist-event-source-mappings --event-source-arn <queue>returns the UUID); (4) the wiring actually delivers — sends a probe message to the queue and polls the Lambda's CloudWatch logs for the handler'sCDKD_ESM_PROCESSED <body>marker (proves queue -> ESM -> Lambda delivery, not just that the mapping was created); (5) destroy is clean — NO orphan ESM survives (list-event-source-mappingsby function is empty), the queue is gone, and the state file is gone. An EXIT trap deletes any leftover ESM + queue + state on every exit path. New scenario tageventsourcemapping-fresh-source-raceinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ eventsourcemapping-racebefore merge.)✅ New
update-policy-mutationsinteg surfaces UPDATE-time handling ofDeletionPolicy/UpdateReplacePolicy/DependsOnchanges + metadata-only no-ops + orphan-on-replace (test-only — nosrc/change) —tests/integration/update-policy-mutations/**. The existing fixtures cover property mutations (update-replace) and a single-deploy Retain skip (deletion-policy-retain), but NONE exercise a template-level ATTRIBUTE that changes across two deploys — a likely-under-tested cluster indiff-calculator.ts'scompareAttributes+deploy-engine.ts's Retain-on-replace / DeletionPolicy destroy-skip paths. The newCdkdUpdatePolicyMutationsExamplestack drives two deploys via a CDK context flip (-c phase=athen-c phase=b, read at synth time) and asserts four edge cases against real AWS: (1)UpdateReplacePolicy: Retainorphan-on-replace —RetainReplaceBucket(AWS::S3::Bucket,RemovalPolicy.RETAIN) changesBucketName(in the replacement-rules registry) so phase-b forces a REPLACEMENT; verify.sh asserts the OLD physical bucket is RETAINED on AWS while the new one is created; (2)DeletionPolicyflip —PolicyFlipParam(AWS::SSM::Parameter) flipsRemovalPolicy.DESTROY->RETAINbetween phases (value unchanged, so the only diff is the attribute); the FINAL destroy must honor the current Retain policy and leave it on AWS; (3)DependsOnadd/remove — two SNS-topic pairs (DependsOnAdd{A,B}gains a DependsOn in phase b;DependsOnRemove{A,B}loses one) must update successfully with both topics keeping their physical id (ARN) — a metadata-only change must NOT trigger replacement; (4) no-op — an identical redeploy must reportNo changes detected. The test INTENTIONALLY creates orphans (the Retain-replaced old bucket + the phase-b Retain bucket + the phase-b Retain SSM param survive destroy); the verify.sh trap deletes EVERY captured / deterministic physical id (both bucket phases + both SSM params) and the final step asserts 0 leftovers via direct AWS API checks. BSD/macOS-portable (nogrep -P/date -d), captures real rc, printsAll update-policy-mutations checks passedonly on full success. SNS ARNs are captured fromcdkd state show --json. New scenario tagupdate-policy-mutationsin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ update-policy-mutationsbefore merge.)✅ New
importvalue-chainfailure-seeking integ for TRANSITIVE 3-stackFn::ImportValuechains (test-only — nosrc/change) —tests/integration/importvalue-chain/**. Goes deeper than the two existing cross-stack fixtures:cross-stack-references(1 producer + 1 consumer;Fn::ImportValuevsFn::GetStackOutputside by side) andimport-value-strong-ref(1 producer + 1 consumer; strong-ref refusal + schema v3→v4 migration story) both stop at a single producer→consumer edge. This fixture is a 3-stack chain A→B→C where the middle stack BOTH imports AND re-exports: Stack A (CdkdImportChainA) is an SNS Topic exportingChainTopicArn; Stack B (CdkdImportChainB) importsChainTopicArn(stored in an SSM Parameter) AND derives a value from it viaFn::Sub(derived::<topicArn>::from-b) which it re-exports asChainDerivedValue; Stack C (CdkdImportChainC) importsChainDerivedValue. So C's value transitively depends on A's export through B — the link neither existing fixture exercises. Resources are cheap (SNS + SSM only, no VPC).verify.sh(BSD/macOS-portable, real exit codes captured per step, explicitAll importvalue-chain smoke tests passed): (1)deploy --all(DAG must order A→B→C); asserts B's SSM Parameter holds A's REAL SNS topic ARN (exact match when the topic is enumerable, ARN-shape otherwise), C's SSM Parameter holds B's derived value with the embedded ARN equal to what B imported (full transitive chain), and the exports index carries BOTHChainTopicArn+ChainDerivedValue(withChainTopicArn's indexed value matching B's import). (2) Error path — deploying C with--exclusivelyagainst a throwaway--state-prefix(no producers) must fail with a clearnot found/exporterror namingChainDerivedValue(cdkd does not silently resolve a dangling token).--exclusivelyis load-bearing: likecdk deploy, a barecdkd deploy <stack>deploys the stack's DEPENDENCY CLOSURE, so without it cdkd would deploy A→B→C on the fresh prefix (producing the import) and B's account-global SSM Parameter name would collide with the main chain's still-live B parameter — masking the missing-export path. With--exclusivelyonly C is deployed, so itsFn::ImportValuegenuinely has no producer and fails at resolution before any resource is created. (3) Chained strong-ref protection — destroying B while C imports it is refused (Cannot destroy stack, namesCdkdImportChainC+ChainDerivedValue); destroying A while B imports it is refused (namesCdkdImportChainB+ChainTopicArn). (4) Ordered teardown — destroy C→B→A; each succeeds once its consumer is gone; asserts state gone for all 3, the named SSM Parameters + the SNS topic NOT-FOUND in AWS (state-empty can miss an orphan carrying no stack name — assert the real resources directly, perfeedback_protection_integ_must_instantiate_resource), and the exports index purged of both exports. An EXIT trap tears down both the main and fresh prefixes consumer-first and sweeps the SSM params. Reuses the existingmulti-stack-importvalue-strong-refscenario tag (the chain exercises the sameFn::ImportValuestrong-ref + exports-index machinery transitively, so no newKNOWN_SCENARIOSentry); coverage matrices regenerated. Validated against real AWS (/run-integ importvalue-chain— full chain + error-path + strong-ref + ordered teardown clean, 0 orphans).✅ Hardened post-destroy assertions across three new behavior-class integ fixtures so a silently-skipped delete cannot pass as clean (issue #831, test-only — no
src/change) —tests/integration/{update-replace,destroy-interrupt,deployment-events}/verify.sh. Code review of the newupdate-replaceinteg (PR #830) surfaced a hardening gap shared by several of the new fixtures: their post-destroy assertions checked only thatstate.jsonwas gone (and S3 buckets), but did NOT explicitly assert that the named NON-bucket resources (Lambda / IAM Role / SecurityGroup / SNS Topic) were actually deleted from AWS. Per thefeedback_protection_integ_must_instantiate_resourcerule ("state-empty misses an orphan carrying no stack name"), astate destroythat silently skips a resource (e.g. an SG delete blocked by a lingering ENI, or an IAM role that is not VPC-bound and orphans independently) would leave a real orphan while the test still passed. The fixtures now assert each named resource is NOT-FOUND in AWS after destroy:update-replace— the Lambda (WorkerFn,aws lambda get-function-configuration), IAM Role (WorkerRole,aws iam get-role), and SecurityGroup (WorkerSg,aws ec2 describe-security-groups) physical ids captured from state in Phase 1 must each error post-destroy, on top of the two existing bucket checks; plus a belt-and-suspendersaws s3 rb --forceof the predictablecdkd-update-replace-{account}-{region}-v1/-v2bucket names in the cleanup trap so a re-run is not blocked after a mid-replacement crash that left a v1/v2 bucket behind.destroy-interrupt— already resolved the backing Lambda + VPC (covering subnets/SG/ENI implicitly) + each SSM parameter id from state; added EXPLICIT not-found assertions for the CR handler's IAM Role (aws iam get-role— NOT VPC-bound, so the VPC-gone assert did NOT cover it) and the Lambda SecurityGroup (aws ec2 describe-security-groups— belt-and-suspenders on top of the VPC-gone implication).deployment-events— already asserted the SSM parameter gone; added an SNS Topic not-found assertion (aws sns get-topic-attributesagainst the deterministic${STACK}-topicARN resolved viasts get-caller-identity) plus a matching topic delete in the failure-cleanup path. All three scripts stay BSD/macOS-portable (nogrep -P/date -d), usejq has()for boolean probes, capture real exit codes, and print[verify] PASSonly on full success — following the own-cdkd:integ-fixture-tag pattern already used byrollback-failure-injection(these three resolve physical ids from cdkd state instead, which is equally reliable and needs nolib/change). Resource TYPES are unchanged, so the integ-coverage / scenario-coverage matrices regenerated with no diff. (NOTE:update-replaceanddeployment-eventsneed a real-AWS re-run to validate the new assertions;destroy-interruptadds only two cheap not-found checks to an already-passing fixture.)✅ New
replacement-fanoutinteg stresses replacement propagation (issue #807) at FAN-OUT scale —tests/integration/replacement-fanout/(new fixture; nosrc/change). #807 fixed the basic replacement-propagation case (promoteReplacementDependentsinsrc/analyzer/diff-calculator.ts: a dependent whose only "change" is aRef/Fn::GetAtt/Fn::Subto a to-be-REPLACED resource is promotedNO_CHANGE→UPDATEso it re-points at the new physical id), and it was verified end-to-end with a single dependent (the ECSService→ replacedTaskDefinitioncase inecs-fargate). A single-dependent test cannot surface a PARTIAL-propagation gap. This fixture exercises the same propagation with one base resource referenced by many (10) dependents:CdkdReplacementFanoutExampleis anAWS::SNS::Topic(BaseTopic) with an explicit, phase-keyedTopicName(cdkd-replacement-fanout-{region}-a), referenced by 10AWS::SSM::Parameters whoseValueisFn::Sub("arn=<topicArn>|idx=N", { arn: Ref(BaseTopic) })(SNSRefresolves to the topic ARN) plus anAWS::SNS::TopicPolicywhose policyResourceis the topic ARN. A-c phase=a|bflip (read at synth time) renames the topic-a→-bon phase b;TopicNameis in the SNS entry of the replacement-rules registry, so the rename forces delete + recreate → a NEW topic ARN. The 10 parameters are auto-named, so they keep their physical id and take an in-placeValueupdate — only the embedded ARN changes.verify.sh(BSD/macOS-portable — nogrep -P/date -d, real exit codes captured to variables, explicit[verify] PASS): (1) deploy-c phase=a, capture the base ARN + every dependent's AWS-resolvedValue(baseline asserts all embed the phase-a ARN, TopicPolicy references it); (2) redeploy-c phase=b, assert the base ARN CHANGED (old topic gone, new present) AND every dependent re-resolved to the NEW ARN on AWS — a dependent left on the stale phase-a ARN FAILS naming its index (#807 fan-out gap), each parameter keeps its physical id, and the TopicPolicy re-points at the new ARN with the stale one absent; (3) destroy and assert the state file, base topic, and all 10 parameters are gone (per-index orphan attribution). New scenario tagreplacement-fanout-propagationinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ replacement-fanoutbefore merge.)✅ Fix: parse the SecretsManager whole-secret dynamic-reference form + new
secrets-dynamic-reffailure-seeking integ for CloudFormation dynamic references ({{resolve:secretsmanager:...}}/{{resolve:ssm:...}}) —src/deployment/intrinsic-function-resolver.ts+tests/integration/secrets-dynamic-ref/. Bug: the whole-secret form{{resolve:secretsmanager:SECRET_ID:SecretString}}(and:SecretBinary) with no trailing json-key/version segments was mis-parsed —resolveSecretsManagerReferenceonly matched the mid-string delimiter:SecretString:/:SecretBinary:(with a trailing colon), so the end-anchored whole-secret form fell through. Fix: add an end-anchoredendsWith(':SecretString')/endsWith(':SecretBinary')fallback that fires only when the mid-string delimiter is absent AND the segment is at the very end, so aSECRET_IDthat merely contains:SecretStringmid-name is not split incorrectly. cdkd resolves dynamic references itself inresolveDynamicReferences(src/deployment/intrinsic-function-resolver.ts) BEFORE the property reaches the provider, and that code path had unit coverage only — no real-AWS integ proved that the deployed resource actually carries the RESOLVED value rather than the literal{{resolve:...}}token (which is how the whole-secret bug stayed latent). The fixtureCdkdSecretsDynamicRefExample(cheap, no VPC) deploys a SecretsManager secret with a KNOWN JSON value ({"username":"cdkd-user","password":"cdkd-known-pw-123"}), an SSMStringparameter with a KNOWN value (cdkd-known-ssm-value), and a consumer inline-codeAWS::Lambda::Functionwhose ENVIRONMENT VARIABLES are literal{{resolve:...}}strings (CDK emits them asFn::Joinarrays interpolatingAWS::AccountId; cdkd'sresolveJoinre-runsresolveDynamicReferenceson the joined result). Forms exercised — and which cdkd supports: secretsmanager JSON-key (:SecretString:password) SUPPORTED, secretsmanager whole-secret (:SecretString) SUPPORTED, secretsmanager version-stage (:SecretString:password:AWSCURRENT) SUPPORTED,ssm:<name>plaintext param SUPPORTED.ssm-secure:<name>is intentionally OUT OF SCOPE / SKIPPED (cdkd's resolver routes onlysecretsmanager+ssm;ssm-securehits the else branch → warn + leave literal → broken deploy), and the secret VERSION-ID slot is not exercised (the version id is not knowable ahead of deploy; the version-stage slot covers the optional-trailing-field grammar).verify.sh(BSD/macOS-portable, real-rc capture, explicit final PASS line) deploys, reads the consumer Lambda's env vars viaGetFunctionConfiguration, and asserts each env var is (a) NOT still a literal{{resolve:...}}token AND (b) equals the known expected value — a wrong-or-literal value FAILS with specifics; then destroys and asserts the Lambda, secret (force-deleted, sodescribe-secretis NotFound), SSM parameter, and state file are all gone. SECURITY: secret-derived values are never printed — assertions mask them (xx***(len=N)); only PASS/FAIL plus a masked snippet appears in the log. New scenario tagdynamic-reference-resolutioninKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. Validated against real AWS (/run-integ secrets-dynamic-ref— deploy + destroy clean, 0 orphans) plus abench-cdk-samplebroad integ for the cross-cutting resolver change.✅ New
s3-asset-deployinteg exercises the S3 file/ZIP asset-publishing path during a realcdkd deploy—tests/integration/s3-asset-deploy/**. Most Lambda fixtures use inline code (Code.fromInline) or never assert the asset upload itself, leaving cdkd'sFileAssetPublisher(S3 zip + upload, content-addressed skip-if-exists) without a dedicated end-to-end regression. The newCdkdS3AssetDeployExamplestack closes that gap: a Lambda whose code comes from a local multi-file directory (lambda/— handler +helpers/+vendored/Python sub-packages, so the asset is a genuine multi-file ZIP, not a trivial single file) forces cdkd to zip the directory + upload it to the CDK bootstrap asset bucket and wire the function'sCode.S3Bucket/Code.S3Keyto the uploaded object; AND a generics3_assets.Asset(asset-data/) is uploaded to the same bucket with its resolveds3BucketName/s3ObjectKeythreaded into the Lambda asCONFIG_BUCKET/CONFIG_KEYenv vars (synth confirms both are emitted asFn::Sub-backed bucket + literal key refs, exercising cdkd's intrinsic resolver).verify.sh(BSD/macOS-portable, real-rc + explicit[verify] PASS) deploys, asserts the function'sCodeSize > 500bytes (proves it ran from the uploaded ZIP, not inline), invokes it and asserts the handler marker (cdkd-s3-asset-deploy-marker-v1) plus a non-zeroconfigBytesfrom the generic-asset S3 read-back (proving that upload reached AWS and the bucket/key env wiring resolved), then destroys and asserts the Lambda + state file are gone with 0 errors. The bootstrap-bucket asset OBJECTS persist by design (cdkd does not own / delete the CDK bootstrap bucket) — the script deliberately does NOT assert their absence and notes this. New scenario tags3-asset-deployin the canonical taxonomy (scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs/run-integ s3-asset-deploybefore merge.)✅ New
drift-revert-arraysinteg broadens drift coverage to TAG-heavy / ARRAY-heavy resource types (refs issue #802) —tests/integration/drift-revert-arrays/(new fixture; nosrc/change). Issue #802 addedsrc/analyzer/drift-normalize.ts(canonicalizeTagListsDeep+canonicalizeIdArraysDeep) so a benign AWS-side reorder of a tag list ({Key,Value}[]) or a resource-id / ARN array no longer surfaces as phantom drift, but the existingdrift-revert/drift-revert-vpcfixtures carry none of those unordered-set array shapes — the canonicalization path had unit coverage only. This fixture (CdkdDriftArraysExample) deploys an S3 Bucket, SNS Topic, SQS Queue (each with six user tags), an IAM ManagedPolicy with a multi-statement document carrying multipleAction[](plain scalar arrays — intentionally NOT canonicalized) + multipleResource[]ARN arrays (canonicalized) + six tags, and a VPC (natGateways: 0, no NAT cost) + SecurityGroup with four CIDR ingress rules + six tags.verify.shasserts (a) no false positive on a clean deploy (cdkd driftexit 0 even though AWS reorders the tag lists / ARN arrays on readback), (b) no false positive on an induced reorder (inject-drift.ts reorderre-PUTs the same six S3 tags reversed;cdkd driftstill exit 0 — provescanonicalizeTagListsDeep), (c) true drift still detected (inject-drift.ts driftchanges a tag VALUE + adds a managed-policy Action + authorizes a new SG ingress rule out of band;cdkd driftexit 1), thencdkd drift --revert -yreverts and a follow-upcdkd driftis clean, and destroy leaves 0 orphans. The script is BSD/macOS-portable (nogrep -P/date -d), captures eachcdkd driftreal exit code, hard-fails with a canonicalizer-naming message, and prints[verify] PASSonly on full success. Thesubnet-…/sg-…resource-id branch ofcanonicalizeIdArraysDeepstays unit-covered (tests/unit/analyzer/drift-normalize.test.ts); the integ exercises the ARN branch of the same function end-to-end. New scenario tagdrift-revert-array-canonicalization.✅ New
docker-image-assetinteg exercises the deploy-time ECR build + push asset pipeline end-to-end —tests/integration/docker-image-asset/. Until now cdkd's Docker asset path (src/assets/docker-asset-publisher.ts—docker buildof a local Dockerfile, ECR auth,docker push) was only covered by the LOCAL-emulation container fixtures (local-invoke-containeretc.), which build images locally and never touch AWS — the actual deploy-time ECR build+push was untested end-to-end. The new fixture is a tinyCdkdDockerImageAssetExamplestack with a singlelambda.DockerImageFunction(DockerImageCode.fromImageAsset(docker/, { platform: LINUX_ARM64 }), a trivialpublic.ecr.aws/lambda/nodejs:20-based multi-arch image with a one-line handler, no VPC / NAT — the cheapest reliable way to force the build+push). The buildplatformAND the Lambdaarchitectureare BOTH pinned to ARM_64 (matching) on purpose: a defaultDockerImageFunctionsynthesizes NOsource.platformin the asset manifest and NOArchitectureson the template (defaulting the function to x86_64), so cdkd — which correctly honorssource.platformwhen present but builds for the HOST arch when it is absent — produces an arm64 image on an Apple-Silicon host that the x86_64 Lambda rejects at invoke withRuntime.InvalidEntrypoint: ProcessSpawnFailed(the same cross-arch trap CDK CLI users hit on Mac). PinningLINUX_ARM64makes CDK emitsource.platform: "linux/arm64"+Architectures: ["arm64"]so the built image arch always matches the Lambda runtime arch on any host.verify.sh(BSD/macOS-portable, captures the real rc + prints an explicit[verify] PASS) gracefully SKIPs (exit 0) whendocker infofails so it is robust on a Docker-less box but runs in a Docker env; it then: (1) deploys and asserts the Lambda exists withPackageType=Imageand aCode.ImageUripointing at the CDK-managed container-assets ECR repo, that the repo exists, and that OUR exact pushed image — identified by its content-addressed asset-hash TAG parsed from the ImageUri — is present (it deliberately does NOT count images in the shared bootstrap repo, which already holds thousands from other deploys, so a count is not a meaningful signal); (2) invokes the Lambda (aws lambda invoke) and asserts the expected payload (message,deployedByenv var, echoed event) — proving the pushed image actually runs; (3) destroys and asserts clean (Lambda gone, OUR pushed image gone from ECR with 0 orphans — the asset repo is the shared bootstrap-managedcdk-hnb659fds-container-assets-*repo so the assertion is on OUR image's lifecycle by tag, not the shared repo's removal, which is expected to persist), and the state file gone. The EXIT trap aggressively sweeps any leftover state, the deployment-events sidecar, and (by tag) the pushed image so the cost-bearing ECR image never lingers. New scenario tagdocker-image-asset-ecr-publish(KNOWN_SCENARIOS inscripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated; testing-guide entry added to docs/testing.md.✅ New
destroy-interruptinteg for the graceful-SIGINT destroy path (issue #816) + the Custom-Resource replay fail-fast (issue #804) —tests/integration/destroy-interrupt/(new fixture +verify.sh). Both behaviors shipped with unit + clean-destroy coverage only; this is their first real-AWS end-to-end verification. The fixture stackCdkdDestroyInterruptExample(16 synthesized resources) is a VPC with two isolated subnets + an S3 gateway VPC endpoint + a VPC-attached Lambda (HandlerFn) backing acdk.CustomResource(CrProbe) + fourAWS::SSM::Parameters — enough resources (VPC + subnets + SG + Lambda hyperplane ENI delete in order) that a destroy spans several seconds, so a mid-destroy SIGINT reliably lands during deletion, and the VPC-attached Lambda backing a CR is exactly the #804 shape (on a re-run the backing Lambda may already be gone).verify.sh(BSD/macOS-portable — nogrep -P, nodate -d, real exit codes captured to variables) runs four phases: (1) deploy clean; (2) first Ctrl-C — launchcdkd destroy --forcein the background, poll its log for delete-loop evidence (bounded ~30s) before sending ONEkill -INT <pid>to cdkd's own SIGINT handler, then on an interrupt that lands mid-destroy assert the #816 contract: the drain notice is logged, the stack lock object is gone (released — pre-fix it stranded for its 30m TTL), and the state file is preserved (trimmed, still listing the not-yet-deleted resources); if the destroy finishes before the interrupt can land (a fast-account race) that is logged and accepted, falling through to the clean-end asserts rather than hard-failing; (3) re-runcdkd destroy --forceto completion — assert it exits 0 (clean resume), finishes in < 180s (the #804 fail-fast: NO ~10-minuteGetFunctionstall against the already-deleted backing Lambda), and the log carries noPending/ long-Lambda-waiter signature; (4) clean end-state — state + lock gone, backing Lambda gone, VPC gone (subnets / SG / ENI implicitly cleared, plus an explicit leftover-ENI scan), no leftover SSM parameters. New scenario tagdestroy-interruptinKNOWN_SCENARIOS(scripts/build-scenario-coverage-matrix.ts); the fixture's.scenarios.jsonalso tagscustom-resource-async-poll+vpc-lambda-eni-release. Nosrc/change — integ-only PR.✅ Multi-resource deploy-engine ROLLBACK regression net (refs #808) — new
tests/integration/rollback-failure-injection/fixture. The only existing real-AWS rollback coverage was the trivialbasicsingle-SQSCDKD_TEST_FAILinjection (one queue, nothing for rollback to delete). This fixture is a RICH interdependent stack (CdkdRollbackFailureExample:AWS::EC2::VPC+ Subnets +AWS::EC2::SecurityGroup+AWS::IAM::Role+AWS::Lambda::Function-in-VPC +AWS::SSM::Parameter) that exercises thesrc/deployment/deploy-engine.tsperformRollbackpath when several siblings have ALREADY completed before the failure fires. The failure is self-contained (does NOT reuse thebasicfixture'sCDKD_TEST_FAILplumbing): gated onROLLBACK_INTEG_FAIL=true, the stack adds anAWS::SQS::Queuewith an out-of-rangemessageRetentionPeriod(9999999; valid range[60, 1209600]) that AWS rejects onCreateQueue. The failing queue is wired to depend on the two fast siblings (IAM Role + SSM Parameter), so cdkd's event-driven DAG guarantees those are created before the queue is attempted — giving rollback already-created siblings to delete (the slow VPC/Lambda branch runs in parallel and is also rolled back).verify.sh(BSD/macOS-portable, real-rc capture + explicit[verify] PASS): (1) deploy with the flag ON exits non-zero; (2) the completed siblings are rolled back — queried directly against AWS, the SSM Parameter / SecurityGroup / VPC are gone, no failing queue lingers, no leftover hyperplane ENIs, and cdkd state reflects rollback (state.jsonremoved or0resources); (3) the #808 events captured the failure —cdkd events --format jsonshows the newest run is aFAILEDdeploywhose per-run stream has aRESOURCE_FAILEDforAWS::SQS::Queue,ROLLBACK_STARTED+ROLLBACK_RESOURCE_SUCCEEDEDevents, andRUN_FINISHED result=FAILED; (4) deploy with the flag OFF succeeds →cdkd destroy --force→ clean (state gone, 0 orphan VPC/SG/SSM); (5) an EXIT trap performs aggressive orphan cleanup on the failure path (SSM Parameter + failing SQS queue + any Lambda / IAM Role tagged with the fixture'saws:cdk:path+ the VPC and its dependents — ENIs first, then NAT GW / SGs / subnets / IGW / route tables / VPC) plus removal of the events sidecar. This closes the failure-path follow-up thedeployment-eventsfixture README explicitly deferred (RESOURCE_FAILED/ROLLBACK_*events). New scenario tagrollback-failure-injection. Test-only PR — nosrc/change.✅ Extracted the triplicated
ensureClientForBucket()state-bucket-region rebuild into one shared helper (issue #827) —src/utils/bucket-region-client.ts(new) +src/state/s3-state-backend.ts+src/state/lock-manager.ts+src/state/export-index-store.ts. Pure refactor, no behavior change. The "resolve the state bucket's region via the cachedGetBucketLocationprobe, short-circuit when it already matches the client's region, else rebuild a region-correctedS3Clientreusing the caller's credentials" pattern had drifted into three near-identical privateensureClientForBucket()copies (the state backend PR #60, the lock manager #803, the exports index store #819). They are now one exported helperrebuildClientForBucketRegion(client, bucket, opts): Promise<S3Client | null>— it returnsnullto mean "no rebuild needed, keep the original client" and a fresh client otherwise. Every load-bearing per-store difference is preserved via options rather than collapsed:destroyOldClient(the state backend OWNS its client and.destroy()s the replaced one; the lock manager + exports store shareAwsClients.s3and must NOT),profile+ staticcredentials(the state backend threads its constructorclientOptsinto both the probe and the rebuild) vsreuseClientCredentials(the other two authenticate the probe viaclient.config.credentials()best-effort and reuse the original client'sconfig.credentialsPROVIDER REFERENCE — not a resolved snapshot — for the rebuild), andtolerateNonStandardClient(only the exports store gracefully degrades a test double whoseconfig.regionis not a function by returningnull; the other two readconfig.region()directly as before). Each store keeps its own per-instance memoization (clientResolvedflag + single-flightresolveInFlightpromise) and its own debug log wording via anonRebuildcallback. The helper deliberately lives in its OWN module rather than alongsideresolveBucketRegioninaws-region-resolver.ts: the three stores' unit tests mockresolveBucketRegionviavi.mock('aws-region-resolver.js', ...), and a helper co-located in that module would call its sibling through an in-module binding vitest cannot intercept (a module cannot mock itself) — a separate module imports the mocked binding cross-module so the mock still applies. Tests: all 75 existing unit tests acrosstests/unit/state/{s3-state-backend,lock-manager,export-index-store}.test.ts(incl. each store's 301-rebuild / same-region-no-rebuild / resolve-once-cached suites) pass UNCHANGED, plus 7 new focused tests intests/unit/utils/bucket-region-client.test.ts(region-mismatch → rebuilt client with correct region; same-region →null; static-credential carry-over to probe + rebuild; provider-reference reuse;destroyOldClientgating; non-standard-client tolerance →null; no-credentials → omitcredentialsfrom the rebuilt client). No runtime behavior change for any of the three cross-region-state-bucket paths (lock acquisition, state read/write, exports index).✅
--regiondeprecation warning no longer contradicts the actual behavior (issue #818) —src/cli/options.ts.warnIfDeprecatedRegionand the hiddendeprecatedRegionOptionhelp text both claimed--region"has no effect" on non-bootstrap commands, but every non-bootstrap command (deploy,destroy,diff,synth,list,state,force-unlock,publish-assets,import,export,orphan,drift,events,local *, …) actually consumesoptions.regionas the highest-precedence region source:const region = options.region || process.env['AWS_REGION'] || 'us-east-1'feeds the provisioning / state-bucket SDK clients and theapplyRoleArnIfSetSTS hop, anddeploy/destroy/import/export/orphanadditionally inject it intoprocess.env.AWS_REGIONso the CDK synth subprocess inherits it (e.g.deploy.ts~L167/L175/L341). The warning and the code therefore disagreed — a user passing--regionwas told it did nothing while it silently took effect. Investigation determined--regionIS legitimately honored everywhere (option B in the issue), so the fix is purely in the warning + help text — no command implementation (deploy.tsetc.) was touched, keeping the change out of theinteg-broadmerge-gate scope and carrying zero behavior-change risk. The warning now reads "--region is deprecated and will be removed in a future release. It is still honored for now (it overrides AWS_REGION / your AWS profile), but prefer the AWS_REGION environment variable or your AWS profile…" and the option description drops the false "No effect" claim. The recommended mechanism is stillAWS_REGION/ the AWS profile; the flag stays hidden + deprecated, just honestly described. Docs corrected: two "deprecated and ignored" lines in docs/cli-reference.md and the--regionbullet in .claude/rules/cli-internals.md. Tests:tests/unit/cli/options.test.ts— the existing message assertion updated, plus new assertions that neither the warning nor the option description contains "no effect" and that both mention the flag is "still honored" (issue #818).✅
destroywaits for NAT Gateway deletion before detaching / deleting the IGW + VPCGatewayAttachment (issue #817) —src/analyzer/implicit-delete-deps.ts. Destroying a VPC + NAT Gateway + IGW stack attempted theVPCGatewayAttachmentdetach while the NAT Gateway's Elastic IP was still mapped to the VPC's public address space, failing withNetwork vpc-xxx has some mapped public address(es), after which the IGW delete hung (~19 min observed). This was the first-run failure split out of the #804 incident as a separate issue. The fix adds two type-based implicit delete-dependency edges so the shared deploy DELETE phase + standalone destroy command order the teardown like CloudFormation does:AWS::EC2::InternetGatewaygainsAWS::EC2::NatGateway(alongside its existingAWS::EC2::VPCGatewayAttachmentdependee) and a newAWS::EC2::VPCGatewayAttachmentkey listsAWS::EC2::NatGateway— both are deleted AFTER the NAT Gateway is gone (NAT deletion releases / decouples the EIP). No type-based rule is needed for the EIP itself: the NAT Ref's its EIP viaAllocationId, so the reversed delete traversal already deletes the NAT before the EIP is released. The injection logic (destroy-runner.ts/deploy-engine.ts) naturally produces no edge when no NatGateway is in state. Tests: 4 unit assertions intests/unit/analyzer/implicit-delete-deps.test.ts(IGW-after-NAT edge, VPCGatewayAttachment-after-NAT edge, no NatGateway / EIP key registered; the existing no-self-cycle guard covers the new entries). Integ: the existingvpc-nat-gatewayfixture (VPC + public/private subnets + IGW + NatGateway + EIP) exercises exactly this teardown end-to-end.✅ Exports index store resolves the state bucket's region before its write/remove (issue #819) —
src/state/export-index-store.ts. PR #803 fixedLockManagerto resolve a cross-region state bucket's actual region viaGetBucketLocationbefore any S3 op; the automatedcross-region-state-bucketinteg then surfaced that the exports index store (Fn::ImportValuecross-stack reference tracking, writess3://{bucket}/{prefix}/_index/{region}/exports.json) still had the SAME unfixed bug. Its S3 client was pinned to the CLI base region, so against a state bucket in another region every index write (after a deploy save) and remove (after a destroy) hit S3's 301 PermanentRedirect, logged asExports index remove failed (non-retryable): The bucket you are attempting to access must be addressed using the specified endpoint ...; continuing without index update. Non-fatal by design (the canonicalstate.jsonis written through the already-region-correctedS3StateBackendand stays correct; the index is a perf-only derived view that self-heals on the nextlookupmiss-and-patch / rebuild), so the run still passed — but the cross-region exports index was silently never maintained. The fix ports theLockManager.ensureClientForBucket()pattern intoExportIndexStore: before its first S3 read (readIndexRaw) or write (writeIndex) it resolves the bucket's region (cached process-wide viaresolveBucketRegion, so when the state backend / lock manager already resolved the same bucket there's no extraGetBucketLocationcall) and, if it differs from the supplied client's region, builds a private replacementS3Clientfor that region — reusing the caller's resolved credentials (so--profile/ static creds carry over without threading client options through the four store call sites) and NOT destroying the sharedAwsClients.s3instance other components still hold. The resolution is memoized + single-flight (clientResolved/resolveInFlight), and degrades gracefully for a test double whose client lacks the SDKconfig.region()shape (skips resolution, uses the client unchanged) — so the store stays contained, with no ripple todeploy.ts/destroy.ts/state.ts/local-state-loader.ts. Tests: 4 new unit tests intests/unit/state/export-index-store.test.ts(removeStack + updateForStack succeed through a region-corrected client when the bucket region differs — pre-fix 301; no client rebuild when the resolved region matches; the bucket region is resolved exactly once across multiple index ops). Integ: thecross-region-state-bucketfixture stack now publishes a CloudFormation Output with anExport.Name(an export-less stack short-circuits the index write entirely), andverify.shgreps thecdkd deploy+cdkd destroy --verboseoutput to assert the exports-index 301 warning is GONE on both paths AND that_index/{region}/exports.jsonwas actually written to the cross-region bucket on deploy. New scenario tagexports-index-region-resolve.✅
destroyhandles the first Ctrl-C gracefully — flushes state + releases the lock instead of stranding it (issue #816) —src/cli/commands/destroy-runner.ts+src/cli/commands/destroy.ts+src/cli/commands/state.ts. This is the deferred "optional fix 3" from #804 (the incremental-state-persistence + CR fail-fast work shipped in PR #814). Before:cdkd destroy/cdkd state destroyhad NO SIGINT handler, so a first Ctrl-C killed the process mid-destroy — thefinallythat releases the stack lock never ran, leaving the lock stranded for its full TTL, and any in-flight provider delete was severed abruptly. After (Terraform parity): the runner registers a per-call SIGINT handler that on the FIRST Ctrl-C sets adrainingflag — the reverse-DAG delete loop checks it before scheduling each subsequent LEVEL (and, defense-in-depth, before dispatching each resource), so NO new delete is started; the deletes already in flight in the current level are awaited to completion (NOT cancelled). Control then falls through to the existingfinally, which flushes the incremental save-chain from #804 (so the preservedstate.jsonlists only the resources that still exist), stops the live renderer, and releases the lock. A SECOND Ctrl-C bypasses graceful shutdown (process.exit(130)). On a graceful interrupt the runner PRESERVES state (it does NOTdeleteState, even thougherrorCount === 0, because resources remain) and surfaces the outcome via a newDestroyRunnerResult.interruptedflag; bothdestroy.tsandstate.tsstop their multi-stack loop on the first interrupted stack and throwPartialFailureError(exit code 2) so scripts / CI see the destroy did not complete. The handler reads/writes only its own call's closure state and is removed viaprocess.removeListener('SIGINT', ...)in thefinally, so no listener leaks — important for nested-stack recursion, whereNestedStackProvider.deleterecurses intorunDestroyForStackand registers one handler per level (Node delivers SIGINT to every listener, so the first Ctrl-C drains the parent AND every in-flight child). A re-run ofcdkd destroyafter a graceful interrupt resumes cleanly with no replay (the #814 incremental state already trimmed the deleted resources) and no wait for the lock TTL. Tests: 5 unit tests intests/unit/cli/destroy-runner-sigint.test.ts(the SIGINT handler is captured by spying onprocess.on('SIGINT', ...)and invoked directly — no real OS signal is sent): first Ctrl-C finishes the in-flight delete + schedules no new deletes + preserves the trimmed state + releases the lock + marksinterrupted; the level-boundary gate stops all subsequent levels; a second Ctrl-C force-quits viaprocess.exit(130); a normal completion leavesinterrupted: falseand removes the listener;process.removeListeneris invoked in thefinally. Happy-path (uninterrupted) destroy is unchanged. Docs: destroy-interruption subsection in docs/state-management.md + the stale-lock note in docs/troubleshooting.md.✅
deployretries the ECS CapacityProvider same-stack infrastructure-role IAM-propagation race (issue #805) —src/deployment/retryable-errors.ts. cdkd's event-driven DAG dispatches the Cloud ControlCreateResourcefor anAWS::ECS::CapacityProvider(Managed Instances) as soon as its same-stack infrastructure role finishes creating, and cdkd's fast SDK path creates the IAM role without waiting for propagation — so ECS tried to assume the just-createdInfrastructureRoleArnbefore IAM had propagated it and rejected the create withCaught ServiceAccessDeniedException for ECSInfrastructureRole[arn:...]. The CC API handler classifies this as a terminalInvalidRequest(no internal retry,SDK Attempt Count: 1), and none of the existing message patterns matched it, so the deploy failed fast on a transient error. The fix adds'Caught ServiceAccessDeniedException'toRETRYABLE_ERROR_MESSAGE_PATTERNS— mirroring theENHANCED_MONITORINGpattern added for #794 — so the deploy engine's existingwithRetry(8 attempts, ~47s cumulative) absorbs the propagation window; the phrase is anchored on the CC-API/ECS handler wording so a genuine, permanent role misconfiguration only burns the bounded retries before surfacing. Generic by design: any Cloud-Control-provisioned type that validates a same-stack IAM role at create time and surfacesServiceAccessDeniedExceptionis covered. Tests: the exact wire message from the issue classifies retryable + a plainAccessDeniedException(without the handler's "Caught" anchor) stays non-retryable inretryable-errors.test.ts. Verified by the issue reporter against the real-world 33-resource stack that surfaced the bug (the capacity provider create retried through the window and completed).✅
AWS::ECS::TaskDefinitionVolumes[].ConfiguredAtLaunchno longer silently dropped (issue #806) —src/provisioning/providers/ecs-provider.ts.ECSProvider.convertVolumesmapped onlyName/Host/EFSVolumeConfigurationwhen converting CFnVolumesto theRegisterTaskDefinitionwire shape;ConfiguredAtLaunchwas dropped, so the registered task definition had noconfiguredAtLaunchvolume and a same-stackAWS::ECS::ServicecarryingVolumeConfigurations(a managed EBS volume — CDK'sServiceManagedVolume) failed to create with "Volume configuration provided but no matching configuredAtLaunch volume found in task definition". The pre-flight property-coverage gate could not catch this class: it works at top-level property granularity andVolumesIS inhandledProperties— the gap was one level down, inside the handled property.convertVolumesnow forwardsconfiguredAtLaunchvia acoerceBoolhelper (same pattern asEC2Provider's) that normalizes CFn boolean-ish values (true/"true"/false/"false") at the wire boundary and returnsundefinedfor absent props so the field is omitted from the SDK input (AWS keeps its default). No parallelupdate()change is needed — ECS TaskDefinitions are immutable revisioned resources; property changes route through Replace (CREATE then DELETE). Tests: 4 unit tests (present-true forwarded, string"true"/"false"coerced, absent omitted, explicitfalsepreserved as distinct from omit). Theecs-fargateinteg fixture gains aServiceManagedVolume(1 GiB gp3, XFS) mounted into the container and attached to the Service viaservice.addVolume()— synthesizing exactly theConfiguredAtLaunch+VolumeConfigurationspairing the bug broke (withdesiredCount: 0no task launches, so no EBS volume is actually created);verify.shasserts the registered task definition'sebs-datavolume hasconfiguredAtLaunch == true(probed via jqhas()— the//operator would map an explicitfalseto the fallback) and thatDescribeServicesshows the deployment carrying theebs-datavolume configuration. RemainingconvertVolumessub-property gaps of the same class (DockerVolumeConfiguration/FSxWindowsFileServerVolumeConfigurationunmapped;Host/EFSVolumeConfigurationcast without PascalCase-to-camelCase conversion) are tracked separately per the issue.✅
AWS::ECS::TaskDefinitionVolumes[]sub-configurations fully PascalCase-to-camelCase converted (issue #815) —src/provisioning/providers/ecs-provider.ts. The remainingconvertVolumessub-property gaps deferred from #806 are now closed. Before:DockerVolumeConfigurationandFSxWindowsFileServerVolumeConfigurationwere not mapped at all (silently dropped fromRegisterTaskDefinition), andHost/EFSVolumeConfigurationwere cast through raw — so their nested CFn-PascalCase keys (Host.SourcePath,EFSVolumeConfiguration.{FilesystemId, RootDirectory, TransitEncryption, TransitEncryptionPort, AuthorizationConfig.{AccessPointId, IAM}}) reached the ECS SDK still PascalCase and AWS dropped them. This is the same PascalCase-to-camelCase trap already fixed for theContainerDefinitionssub-arrays (convertEnvironment/convertSecrets/convertMountPointsetc.). The property-coverage gate could not catch it —VolumesIS inhandledProperties, so the gap was one level down inside the handled property. After:convertVolumesruns each volume sub-block through a dedicated explicit converter (convertVolumeHost/convertDockerVolumeConfiguration/convertEFSVolumeConfiguration+convertEFSAuthorizationConfig/convertFSxWindowsVolumeConfiguration+convertFSxWindowsAuthorizationConfig), matching the provider's existing per-type converter style. The case mapping is NOT a simple first-letter flip in two spots, verified against the CDK L1*ToCloudFormationmappings: EFS usesFilesystemId(lowercases) while FSx usesFileSystemId(capitalS), and EFSAuthorizationConfigusesIAM(all caps), notIam.Autoprovision(Docker) andTransitEncryptionPort(EFS) are coerced at the wire boundary (coerceBool/Number(...)) since CFn can carry them stringly-typed.readCurrentStateTaskDefinitionnow also normalizes the camelCase SDKvolumesshape back to PascalCase via the newvolumesToCfnSDK-to-CFn converter, so thereadCurrentStatedrift snapshot matches the deploy-time template form (forward-looking — TaskDefinitions are immutable replace-only today, so no UPDATE path consumes it yet). Tests: 8 new unit tests (EFS full-shape conversion + stringly-typedTransitEncryptionPortcoercion +Dockerfull-shape + stringly-typedAutoprovisioncoercion +FSxfull-shape +Host.SourcePath+ omit-when-absent for every sub-block) plus areadCurrentStatenormalization test asserting all four sub-block types round-trip back to PascalCase; the two pre-existing #806 volume tests were updated for the new omit-when-absent key set and the corrected PascalCaseHost.SourcePathinput. Integ: theecs-fargatefixture gains anefs.FileSystem+efs.AccessPoint(public subnets,RemovalPolicy.DESTROY) and anefsVolumeConfigurationvolume on the task definition;verify.shassertsdescribe-task-definitionshows theefs-datavolume'sefsVolumeConfigurationreached AWS with camelCasefileSystemId/transitEncryption: ENABLED/authorizationConfig.{accessPointId, iam: ENABLED}. EFS is the integ-verified path;DockerVolumeConfiguration(Docker-daemon-scoped, unsupported on Fargate) andFSxWindowsFileServerVolumeConfiguration(Windows / FSx-specific) are hard to integ on Fargate and are covered by the unit tests only.✅ Cloud Control UPDATE re-includes write-only properties in every patch document (issue #809) —
src/provisioning/cloud-control-provider.ts+ newsrc/provisioning/write-only-properties.ts. Cloud Control applies UPDATE patches read-modify-write: the type's read handler returns the current model, the patch is applied on top, and the result becomes the desired state — but read handlers cannot return write-only properties, so any write-only property absent from cdkd's minimal previous-vs-desired patch silently vanished from the desired state on every CC-routed UPDATE.AWS::ECS::Service(writeOnlyProperties:ServiceConnectConfiguration/VolumeConfigurations/ForceNewDeployment) hard-failed: a task-definition-only change on a service with a managed EBS volume produced a patch withoutVolumeConfigurations, andUpdateServicerejected with "Task definition has configuredAtLaunch volume but no volume configuration provided at runtime", wedging the stack (state still recorded the old properties, so every subsequent deploy retried the same failing patch). Types whose handler accepts the write-only-less state lost the configuration silently instead. The fix mirrorsterraform-provider-awscc:CloudControlProvider.updatenow resolves the type'swriteOnlyPropertiesfrom the registry schema viacloudformation:DescribeType(reduced to the top-level containing property — a nested path like/properties/Foo/Barstrips toFoo), removes those properties from the PREVIOUS side, and regenerates the patch — the generator then naturally emitsaddops for every write-only property present in the desired properties, which is exactly what the CC read-modify-write contract requires. Only write-only properties are force-included (blanket-upserting all desired properties would risk false replacement signals oncreateOnlyPropertieswhose read-back form differs from the stored form). Only SUCCESSFUL DescribeType results are cached per resource type for the deploy lifetime in a module-level map, so repeated updates of the same type pay one throttled-API call; a DescribeType failure (missing IAM permission, transient throttle / 5xx) is NOT cached — it warns and falls back to the pre-#809 minimal patch for that update, and a later update of the same type retries DescribeType. Caching failures would let one transient throttle on the first CC-routed UPDATE silently disable write-only re-inclusion for every CC-routed type for the rest of the deploy, reintroducing the exact hard-fail this fixes. No regression for callers permanently without the newcloudformation:DescribeTypepermission — each update simply re-warns and re-falls-back. A DescribeType response without aSchema(e.g. a still-registering type) is treated as "no write-only properties" — a successful, cacheable, warning-free lookup. Removal-only write-only diffs skip the update entirely (CC cannot remove what its read handler never returns — pre-fix aremoveop against a path absent from the current model would have failed), and the no-change fast path still skips without any DescribeType call. Tests: 11 unit tests intests/unit/provisioning/cloud-control-provider.test.ts(unchanged write-only prop rides along asadd; changed write-only prop not duplicated; nested-path top-level strip; no-write-only type keeps the minimal patch; DescribeType failure warning + fallback; per-type caching of successful lookups; failures NOT cached so a later update retries; retry-after-failure uses the populated set on success; Schema-less response = no write-only props + no warning; no-change skip; removal-only skip). Integ: theecs-fargatefixture'sServiceManagedVolume+CDKD_TEST_UPDATEpass (issues #806/#807) exercises this exact path end-to-end once both land.✅ Replacement of a referenced resource now propagates to dependents diffed as
NO_CHANGE(issue #807) —src/analyzer/diff-calculator.ts+src/analyzer/template-parser.ts. Diff-time intrinsic resolution runs against CURRENT state, so a dependent whose only "change" was aRef/Fn::GetAttto a resource that gets a NEW physical ID on replacement (e.g.AWS::ECS::TaskDefinition— every revision is a new ARN) compared equal and landed onNO_CHANGE; the deploy engine excluded it from the execution DAG and never re-pointed it at the new physical resource. For ECS this meant a task definition change registered a new revision butUpdateServicewas never issued — the service kept running tasks on the old, now-deregistered revision. CloudFormation propagates the new physical ID to dependents; cdkd now mirrors that: after per-resource diffs are computed,DiffCalculator.promoteReplacementDependentswalks reverse reference edges (built from the desired template's per-propertyRef/Fn::GetAtt/Fn::Sub/ nested-intrinsic references via the new publicTemplateParser.extractReferences;DependsOnis excluded — pure ordering carries no value to propagate) from every replacement-triggeringUPDATEand promotesNO_CHANGEdependents toUPDATEwith syntheticPropertyChangeentries for the referencing top-level properties. Each synthetic change is re-evaluated againstReplacementRulesRegistrywithundefinedold/new values — the referencing property's template value did not actually change (only its resolved physical ID / ARN will), so unconditionalreplacementPropertiesstill fire on the property name whileconditionalReplacementsare NOT fed a phantom resolved-string → unresolved-intrinsic delta that would falsely report "changed" and spuriously enqueue the dependent's grandchildren (review Fix 1). A promoted dependent whose referencing property is itself immutable (e.g. a TaskDefinition whoseContainerDefinitionsreference a replaced resource) becomes a replacement seed for its dependents — the walk is transitive, and theenqueuedguard makes it terminate even on a reference cycle (A→B→A). Dependents that already had their own property changes stayUPDATEand gain the referencing-property entry (no duplicates);CREATE/DELETEdependents are untouched. Over-promotion is harmless by construction: the deploy engine's UPDATE path re-resolves desired properties against the in-flight state map (which by DAG order already carries the replaced dependency's new physical ID) and skips the provider call when the resolved properties are unchanged. Each syntheticPropertyChangecarriesreplacementPropagated: true(a new optional field on the sharedPropertyChangetype) socdkd diffannotates the property line[replacement propagated]— the apparent old-value →{Ref}delta reads as a propagated replacement rather than a literal value edit (review Fix 2). Tests: 8 unit tests (Ref promotion + unrelated NO_CHANGE stays, GetAtt promotion, transitive A→B→C with replacement re-evaluation, already-UPDATE dependent append-once, in-place update does NOT promote,replacementPropagatedmarker present, conditionalReplacement does NOT spuriously promote grandchildren, reference-cycle termination); theecs-fargateinteg fixture gains aCDKD_TEST_UPDATE=truePhase 1b (container command change → TaskDefinition replacement) whose verify.sh asserts the Service'staskDefinitiontracks the new ACTIVE revision carrying the updated command.✅ Interrupted / partially-failed destroy no longer replays Custom Resource deletes against an already-deleted backing Lambda (issue #804) —
src/provisioning/providers/custom-resource-provider.ts+src/cli/commands/destroy-runner.ts. Two layered fixes:- CR provider delete fail-fast. Before: re-running a destroy whose first run had already deleted a Custom Resource AND its backing Lambda stalled ~10 minutes per CR — the delete entered
waitForBackingLambdaReady, whose SDK v3 waiters classifyResourceNotFoundExceptionas RETRY (no error acceptor) and pollGetFunctionfor the fullmaxWaitTime: 600, until the lenient delete catch swallowed the timeout. After:delete()issues ONEGetFunctionpre-check before preparing the invocation; a definitiveResourceNotFoundExceptionlogs a warning and treats the Custom Resource as already deleted (warn-and-continue is the provider's existing delete policy), restoring re-run idempotency parity with every other resource type. Inconclusive pre-check errors (throttle, IAM) fall through to the normal invoke path; SNS-backed tokens skip the pre-check; create / update are unchanged (they must keep failing loudly against a missing function). - Incremental state persistence on destroy (Terraform parity, root fix). Before: destroy state handling was all-or-nothing —
deleteStateon full success, untouched full state on any failure / interrupt, so a preserved state still listed every already-deleted resource and the next run replayed them all. After:runDestroyForStackmirrors deploy'ssaveStateAfterResource— each successfully deleted resource (including the idempotent "not found → already deleted" path) is removed from a working copy ofstate.resourcesand the trimmed state is persisted to S3 per resource, serialized through a save chain under the already-held stack lock. Retained resources (DeletionPolicy: Retain) stay in every snapshot (their record is only dropped by the wholesale state-file delete at the end of a clean destroy, as before). Persist failures are warn-and-continue and never fail the destroy; the final write remains authoritative (deleteStateonerrorCount === 0, a final preserve-write of the remaining resources onerrorCount > 0); the save chain is flushed beforedeleteState(no resurrection race) and before lock release. Nested stacks inherit the behavior automatically —NestedStackProvider.deleteroutes child destroys through the samerunDestroyForStack.cdkd destroyandcdkd state destroyshare the runner, so both get it. - Persisted destroy snapshots clear
outputs/ dropimports/outputReads(phantom-export fix). Both the incremental writes and the final partial-failure preserve-write now writeoutputs: {}and omitimports/outputReads.outputsis keyed by output NAME (not logical id) so it cannot be pruned per-resource as backing resources are deleted; a partially/fully destroyed stack has no meaningful outputs, and leaving them in the preserved state would advertise an export whose backing resource is gone — a phantom export the exports index or another producer'sscanActiveConsumersstrong-ref scan could pick up. The destroy's OWN strong-ref check is unaffected: it reads the in-memorystate.outputsBEFORE the delete loop, and the in-memorystateobject is never mutated (only the persisted snapshot copies are cleared). On a clean destroy the exports-index entry is removed viaexportIndexStore.removeStack; on a partial destroy the index may briefly carry stale entries (a perf-only derived view that self-heals), but the canonicalstate.jsonno longer carries the phantom outputs. - Issue's optional fix 3 (graceful SIGINT handling for destroy: stop scheduling, persist, release the lock) was deferred here and later shipped as issue #816 (see the dedicated entry above). The first-run IGW/NAT/EIP implicit delete-dependency gap mentioned in the issue is a separate issue.
- Tests: 5 CR fail-fast unit tests (
custom-resource-provider.test.ts— Lambda gone → 1 GetFunction, no waiter polls / no invoke / no S3, warn logged; Lambda present → unchanged 4-call path; inconclusive pre-check on Throttling / AccessDenied / generic 5xx → falls through to the normal invoke; SNS ServiceToken → no GetFunction pre-check issued) + 11 destroy-runner unit tests (destroy-runner-incremental-state.test.ts— per-resource trimmed persists thendeleteState, partial-failure state contains only failed/remaining resources, "not found" removal, incremental + final persist failures are non-fatal, retained resources survive snapshots, persisted snapshots clear outputs/imports/outputReads while the in-memory state is preserved, mid-chain incremental persist failure doesn't poison later links, 3-concurrent-sibling snapshots shrink monotonically, nested-stack child drives its own state key and flushes before the parent deleteState).
- CR provider delete fail-fast. Before: re-running a destroy whose first run had already deleted a Custom Resource AND its backing Lambda stalled ~10 minutes per CR — the delete entered
✅ Structured deployment events +
cdkd eventscommand (issue #808) —src/types/deployment-events.ts,src/state/deployment-events-store.ts,src/cli/commands/events.ts, plus event-emission seams insrc/deployment/deploy-engine.ts,src/cli/commands/deploy.ts,src/cli/commands/destroy-runner.ts,src/cli/commands/destroy.ts. cdkd now records a CloudFormationDescribeStackEvents-equivalent stream of structured deployment events to S3 for everycdkd deploy/cdkd destroyrun, readable back with the newcdkd events <stack>command. Pre-PR the only durable artifact of a failed run was the (partial)state.json; per-resource lifecycle detail (which op failed, why, in what order, with what AWS error) existed only as transient stdout/stderr log output — making post-hoc troubleshooting (especially handing context to an AI agent on another machine / session) impossible.- Event types:
RUN_STARTED/RUN_FINISHED(command, region, cdkd version, terminal result, per-op counts);RESOURCE_STARTED/RESOURCE_SUCCEEDED/RESOURCE_FAILED(logicalId, resourceType,provisionedBy, physicalId on success, durationMs, error metadata on failure);RESOURCE_RETAINED(destroy-sideDeletionPolicy: Retainskip);ROLLBACK_STARTED/ROLLBACK_RESOURCE_SUCCEEDED/ROLLBACK_RESOURCE_FAILED/ROLLBACK_FINISHED. Failure events carry{ name, message, awsErrorCode?, requestId? }extracted from the innermost AWS-SDK-shaped error in the thrown error's.causechain. - Emitter seam: the
DeployEngineemits per-resource + rollback events through an optionalDeploymentEventRecorderinjected viaDeployEngineOptions.eventRecorder(around the existingprovisionResource/performRollbackpaths — no logging rewrite); the destroy runner emits per-resource DELETE events throughDestroyRunnerContext.eventRecorder; the deploy / destroy CLIs own the run-levelRUN_STARTED/RUN_FINISHEDevents (they know the command / version / result) andfinalize()the recorder in afinally. - S3 layout (no state schema bump): JSONL at
s3://{bucket}/{prefix}/{stackName}/{region}/deployments/{runId}.jsonl+ a smalldeployments/index.json(last 20 runs, newest first). Deliberately a separate key family fromstate.json— state stays at its current version (nointeg-schema-migrationgate), fully backward compatible. Event files survivecdkd destroy(state deletion does not touchdeployments/), so a destroyed stack's failure history stays readable. - Best-effort, never blocking:
record()is synchronous + buffers in memory; flushes are async (debounced timer + size threshold) serialized on a write chain; a failed S3 write warns at most once then degrades to debug — it can NEVER fail or block the deploy / destroy. No locking (per-run unique.jsonlkeys;index.jsonis last-writer-wins — a derived view, the.jsonlfiles are the source of truth). No resource properties in events (secrets) — errors + metadata only; properties already live in state.json. cdkd events <stack> [--run <id>] [--format json] [--stack-region <r>]: lists runs newest-first (from the index, falling back to{runId}.jsonlkey enumeration);--runreads one run's ordered stream (skipping torn / malformed lines from an interrupted flush);--format json(alias--json) emits raw JSON for tooling / AI-agent hand-off. State-driven (no synth, no lock); region auto-discovered from thedeployments/key listing so it works for destroyed stacks. Registered insrc/cli/index.ts.- Tests:
tests/unit/state/deployment-events-store.test.ts(JSONL shape, no-properties-leaked, error-metadata capture, best-effort no-throw + one-shot warn, empty-run no-artifact, index newest-first + truncation-to-N, corrupt-index rebuild, reader listing / index-fallback / torn-line skip / region discovery),tests/unit/deployment/deployment-events-emission.test.ts(ordered emission, no-properties, AWS-error-metadata + rollback events on failure, no-recorder back-compat, throwing-recorder never breaks deploy),tests/unit/cli/commands/events.test.ts(list / read-one /--format json/ not-found / no-history / multi-region ambiguity /--stack-region). Docs: new docs/deployment-events.md + acdkd eventssection in docs/cli-reference.md. Out of scope (per the issue, deferred):cdkd doctor --bundlediagnostic bundle + MCP server exposure. - Follow-up (review fixes, same PR):
cdkd eventsno longer mislabels a successful run asFAILEDin the index-fallback (user-visible correctness fix). Whendeployments/index.jsonis missing / corrupt,DeploymentEventsReader.listRunsrebuilds the run listing by enumerating the{runId}.jsonlkeys. It previously stamped every fallback rowresult: 'FAILED'— so a run that genuinely SUCCEEDED but whoseindex.jsonwrite lost the last-writer-wins race showed asFAILED. The fallback now reads each run's JSONL and derives the true terminal result (command / cdkd version / timestamps / event count too) from the run's lastRUN_FINISHEDevent; a run with no terminalRUN_FINISHED(interrupted, or index write lost) reports the newresult: 'UNKNOWN'(added to aDeploymentRunSummaryResult = DeploymentRunResult | 'UNKNOWN'type used only on the summary; the run-level emitters still only ever produceSUCCEEDED/FAILED) and is colored neutrally in the run listing rather than red.- Run-level bracket extracted to
src/cli/commands/deployment-events-run.ts(startRunRecorder/recordRunSucceeded/recordRunFailed) so theRUN_STARTED/RUN_FINISHED+--dry-run-skips-recorder +extractDeploymentEventError-on-failure contract is directly unit-testable and shared by bothdeploy.tsanddestroy.ts(behavior identical to the prior inline code). - Added tests:
tests/unit/types/deployment-events.test.ts(extractDeploymentEventErrordeepest-AWS-shaped-error extraction, bounded-depth-10 + cyclic-chain guard, non-Error inputs),tests/unit/cli/destroy-runner-events.test.ts(destroy-runnerRESOURCE_STARTED/SUCCEEDED/FAILED+RESOURCE_RETAINEDfor aDeletionPolicy: Retainskip + no-recorder back-compat),tests/unit/cli/deployment-events-run.test.ts(run-level bracket: dry-run = no recorder,RUN_STARTEDat create, successRUN_FINISHEDwith counts, failureRUN_FINISHEDwithresult: 'FAILED'+ error metadata, no-properties-leak), aROLLBACK_RESOURCE_FAILEDcase indeployment-events-emission.test.ts, the no-FAILED-fabrication +UNKNOWN-on-torn cases indeployment-events-store.test.ts, and alistRawKeysmulti-pageContinuationTokenpagination case intests/unit/state/s3-state-backend.test.ts.
- Integration coverage (follow-up PR, refs #808): new
tests/integration/deployment-events/fixture — a tinyCdkdDeploymentEventsExamplestack (anAWS::SNS::Topic+ anAWS::SSM::Parameter, no VPC / NAT, deploy+destroy in well under a minute) with averify.shthat exercises the feature end-to-end against real AWS: deploy writes thedeployments/{runId}.jsonl+index.jsonsidecar;cdkd events <stack> --stack-region <r>lists adeployrun asSUCCEEDED;--format jsonis valid JSON carryingRUN_STARTED/RUN_FINISHED+ at least oneRESOURCE_*event for the topic / parameter; the SSM parameter's marker value (events-integ-secret-value) does NOT appear anywhere in the events JSON (the no-secrets guarantee); aftercdkd destroy --force,state.jsonis gone but thedeployments/sidecar survives (now carrying the destroy run too) andcdkd eventslists BOTH a deploy and a destroy run; the fixture removes the events sidecar at the end (and on the failure path via an EXIT trap). New scenario tagdeployment-events.
- Event types:
✅
LockManagerresolves the state bucket's actual region before lock operations (issue #803) —src/state/lock-manager.ts. PR #60 taughtS3StateBackendto resolve a cross-region state bucket's real region viaGetBucketLocationand rebuild its S3 client, butLockManagerwas left out: it kept using the raw client pinned to the CLI's base region (AWS_REGION/ fallbackus-east-1), so against a bucket in another region every state read/write succeeded while every lock acquisition failed with S3's 301 PermanentRedirect ("must be addressed using the specified endpoint") — contradicting the documented "the state bucket can live in any AWS region" guarantee.LockManagernow has its ownensureClientForBucket()(awaited at the top ofacquireLock/getLockInfo/releaseLock/deleteLock) mirroring the state backend's pattern with two deliberate differences: the replacement client reuses the original client's resolved credentials provider (so--profile/ static credentials carry over without threading client options through the 8new LockManager(...)call sites), and the original client is NOT destroyed (it is the sharedAwsClients.s3instance other components still hold).resolveBucketRegioncaches per bucket name, so when the state backend already resolved the same bucket the lock path adds no extraGetBucketLocationcall. The fix is contained entirely insideLockManager— none of the 8 call sites changed. Unit tests: region-mismatch rebuild (pre-fix 301 path — the PutObject goes through the rebuilt client), same-region no-rebuild, single resolution across multiple lock ops, and resolver receives the caller's credentials + fallback region. Thecross-region-state-bucketinteg fixture is now AUTOMATED: its newverify.shcreates a temporary uniquely-named state bucket inus-west-2, runs deploy / state ls / destroy withAWS_REGION=us-east-1, asserts state.json written + lock.json released in the cross-region bucket, and deletes the bucket at the end (EXIT trap covers failure paths) — previously the fixture was manual-only and its 2026-06-02 ledger PASS ran against the default same-region bucket, never exercising the scenario it is named after. Docs:state-management.md(State Bucket Region + Lock Mechanism),troubleshooting.md(lock-path 301 symptom + fix note).
Recently Implemented (2026-06-10):
- ✅
AWS::EC2::Instancesecurity-prop backfill: DisableApiTermination / MetadataOptions / Monitoring / EbsOptimized / CreditSpecification (issue #609) —src/provisioning/providers/ec2-provider.ts. Five security-focused properties that were silent-dropped pre-PR are now wired throughEC2Provider'screate()+update()+readCurrentState()and added tohandledPropertiesforAWS::EC2::Instance(the type stays open for the remaining ~26 props intests/fixtures/cfn-schemas/_todo-backfill.json). All five are mutable in-place, so each has anupdate()path diffed againstpreviousProperties(thecdkd drift --revertno-op round-trip stays free of mutating SDK calls):- DisableApiTermination — termination protection (pre-PR a silent-drop let a user believe the instance was protected when it was not). Rides on
RunInstancesat create;ModifyInstanceAttributeon update; readback via the existingDescribeInstanceAttribute(disableApiTermination)call. The destroy-side flip-off already lived inec2-termination-protection.ts. - MetadataOptions — IMDSv2 enforcement (
HttpTokens=required) mitigates SSRF credential theft.RunInstancesat create;ModifyInstanceMetadataOptionson update; reverse-mapped fromDescribeInstances .MetadataOptionson readback, excluding the AWS-managedStatefield to avoid false-positive drift. - Monitoring — detailed CloudWatch monitoring.
RunInstances{ Enabled }at create;MonitorInstances/UnmonitorInstanceson update; readback already mapped.Monitoring.Stateto a boolean. - EbsOptimized — dedicated EBS throughput.
RunInstancesat create;ModifyInstanceAttributeon update; readback emit-when-present. - CreditSpecification — T-family burstable CPU credit mode.
RunInstancesat create;ModifyInstanceCreditSpecificationon update; readback viaDescribeInstanceCreditSpecifications(best-effort: non-burstable families error and fall back to omitting the key). Accepts the canonical CFnCPUCreditskey and the SDK-styleCpuCreditskey. - CFn boolean-ish (
true/"true") and numeric (HttpPutResponseHopLimit) values are coerced at the wire boundary. Theec2-instanceinteg fixture is rewritten to author the instance as a raw L1ec2.CfnInstance: the L2ec2.Instanceconstruct always emits anAvailabilityZoneproperty (a cdkd silent-drop) which under the #614 routing rule flips the whole resource onto the Cloud Control path, bypassing the SDK backfill this slice verifies. The newverify.shasserts each prop reached AWS post-deploy and thatprovisionedBystayedsdk, then exercises the destroy path with--remove-protection(the instance is termination-protected). Tests: 14 create/update unit tests + 5 readback unit tests.
- DisableApiTermination — termination protection (pre-PR a silent-drop let a user believe the instance was protected when it was not). Rides on
Recently Implemented (2026-06-09):
✅ Property-coverage backfill (issue #609): wired 6 top-level properties on
AWS::EFS::FileSystemin one bundle —AvailabilityZoneName,LifecyclePolicies,BackupPolicy,FileSystemPolicy,BypassPolicyLockoutSafetyCheck, andFileSystemProtection— all previously silent-dropped byEFSProvider. One prop is deferred asunhandledByDesign:ReplicationConfiguration(cross-region EFS replication provisions a separate destination file system in another region with its own lifecycle / KMS key / AZ placement — a multi-resource, cross-region orchestration out of scope for the single-resource SDK provider; tracked as a #609 follow-up). With this slice,AWS::EFS::FileSystem's remainingsilentDropset is exactly{ ReplicationConfiguration }.AvailabilityZoneName(One Zone EFS) rides DIRECTLY onCreateFileSystemand is immutable —create()forwards it; a later change is routed through DELETE+CREATE by the replacement-detection layer (it is inupdateFileSystem's immutable-key reject guard alongsideEncrypted/KmsKeyId/PerformanceMode).readCurrentStatesurfaces it fromDescribeFileSystems.LifecyclePolicies/BackupPolicy/FileSystemPolicy(+BypassPolicyLockoutSafetyCheck) /FileSystemProtectioneach ride on a separate post-create control-plane API (PutLifecycleConfiguration/PutBackupPolicy/PutFileSystemPolicy/UpdateFileSystemProtection) — AWS rejects all four against a still-creating file system, so they run AFTER the create-timeavailablewait. They are wrapped in a newretryOnTransientControlPlanehelper (modeled on the DynamoDB provider's PITR/TTL retry) because back-to-back EFS control-plane ops collide withIncorrectFileSystemLifeCycleState/ConflictException/ "in progress".create()is atomic: a post-ACTIVE step failure best-effortDeleteFileSystems the just-created file system (modeled onDynamoDBTableProvider.create'stableCreatedrollback) so a half-built file system does not orphan + block the next deploy'sCreationToken.FileSystemPolicycasing/shape: the CFn property is a JSON policy object but the SDK'sPutFileSystemPolicy.Policyfield is a JSON string, so the providerJSON.stringifys an object value;readCurrentStateJSON.parses theDescribeFileSystemPolicy.Policystring back to an object so the drift comparator compares object-to-object.BypassPolicyLockoutSafetyCheckis a field ONPutFileSystemPolicy(not a standalone resource on AWS), so it wires together withFileSystemPolicy.update()applies each control-plane prop only onJSON.stringify-deep diff; aLifecyclePoliciesremoval clears all policies viaPutLifecycleConfiguration([]);BackupPolicy/FileSystemPolicy/FileSystemProtectionhave no clean CFn "drop" mapping so a pure removal is a deliberate no-op.readCurrentStateis emit-when-present for every prop (a phantom default would force guaranteed drift on the typical un-configured file system).- The 6 props move from
silentDroptohandledinproperty-coverage.generated.ts(regenerated viavp run gen:property-coverage). 15 new unit tests intests/unit/provisioning/providers/efs-provider.test.tscover the create-input ride (AvailabilityZoneName), each post-ACTIVE Put*/Update* apply, theJSON.stringifypolicy + Bypass forwarding, the transient-control-plane retry, the post-ACTIVE-failure rollback, update diffs (BackupPolicy apply, LifecyclePolicies removal-clears, no-op), and readback (all props surfaced + FileSystemPolicy JSON round-trip + PolicyNotFound omission). Real-AWS verified via the existingtests/integration/efs-standalone/fixture — its L2efs.FileSystemgainslifecyclePolicy/enableAutomaticBackups/replicationOverwriteProtection/fileSystemPolicy, and a NEWverify.shdeploys, asserts all four reached AWS (describe-backup-policy,describe-lifecycle-configuration,describe-file-systemsforFileSystemProtection,describe-file-system-policy), then destroys clean.
✅
cdkd local invoke/run-taskreach a server on the host viahost.docker.internal+ start-service/start-alb WARN dedup follows (issues #784 / #785 / #786 / #787) — bumpscdk-local^0.142.0->^0.147.0. The bump auto-inherits the start-service/start-alb fixes; #784 needed cdkd source work because cdkd keeps its OWNinvoke/run-taskcommand paths (it does NOT embed cdk-local'sinvoke/run-taskfactories).- #784 (cdk-local #483) —
host.docker.internalreachability oninvoke/run-task— REQUIRED cdkd code. A Lambda / ECS task container can now reach a server bound on the host loopback (anAWS_ENDPOINT_URL_*local endpoint, or a tunneled VPC resource) viahost.docker.internal. Docker Desktop resolves it natively; Linux native dockerd needs the explicit--add-host host.docker.internal:host-gateway(Docker 20.10+), silently skipped on an older / unavailable daemon (never throws). cdkd adopts cdk-local'sresolveHostGatewayExtraHosts()(re-exported viasrc/local/docker-version.tsalongsideHOST_DOCKER_INTERNAL_GATEWAY) intocdkd local invoke(threaded intorunDetached'sextraHosts) andcdkd local run-task(set onRunEcsTaskOptions.hostGatewayExtraHosts, merged with the Cloud Map peer-discovery--add-hostflags by the new puremergeHostGatewayAddHostFlagshelper inecs-task-runner.ts).start-service/start-albinherit the same reachability automatically from cdk-local's bundled ECS service emulator engine (cdkd'secs-service-emulator.tsis a re-export shim — no local resolve site). Tests: amergeHostGatewayAddHostFlagsunit suite + a source-level binding test (tests/unit/cli/host-gateway-extra-hosts-binding.test.ts) pinning the resolve + thread at each cdkd-owned run site — the reachability only differs on Linux, so a Docker-Desktop integ cannot catch a dropped wiring (per memoryfeedback_site_level_binding_test.md). Docs:local-emulation.md"Reaching a server on the host" note. - #785 (cdk-local #485) — start-service/start-alb same-stack-ECR boot WARN fires once, not twice — AUTO-inherited. cdkd's
start-service/start-albconsume cdk-local's ECS service emulator engine, so the dedup lands with the bump; no cdkd source change. - #786 (cdk-local #488) — start-service/start-alb listener
WARN: WARN:doubled-prefix collapses to one — AUTO-inherited. Same engine-inheritance path as #785; no cdkd source change. - #787 (cdk-local #490) — studio
pinUnresolvedbrowser hint — N/A for cdkd. cdkd does not embed cdk-local'sstudiocommand, so thiscreateLocalStudioCommand-only change has no cdkd surface. - The remaining cdk-local 0.143.x-0.147.0 commits (logger
WARN:/ERROR:prefix [#478] surfaces only through inherited-command warn output; studio readability passes; test-infra reverts) carry no cdkd-owned behavior change. Verified end-to-end via/run-integ local-invoke+/run-integ local-run-taskagainst real Docker (the host-gateway mapping is added on a host-gateway-capable daemon and the containers run cleanly).
- #784 (cdk-local #483) —
✅
cdkd local start-cloudfrontWARNs when--cache-originis set without--from-cfn-stack(issue #782) — bumpscdk-local^0.140.0->^0.142.0.cdkd local start-cloudfrontis a THIN pass-through to cdk-local'screateLocalStartCloudFrontCommandfactory, so cdk-local's #476 is inherited with no cdkd source-logic change — only the dep bump + thelocal-emulation.md--cache-origindoc line were updated. Behavior delta (cat 4 in #782):start-cloudfront ... --cache-originwith no--from-cfn-stackwas previously a fully silent no-op (--cache-originonly feeds the deployed-S3 read-through reader, which is built solely under--from-cfn-stack); it now logs one boot-time WARN (--cache-origin has no effect without --from-cfn-stack: ...). Non-fatal — no error, no exit-code change. cdkd does not post-process / match on start-cloudfront's stderr (thelocal-start-cloudfrontinteg verify.sh greps only the boot banner + specific GET response bodies/headers, and uses neither--cache-originnor--from-cfn-stack), so the new WARN never fires in the fixture and no test change was needed. The cdk-local 0.141.0 studio change (#472, auto-render editable controls) is irrelevant to cdkd (cdkd does not embed cdk-local'sstudiocommand); the rest of the 0.141.0 / 0.142.0 commits are test / docs / chore.✅
cdkd local start-cloudfront --kvs-fileaccepts a construct path / bare construct id (issue #780) — bumpscdk-local^0.139.0->^0.140.0.cdkd local start-cloudfrontis a THIN pass-through to cdk-local'screateLocalStartCloudFrontCommandfactory, so cdk-local's #467 is inherited with no cdkd source-logic change — only the dep bump + thelocal-emulation.md--kvs-filedoc line were updated. Behavior delta (cat 4 in #780): the<key>left-hand side of--kvs-file <key>=<file.json>previously HAD to be the hash-suffixedAWS::CloudFront::KeyValueStoreresource logical id, and an unrecognized key was silently ignored (the store stayed unbound and thecf.kvs()read failed at runtime). It now accepts the logical id, the construct path (MyStack/RoutesKvs), or the bare construct id (RoutesKvs) — normalized to the logical id before binding — and an unrecognized key (or an ambiguous bare id) now fails FAST with an error listing the distribution's KeyValueStore candidates. cdk-local also exportsnormalizeKvsFileKeysfromcdk-local/internal(cat 3 in #780) for a host building its own--kvs-fileflow; cdkd just wraps the command, so it does NOT consume it. The studio fixes in cdk-local 0.139.1 / 0.139.2 / 0.139.3 are irrelevant to cdkd (cdkd does not embed cdk-local'sstudiocommand). cdkd's unit test asserts only that--kvs-fileis a registered option (no assertion on the old silent-ignore behavior), so no test change was needed; thelocal-start-cloudfrontinteg fixture does not exercise--kvs-file(no KeyValueStore in the distribution).✅
cdkd local start-agentcorefollows cdk-local #454 (warm serve generalization) + #455 (CodeConfiguration build no-install) — issues #774 / #775 / #776 / #777 / #778 — bumpscdk-local^0.128.0->^0.139.0.cdkd local start-agentcoreis a THIN pass-through to cdk-local'screateLocalStartAgentCoreCommandfactory and everysrc/local/agentcore-*.tsmodule is a re-export shim overcdk-local/internal, so the entire serve generalization is inherited with no cdkd source-logic change — only the command's doc comment + the user docs (local-emulation.md/README.md) and thelocal-start-agentcoreinteg verify.sh were updated. Behavior deltas inherited (verified end-to-end via/run-integ local-start-agentcoreagainst real Docker):- #775 (slice 1, cdk-local#458) — warm HTTP serve. The container boots once and stays warm; HTTP / AGUI runtimes now serve
POST /invocations+GET /ping(proxied to the warm container, session-id / boot-resolvedAuthorizationinjected, request/response incl. SSE streamed) alongside the/wsbridge, both on the same host port. A newHTTP contract served on http://...ready line is printed; the existingServer listening on ws://...line is kept verbatim. (Was:/ws-only.) - #776 (slice 2, cdk-local#459) — MCP + A2A warm serve. MCP runtimes serve
POST /mcp(container port 8000), A2A servePOST /(port 9000), with no/wsbridge. (Was: rejected up front withLOCAL_START_AGENTCORE_PROTOCOL_UNSUPPORTED.) cdkd never special-cased the old rejection, so nothing to drop. - #777 (slice 4a, cdk-local#461) — per-request inbound JWT +
--sigv4. AcustomJwtAuthorizerruntime now boots without a token and verifies each contract request'sAuthorizationper request (401 missing / 403 invalid / forwarded on pass;GET /pingunauthenticated);--bearer-tokenis the default-when-missing fallback.--sigv4(new flag, auto-inherited viaaddStartAgentCoreSpecificOptions) signs each forwarded request with AWS SigV4 (servicebedrock-agentcore) when nocustomJwtAuthorizeris declared; mutually exclusive with--bearer-token. (Was: boot-time--bearer-tokenvalidation, rejected at boot if missing.) - #778 (slice 4b, cdk-local#462) —
--watch. New flag (auto-inherited) re-synths + reloads the warm container in place on a CDK source change, keeping the host serve up (per-firing rebuild / soft-reload classifier, the same machinery asinvoke-agentcore --ws --watch). (Was: ran until^Cwith no reload.) - #774 (cat 4, cdk-local#455 / cdk-local#456) —
CodeConfigurationbuilds no longer install deps. ThefromCodeAsset/fromS3source build (buildAgentCoreCodeImage, shared by bothinvoke-agentcoreandstart-agentcore) now runs the bundle as-is — nopip install/npm install— matching the AWS managed runtime, which resolves deps vendored into the bundle at deploy time. A bundle declaring a dependency manifest without vendored deps now fails locally withModuleNotFoundErrorthe same way it fails deployed (instead of passing locally only because of the local install), and cdkd emits a warning with the vendoring recipe. Container artifacts (fromContainerAsset/fromEcr) are unaffected. - Tests:
tests/integration/local-start-agentcore/verify.shextended to probe the new warm HTTP contract (theHTTP contract served on http://...ready line,GET /ping-> 200,POST /invocationsecho round-trip with the bridge-injected session-id) and a second--sigv4boot asserting the forwarded request carries anAWS4-HMAC-SHA256Authorizationheader — on top of the existing header-less/wsbridge probe. No unit-test change (all behavior is upstream-owned + upstream-tested; cdkd's surface is the unchanged factory pass-through).tests/unit/cli/local-start-agentcore.test.tscontinues to assert the cdkd--from-state/--state-bucket/--state-prefixflags + the inherited option block.
- #775 (slice 1, cdk-local#458) — warm HTTP serve. The container boots once and stays warm; HTTP / AGUI runtimes now serve
Recently Implemented (2026-06-05):
✅
cdkd local invoke/cdkd local start-apipin a ZIP Lambda's--platformto its declaredArchitectures(issue #768) — follows cdk-local's #428 for cdkd's OWN local-execution paths. Before: cdkd's ZIP container run never set--platform(only the IMAGE path did —lambda-resolver.tscapturedArchitecturesonly on the IMAGE variant), so a ZIP container ran at the host's native arch; aprovided.*custom-runtimebootstrapcompiled for the other architecture failed withfork/exec /var/runtime/bootstrap: exec format error/Runtime.InvalidEntrypointon an arch-mismatched host. After:ResolvedZipLambda/ResolvedStartApiZipLambdacarryarchitecture(parsed by a sharedextractArchitecture/extractStartApiArchitecturehelper, defaultx86_64,arm64honored, unsupported values rejected), and both thecdkd local invokeZIP plan (resolveZipImagePlan) and thecdkd local start-apiwarm-container spec threadarchitectureToPlatform(architecture)todocker run --platform, so Docker emulates the function's declared arch. This was NOT auto-inherited from the cdk-local bump — cdkd does not embed cdk-local'sinvoke/start-apifactories; those paths are cdkd-local code (lambda-resolver.ts/local-invoke.ts/local-start-api.ts). (cdkd local start-alb/start-cloudfrontuse cdk-local's engine / factory and already inherited #428 via the pinned cdk-local 0.126.6.) Tests: ZIP-arch capture (lambda-resolver.test.tsarm64 / default-x86_64 / reject;local-start-api-container.test.tssame for the start-api resolver) + the ZIP plan--platformthreading (local-invoke-zip-platform.test.ts). Verified end-to-end via/run-integ local-invoke-provided.✅
cdkd local start-cloudfront --from-state— closes the start-cloudfront half of issue #766 — bumpscdk-local^0.126.6->^0.128.0and threads cdkd's S3-backed--from-statefactory into thestart-cloudfrontpass-through, mirroringstart-agentcore/start-alb/start-service. cdk-local 0.128.0 (go-to-k/cdk-local#426 / #436) added theextraStateProvidersseam toCreateLocalStartCloudFrontCommandOptions(the factory now passes it through to its two internalcreateLocalStateProvidercalls — the KVS resolver + the S3-origin/Function-URL resolver), whichstart-cloudfrontpreviously lacked (the reason it shipped--from-state-exempt in the start-agentcore PR #767).src/cli/commands/local-start-cloudfront.tsnow passes{ embedConfig, extraStateProviders: cdkdExtraStateProviders }to the factory and adds the cdkd-specific--from-state/--state-bucket/--state-prefixflags on top of cdk-local's inherited--from-cfn-stack/--stack-region/--assume-role. So a CloudFront distribution's Lambda Function URL origin (backing Lambda) and deployed-S3 origin (bucket name) can be bound to cdkd-managed state after acdkd deploy, not only to a CloudFormation stack. The two state sources stay mutually exclusive (enforced by cdk-local'screateLocalStateProvider). Tests:tests/unit/cli/local-start-cloudfront.test.tsflips its "exempt from #766" assertions to assert the three cdkd flags are present + defaulted (--from-statefalse,--state-prefixcdkd); the dispatcher-wiring comment now groupsstart-cloudfrontwith the other three factory-seam pass-throughs. Docs (local-emulation.md/README.md/.claude/rules/code-layout.md) drop the--from-state-exemption language. Verified via thelocal-start-cloudfrontinteg (the command boots + serves cleanly on cdk-local 0.128.0; the--from-statesubstitution path is the shared cdk-local mechanism already real-AWS-verified bylocal-start-alb-from-state). No cdkd source change beyond the wrapper + the dep bump.✅ Bump
cdk-local^0.126.0->^0.126.6so the factory pass-throughlocalcommands read aws-cdk-lib 2.258.0 (cloud-assembly schema v54) — aws-cdk-lib2.258.0(released 2026-06-04) bumped the cloud-assembly schema to v54. The cdk-local-factory-basedlocalcommands (start-agentcore/start-cloudfront/start-alb/start-service) synth through cdk-local's toolkit-lib-basedSynthesizer, which (via@aws-cdk/toolkit-lib@1.26.2->cloud-assembly-schema@53.27.0, max v53) rejected v54 withAssemblyVersionMismatch: Maximum schema version supported is 53.x.x, but found 54.0.0. cdkd's owndeploy/synthand the cdkd-implementedlocalcommands (invoke/start-api/run-task/invoke-agentcore) were never affected — cdkd's coreSynthesizeris self-implemented (readsmanifest.jsondirectly with no schema validation) and tolerates v54 (verified by synthesizing a 2.258.0 app throughcdkd synth). The fix is upstream in cdk-local (go-to-k/cdk-local#430 / #431, released as cdk-local 0.126.6): bump@aws-cdk/toolkit-libto^1.28.0(cloud-assembly-schema >=54.2.0) + align@aws-cdk/cloud-assembly-apito^2.2.5. cdkd inherits it by bumping thecdk-localfloor to^0.126.6(brings@aws-cdk/toolkit-lib@1.28.0into cdkd's tree; cdkd'smanifest.json-direct reader needs no cloud-assembly-api dedup of its own). Thetests/integration/local-start-agentcore/fixture's interimaws-cdk-libpin (~2.257.0, added in the start-agentcore PR to dodge the v54 break) is relaxed back to^2.257.0so it floats to current aws-cdk-lib — verified end-to-end: the fixture now resolves aws-cdk-lib 2.258.0 andcdkd local start-agentcoreserves/wsthrough the bridge cleanly (/run-integ local-start-agentcore, 0 container leaks). No cdkd source change — a dependency bump + fixture unpin.✅
cdkd local start-agentcore+--from-statefor the factory pass-throughs (issues #765 / #766) — bumpscdk-local^0.106.0->^0.126.0and adds the long-running serve counterpart ofcdkd local invoke-agentcore.cdkd local start-agentcore [target]boots the Bedrock AgentCore Runtime container (same image / env / credential resolution asinvoke-agentcore) and fronts its bidirectional/wsWebSocket endpoint with a host WebSocket bridge that injects the AgentCore session-id (and, under acustomJwtAuthorizer, theAuthorizationheader) on the container upgrade — so a header-less client (e.g. a browserWebSocket, which cannot set custom upgrade headers) can hold an interactive multi-frame session. HTTP / AGUI protocols only (MCP / A2A runtimes have no/ws). Newsrc/cli/commands/local-start-agentcore.tsis a THIN pass-through to cdk-local'screateLocalStartAgentCoreCommandfactory (cdk-local#420, released in cdk-local 0.125.0); cdkd re-hands the active embed config (so branding stays cdkd) and — UNLIKEstart-cloudfront— threads its S3-backed--from-statefactory through the factory'sextraStateProvidersseam, layering the cdkd-specific--from-state/--state-bucket/--state-prefixflags on top of cdk-local's inherited--from-cfn-stack/--stack-region. Registered increateLocalCommand()betweeninvoke-agentcoreandstart-alb. The agentcore-specific option block (--port/--host/--session-id/--bearer-token/--no-verify-auth/--env-vars/--platform/--no-pull/--no-build/--container-host/--timeout/--assume-role/--ecr-role-arn) auto-inherits from cdk-local'saddStartAgentCoreSpecificOptions. The studioagentcore-wsserve kind (cdk-local 0.126.0 / cdk-local#422) spawnscdkl start-agentcore, but cdkd does NOT embed cdk-local'sstudiocommand, so that surface is not exposed by the cdkd CLI (no cdkd-side wiring needed). Tests:tests/unit/cli/local-start-agentcore.test.ts(subcommand name, optional single positional target, inherited agentcore + CFn state-source flags, the cdkd--from-state/--state-bucket/--state-prefixdeclarations + defaults, flag parsing). New integ fixturetests/integration/local-start-agentcore/(adapted from cdk-local's): builds the EchoAgent container from a local Dockerfile, bootscdkd local start-agentcore --port 0, connects a header-less Node global-WebSocketprobe (browser path), asserts the bridge injects a session-id + a second frame round-trips through the bridge (loop-echo:<text>), then SIGTERMs and asserts nocdkd-local-agentcore-*container leaks. Verified end-to-end via/run-integ local-start-agentcore.⚠️
cdkd local start-cloudfrontgains Lambda Function URL + deployed-S3 origins (inherited from the cdk-local bump, cdk-local#380);--from-statestays exempt (#766) — the^0.106.0->^0.126.0cdk-local bump changes the thin-pass-throughstart-cloudfront's surface: it now serves a distribution's Lambda Function URL origins (the backing Lambda runs locally via the RIE container, so Docker is required for that case) and its deployed-S3 origins, and so inherits cdk-local's--from-cfn-stack/--stack-region/--assume-rolestate-source flags +--kvs-file/--cache-origin/--no-pull. It is no longer "pure-local, no AWS call" — a CloudFront-Functions + S3-origin-only distribution still serves fully in-process (no Docker), but a Function-URL-fronted distribution does not. cdkd does NOT wire its S3-backed--from-stateintostart-cloudfront: cdk-local'sCreateLocalStartCloudFrontCommandOptionsaccepts onlyembedConfig, not theextraStateProvidersseam, sostart-cloudfrontstays exempt from #766 until cdk-local exposes that seam (decided with the user; thestart-agentcore/start-alb/start-servicepass-throughs DO thread--from-state). The command's doc comment +tests/unit/cli/local-start-cloudfront.test.tswere updated to the new contract (asserts the inherited CFn flags are present AND cdkd's--from-state/--state-bucket/--state-prefixare absent), and thelocal-emulation.md/README.md/cli-reference.md"no Docker / no state binding" claims were corrected. No other cdkd-owned surface broke on the 20-version bump (typecheck + full unit suite clean).
Recently Implemented (2026-06-02):
✅
destroy --remove-protectionclears EC2DisableApiTerminationon BOTH the SDK and Cloud Control delete paths, retrying through the flip-off propagation race — fixes a realdestroy --remove-protectionfailure surfaced by theremove-protectioninteg. cdkd flipsDisableApiTerminationoff (ModifyInstanceAttribute) and then deletes the instance, but AWS's modify WRITE lags the delete READ, so the delete 400s withThe instance ... may not be terminated. Modify its 'disableApiTermination' instance attribute and try again.even though cdkd just cleared it (empirically: a manualmodify-instance-attribute --no-disable-api-terminationreports success anddescribe-instance-attributereadstruefor ~25s, yet aterminate-instancesimmediately after succeeds — the attribute READ is eventually consistent). cdkd's fast SDK path outruns the propagation window, exactly like the IAM / Route53 races elsewhere. Crucially, anAWS::EC2::Instanceis frequently routed through Cloud Control (its template trips the #614 silent-drop routing — confirmed viaprovisionedBy: cc-apiin the integ's state), andCloudControlProvider.deletehad NODisableApiTerminationhandling at all — so the original SDK-onlyEC2Provider.deleteInstanceflip-off never ran for the integ's instance. The fix adds a sharedsrc/provisioning/ec2-termination-protection.tshelper (disableInstanceApiTermination+isTerminationProtectionPropagationError+TERMINATION_PROTECTION_MAX_ATTEMPTS) used by BOTHEC2Provider.deleteInstance(SDK path) andCloudControlProvider.delete(CC-API path): whencontext.removeProtection === trueand the type isAWS::EC2::Instance, flip the attribute off, then retry the delete up to 5 times with increasing backoff (re-flipping each attempt) to close the propagation window. The 400 is deliberately NOT in the generic retryable set — a protected instance destroyed WITHOUT--remove-protectionmust fail fast so the user is told to pass the flag — so the retry is gated onremoveProtection === true. Without this, the un-terminable instance blocked the entire VPC teardown (the IGW / VPCGatewayAttachment then hit their own 6m/30m delete timeouts). Tests:tests/unit/provisioning/ec2-provider-instance-protection-retry.test.ts(SDK path: retry-then-succeed re-flips each attempt; no---remove-protectionfails fast with no flip-off; non-protection error fails fast; gives up after the 5-attempt budget) +tests/unit/provisioning/ec2-termination-protection.test.ts(the shared helper's flip-off send + error-classification). Verified end-to-end via theremove-protectioninteg (whose instance is CC-API-routed).✅ CLI exits cleanly when a downstream consumer closes stdout/stderr early (EPIPE) — piping any cdkd command into a reader that stops reading (
cdkd state list | grep -q foo,... | head,... | lessthenq) closes the pipe while cdkd is still writing; Node then emitted an unhandled'error'(EPIPE) on the stream and the process crashed with a stack trace + non-zero exit. That is normal Unix behavior for the consumer to stop reading, so the CLI must treat it as success. NewinstallPipeCloseHandler()(src/cli/pipe-close-handler.ts, called once at the top ofmain()insrc/cli/index.ts) attaches an'error'listener toprocess.stdout/process.stderrthatprocess.exit(0)s on EPIPE and re-throws every other (real) stream error unchanged. Surfaced by theremove-protectioninteg, whosecdkd state list | grep -q <stack>assertion crashed cdkd on EPIPE and the test misread the non-zero exit as a "state stripped despite destroy failing" failure (the state was actually preserved correctly —grep -qclosing the pipe after its first match was the real cause). Tests:tests/unit/cli/pipe-close-handler.test.ts(EPIPE → exit 0; non-EPIPE → re-throw; handler installed on every supplied stream). Verified end-to-end via theremove-protectioninteg.✅
Route53::HostedZonedestroy waits through the*_HOSTED_ZONE_LOCKEDaccelerated-recovery transients instead of bailing — fixes a destroy failure + orphan surfaced by the 2026-06-02 regression sweep (fixtureroute53). A hosted zone deployed withHostedZoneFeatures.AcceleratedRecoveryStatus: 'ENABLED'must have the feature disabled beforeDeleteHostedZoneis accepted; the pre-delete guardensureAcceleratedRecoveryDisabledForDelete(insrc/provisioning/providers/route53-provider.ts) issuesUpdateHostedZoneFeatures(false)and pollsGetHostedZoneuntil the status settles toDISABLED. The bug: the enable/disable transition briefly surfacesENABLING_HOSTED_ZONE_LOCKED/DISABLING_HOSTED_ZONE_LOCKED(AWS momentarily locks the zone mid-transition), and these were lumped into theTERMINAL_FAILEDset alongside the genuinely-failedENABLE_FAILED/DISABLE_FAILED— so the destroy bailed withoperator must resolvethe moment it observed a lock transient, even though the zone settles toDISABLEDon its own within seconds (confirmed via manual cleanup: the real zone transitionedDISABLING → DISABLING_HOSTED_ZONE_LOCKED → DISABLED). Fix:TERMINAL_FAILEDnow contains ONLYENABLE_FAILED/DISABLE_FAILED; the*_HOSTED_ZONE_LOCKEDstates are treated as in-flight sub-states — the Phase-1 enabling-settle wait fires onENABLINGORENABLING_HOSTED_ZONE_LOCKED, the Phase-2 already-disabling skip fires onDISABLINGORDISABLING_HOSTED_ZONE_LOCKED, and thewaitForpoll loop polls through any lock transient like any other non-target status until it reachesENABLED/DISABLED(or the existing env-overridable timeout). Genuinely-failed states still hard-fail immediately with the manual-recovery pointer. Tests: two new cases intests/unit/provisioning/route53-provider.test.ts(waits throughDISABLING_HOSTED_ZONE_LOCKED→DISABLEDbeforeDeleteHostedZone; waits through an initialENABLING_HOSTED_ZONE_LOCKED→ENABLED→ disable →DISABLED). Real-AWS verified via theroute53integ: deploy enables accelerated recovery, destroy now disables + waits through the lock transients + deletes clean (was a hard FAIL + manual cleanup before).✅
AWS::SSM::Parameterdeploy no longer crashes onTags(CFn SSM Tags is a key->value MAP, not a list) — fixes a hard deploy failure surfaced by the 2026-06-02 regression sweep (fixturescontext-testANDinfra-security, both never-run integs until this session). Any SSM Parameter with tags failed to create withFailed to create SSM parameter <id>: properties.Tags.map is not a function. Root cause: unlike almost every other CFn resource (whoseTagsis a[{Key,Value}]list),AWS::SSM::Parameter.Tagsis a key->value map ({ "Env": "prod" }) — CDK synthesizes the map form, andSSMParameterProvider.create()/update()didproperties['Tags'].map(...), which throws because an object has no.map. The bug was never caught because the provider's unit tests + the only SSM-with-tags integ fixtures used the (wrong) list shape, andcontext-test/infra-securityhad never been run as integs. Fix: acfnTagsToSdkTags()helper normalizes the CFn value into the SDK{Key,Value}[]shape, accepting BOTH the map (canonical) and the list (defensive), coercing non-string values to strings (SSM tag values must be strings), and droppingaws:-prefixed reserved keys;create()/update()route through it.readCurrentState()now ALSO emitsTagsas the map shape (matching the template shape cdkd stores in state) instead of the{Key,Value}[]list — an array readback would false-positivecdkd drifton every clean run for a tagged parameter (state map vs observed list never compare equal). Tests: newtests/unit/provisioning/ssm-parameter-provider-tags-map.test.ts(create accepts the map shape + applies it as SDKTag[]; defensive list-shape still works;aws:*keys dropped; empty map fires noAddTags; non-string values coerced; update diffs map shapes for add/remove; unchanged map is a no-op) + updatedssm-parameter-provider-readcurrentstate.test.tsassertions to the map shape. Real-AWS verified via thecontext-testinteg (deploy+destroy clean). The list-shape create input the existing partial-create-cleanup / roundtrip tests use is still accepted, so the (incorrect-but-tolerated) list form does not regress.✅ Custom Resources retry on transient IAM-authorization failures (CR-internal retry + exec-env recycle) — fixes a hard deploy failure surfaced by the 2026-06-02 regression sweep (fixture
custom-resource-provider). A CDKcr.Provider-framework custom resource failed to create with403 lambda:GetFunction ... no identity-based policy allowseven though the framework role's inline policy (which DOES grant it — present since aws-cdkv2.178.1/ aws-cdk#26838) was deployed byte-correct. Root cause is NOT a missing permission or a statement-drop: cdkd's fast SDK path attaches the role's inline policy and creates+invokes the backing Lambda ~0.7s later, so the function cold-starts before IAM propagates the policy to its assumed-role session and caches stale, policy-less credentials for the warm container's whole life — the framework's first invoke /waitUntilFunctionActivethen 403s. CloudFormation never hits this because its deployment latency lets IAM settle (confirmed: theoutbound.jsframework runtime is byte-identical across 2.250.0 -> 2.257.0, so a version bump does NOT fix it;SimulatePrincipalPolicyis NOT a valid signal either — it reportsallowedwhile the live assumed-role session still 403s, because IAM's policy-evaluation store and STS credential vending propagate independently). Fix:invokeCustomResourceWithRetry()insrc/provisioning/providers/custom-resource-provider.tsre-invokes (default 2 retries;CDKD_CR_AUTHZ_MAX_RETRIES, 0 disables) when the FAILED reason matches a NARROW IAM-authz signal set (CR_TRANSIENT_AUTHZ_SIGNALS:not authorized to perform/no identity-based policy allows/not in the state functionActive/cannot be assumed/is unable to assume— generic timeouts and handler bugs are deliberately NOT retried, so genuine failures still surface fast). Each retry derives a fresh pre-signed URL/RequestId (preserving thedisableOuterRetryinvariant that guards against stranding a response at an unpolled S3 key) AND recycles the backing function's execution environment via a no-opUpdateFunctionConfigurationso the next cold start re-assumes the role with the now-propagated policy (a plain re-invoke would reuse the same stale warm container). This is the CR-path analogue of the IAM-propagation retry cdkd'swithRetryalready applies to every other resource — the CR path opts out ofwithRetry(disableOuterRetry) so it retries internally instead. Tests:tests/unit/provisioning/custom-resource-provider-authz-retry.test.ts(retry-then-succeed, no-retry-on-generic-FAILED, give-up-after-max,=0disables, narrow classifier). Real-AWS verified via thecustom-resource-providerinteg: deploy 37s (attempt 1 -> 403 -> recycle -> attempt 2 created), destroy 17 deleted / 0 errors / 0 orphans (was a hard FAIL before).✅
destroyretries the transient Lambda EventSourceMapping "in use" delete error — fixes a partial-destroy + orphan surfaced by the 2026-06-02 regression sweep (fixturemulti-resource). Deleting an SQS EventSourceMapping on destroy could fail withCannot delete the event source mapping because it is in use.— a transient AWS state-lifecycle lock during teardown that clears on its own (a manualcdkd destroyre-run succeeded). Root cause:runDestroyForStackinsrc/cli/commands/destroy-runner.tscarried its OWN inline 4-pattern retryable list (Too Many Requests/has dependencies/can't be deleted since/DependencyViolation) and did NOT use the sharedisRetryableTransientErrorclassifier, so the ESM in-use error matched nothing and failed fast. Fix routes the destroy retry decision throughisRetryableTransientError(plus an explicitToo Many Requestskeep, since 429$metadatacan be lost across theProvisioningErrorwrap) and adds thebecause it is in usemessage pattern tosrc/deployment/retryable-errors.ts— matched on the message substring (narrow to the transient delete case) rather than the bareResourceInUseExceptionname, which the SDK also throws for non-transient create conflicts. The provider'sdelete()is already wrapped in a retry loop on both destroy paths (deploy-engine + destroy-runner), so no provider source changed. Tests: a retryable ESM-in-use case + a NOT-retryableResourceNotFoundguard (no over-broadening) inretryable-errors.test.ts, and alambda-eventsource-providerdelete case (throw-in-use → wrappedProvisioningError→ classified retryable). Real-AWS verified:multi-resourcenow destroys clean in a singledestroyrun (previously needed a manual re-run).✅
deploy --all/destroy --allorder stacks by cross-stack references (Fn::ImportValue/Fn::GetStackOutput), not just manifestaddDependency— fixes a real failure surfaced by the 2026-06-02 regression sweep. Previously--allordered stacks ONLY by the cloud-assembly manifest's declared dependencies (CDKaddDependency). A stack linked to another ONLY via a RAWcdk.Fn.importValue('<name>')/Fn::GetStackOutput(noaddDependency) created no manifest dependency, so under the default--stack-concurrency 4the consumer deployed before the producer and failed:deploy --allerroredFn::ImportValue: export 'X' not found/Fn::GetStackOutput: stack 'Y' not found, anddestroy --alldestroyed the producer before the consumer (StackHasActiveImportsError-> partial destroy + orphan). Newsrc/analyzer/cross-stack-deps.tsinferCrossStackStackDeps(stacks)derives consumer->producer edges from the synthesized templates (mapexportName -> producerStackfrom every stack'sOutputs[*].Export.Name; match literalFn::ImportValueexport names + read eachFn::GetStackOutput{StackName}target; edges only between stacks both in the set; non-literal / external / self refs ignored, so resolution of already-deployed external exports via the runtime index is unchanged).deploy.tsunions these withstack.dependencyNamesat both--allsites (auto-include walk + inter-stack DAG edges, in-set guard preserved);destroy.tsreverse-sorts (consumer before producer, exportedorderConsumersBeforeProducers, guarded to the synth path so the state-only fallback keeps original order). ManifestaddDependencybehavior is byte-unchanged when there are no raw cross-stack refs; runtime intrinsic resolution untouched. Unit tests:tests/unit/analyzer/cross-stack-deps.test.ts+tests/unit/cli/destroy-order-consumers.test.ts. Real-AWS verified:multi-stack-deps(Fn::ImportValue) +cross-stack-references(Fn::GetStackOutput) now deploy/destroy clean via--all(producer-first deploy, consumer-first destroy, 0 orphans) — both FAILED before. Known minor: a pathological mutual raw-import (A<->B, unbuildable on AWS) now surfaces as the WorkGraph generic "Deadlock detected" rather than a cross-stack-cycle-specific message.✅ Integ-run ledger (
docs/_generated/integ-last-run.tsv) +/pick-integskill — a committed (NOT gitignored), update-type ledger records, one row per integration test, when it last ran (last_run_iso), itsresult(PASS/FAIL),duration_s,flow(verify.sh / standard), and a shortnote./run-integnow has a MANDATORY step 13 that writes the ledger on EVERY run (pass or fail) using a portable awk update (drop the test's old row + append the new one — NOTgrep -P, which is unavailable on macOS BSD grep, a trap hit while building this). Superseded 2026-07-20 by issue #1112: the drop-and-append shape duplicated rows under rebase (the replayed append landed on a base that already carried the row), so the recipe is now append-then-vp run integ-ledger-normalize, with CI enforcing the normalized shape. The ledger answers "has this integ run recently? / it hasn't run in months, it's risky to trust" without trawling CI history, and is the input to the new/pick-integskill: it ranks tests by staleness (older than the 14-day integ-gate TTL window), last result (FAIL / never-run), and the code areas a recent diff touches (a path→test heuristic table — cross-cutting deploy/destroy → BROAD set,src/provisioning/providers/<Svc>*→ that service's integ,src/local/**→local-*,src/state/**→ schema-migration + cross-stack, etc.), then prints a prioritized/run-integplan (P0 changed+stale, P1 changed+green, P2 hygiene). No new markgate gate was added — the mandatory/run-integstep plus the committed file (a PR that ran integ but skipped the ledger is visible in review) plus/pick-integtreating absent/old rows as stale make enforcement unnecessary. The ledger is seeded with a 2026-06-02 broad regression sweep of 35 tests (29 PASS) — see the sweep findings below.✅
cdkd local invoke-agentcore --watch— re-synth + reload the agent container on CDK source edits, following cdk-local#270. cdk-local'srunAgentCoreWatchLoophard-couples to cdk-local's OWNSynthesizer/LocalInvokeAgentCoreOptionstypes (it lives inside cdk-local's own command), so it cannot be shimmed; instead cdkd owns a watch loop (src/local/invoke-agentcore-watch-loop.ts) built on cdk-local's already-exported watch primitives (createFileWatcher/createWatchPredicates/resolveWatchConfig/classifySourceChange+ theReloadVerdict/ReloadAssetContexttypes) — the SAME patterncdkd local start-api --watchuses. A per-firing classifier picks the reload primitive: an interpreted-language source edit inside aCodeConfigurationsource tree takes a soft-reload FAST PATH (docker cpthe freshly-synthed source into the running container's WORKDIR +docker restart, no rebuild, container ID + host port preserved), while a Dockerfile / compiled-source / asset-hash-changed / ambiguous edit (or afromS3/ non-CDK-asset runtime, or any classifier-context failure) forces a full rebuild (SIGTERM +docker rm -f+ re-resolve the image + freshdocker run).--watchapplies to BOTH the--wssession path (the active socket is closed cleanly on each reload via the abort signal, then re-opened against the new container) AND the default one-shotPOST /invocations(the reload re-runs the single shot — cdkd extends the loop here; cdk-local treats single-shot HTTP as a no-op WARN). For MCP / A2A runtimes--watchis a no-op WARN and the single shot proceeds (those protocols run once and exit with no reconnect surface). Reloads are chain-serialized (no parallel reloads); a reload-callback failure exits the loop cleanly instead of blocking on a stale port. Plumbing: the cold-boot container sequence (image resolution → env build →runDetached→ log stream) is hoisted into the exportedbootAgentCoreContainer(...)so the rebuild callback re-runs it against a fresh synth; the existing one-shot--ws//invocationsbehavior is byte-for-byte unchanged (the watch path is purely additive).loadAgentCoreAssetContext+deriveOldAssetHashare NOT exported fromcdk-local/internalso they are copied into the cdkd module (verified againstnode_modules/cdk-local/dist/internal.d.ts). New--watchOption (default false) registered near--ws;watch?: booleanadded toLocalInvokeAgentCoreOptions. Unit tests:tests/unit/local/invoke-agentcore-watch-loop.test.ts(14 cases — classifier soft-reload vs rebuild dispatch, classifier-failure rebuild fallback, reload-chain serialization, clean WS abort on reload, rebuild-failure loop exit, benign-close exit, thesoftReloadAgentContainerdocker-cp + restart wiring, theisAgentCoreWatchEligibleMCP/A2A no-op predicate, and flag registration). Thelocal-invoke-agentcoreintegverify.shgains a--watchscenario (Test 21): open a long-lived--ws --watchsession against the EchoAgent in loop mode, edit the agent source to inject a unique marker, and assert the watcher logs a reload verdict + the re-opened session surfaces the new marker.⚠️ BREAKING (
cdkd local invoke-agentcore): the--ws-interactiveflag is removed;--wsnow auto-detects a TTY and enables the interactive REPL automatically, following cdk-local (cdk-local#274 / cdk-local#278). When stdin is a TTY, lines typed after the initial--eventframe are sent as follow-up text frames (one per line, blank lines skipped), and each received frame is printed with a trailing newline + a'> 'prompt — a multi-turn REPL on the same/wsconnection. When stdin is piped / redirected (CI), only the initial frame is sent and output stays wire-faithful (no extra newlines / prompts) — the one-shot behavior. Migration: users who passed--ws-interactivemust drop it (REPL is now implicit in a TTY); to force one-shot inside a TTY, redirect stdin from/dev/null(cdkd local invoke-agentcore <t> --ws </dev/null). Plumbing: dropped thewsInteractiveoption field, the--ws-interactiveOption registration, and thewsInteractive && !wswarn guard; the--wsbranch computesconst interactive = process.stdin.isTTY === trueand threads it through frameSource creation + the new exportedwrapWsOnMessage(sink, interactive)helper (+WS_REPL_PROMPT = '> ');readStdinLines()now skips strictly-empty lines.--watchon/ws(cdk-local#270) was deferred from this PR (cdk-local does not export itsrunAgentCoreWatchLoop, which lives inside cdk-local's own command tightly coupled to its synth / image-build / container-lifecycle internals) — it shipped in the follow-upcdkd local invoke-agentcore --watchentry above (a cdkd-owned watch loop on top of cdk-local's exported watch primitives, not a shim ofrunAgentCoreWatchLoop). Unit tests: 7 new cases forwrapWsOnMessage(interactive newline+prompt / non-interactive identity / no double-newline) andreadStdinLines(skips empty, keeps whitespace-only) intests/unit/cli/local-invoke-agentcore-pure-helpers.test.ts. Thelocal-invoke-agentcoreintegverify.shTest 18 was rewritten to drop--ws-interactive(piped stdin = non-TTY = one-shot: asserts the initial ack is present and the piped lines do NOT become follow-up frames).docs/local-emulation.mdupdated.✅
cdkd local start-api --assume-role-auto— ports cdk-local'sstart-api --assume-role-autoflag (cdk-local#271, issue #256 Option 1) into cdkd's OWN start-api command (cdkd does not use cdk-local's start-api command, so the flag is not auto-inherited by the bump). The bare boolean auto-resolves EACH routed Lambda's own execution role per-Lambda instead of a single global default: per-Lambda boot tries the synthesized template's literal-ARNProperties.Rolefirst, then falls back to a deployed-state lookup (resolveExecutionRoleArnFromState, reused fromlocal-invoke.ts), and warns-and-passes-through to the developer's shell credentials when neither recovers the ARN. Precedence: per-Lambda override (--assume-role <LogicalId>=<arn>) > (--assume-role-autoOR global default--assume-role <arn>) > unset.--assume-role-autois mutually exclusive with the global-default--assume-role <arn>form (errors at boot via the newnormalizeStartApiAssumeRoleguard insrc/cli/options.ts) but compatible with per-Lambda--assume-role <LogicalId>=<arn>overrides (the map wins for named Lambdas, auto-resolve handles the rest). Plumbing:AssumeRoleOptiongainsbareAutoResolve?: boolean;LocalStartApiOptionsgainsassumeRoleAuto?: boolean; new exportedresolveStartApiAssumeRoleArn(...)replaces the bareeffectiveAssumeRoleArn(...)call inbuildContainerSpec.assumeLambdaExecutionRoleis unchanged (region-only). New unit testtests/unit/cli/local-start-api-assume-role-auto.test.ts(15 cases) covers the normalization guard, the full resolver precedence, the literal-ARN + state-lookup + miss paths, and flag registration. Slower boot (one STS call per Lambda) but the right shape when each Lambda's deployed role differs.✅
cdk-localbumped 0.69.0 → 0.77.1 — cdkd follows the upstream local-emulation engine forward. The bulk of the delta is auto-inherited through thecdk-local/internalleaf-module shims and the ECS service-emulator option helpers (addStartServiceSpecificOptions/addAlbSpecificOptions) thatcdkd local start-service/start-albalready call, so the new behavior lands with no cdkd.addOption(...)duplication. Newly inherited oncdkd local start-service/cdkd local start-alb: the--image-overridefamily (--image-override <target>=<imageRef|dir|Dockerfile>plus per-service--image-build-arg/--image-build-secret/--image-targetvariants — pin or locally build a replica's image instead of the deployed registry tag; cdk-local#241 / #244),--shadow-ready-timeout <ms>(per-invocation override of the shadow-replica TCP-ready probe budget, default raised to 60s; cdk-local#266), live streaming of each replica's container stdout / stderr to the host terminal (cdk-local#231), and the no-rule-matched 404 now explaining which listener fields were evaluated onstart-alb(cdk-local#229). Also inherited across the shimmed modules: an interactive spinner during longdocker build/docker pull(cdk-local#269), the source-change classifier now defaulting TypeScript edits to a rebuild (precompiled setups were left stale by a soft-reload; cdk-local#236),--profilenow fully threaded across every STSClient site (cdk-local#254), and auth / watcher / HTTPv2 / classifier rejection reasons now surfaced instead of swallowed into debug-log fallbacks (cdk-local#253). Required cdkd call-site adaptation (cdk-local #252/#253 type change): cdk-local switched its pass-through JWKS / discovery warn-dedup state from aSet<string>(warn once ever per URL) to aWarnedAt=Map<string, number>(warn once per time-window).src/cli/commands/local-start-api.tsrenames its localjwksWarnedUrls = new Set<string>()tojwksWarnedAt = new Map<string, number>()(passed tostartApiServer's renamedjwksWarnedAtoption at both the HTTP-API and WebSocket server-construction sites), andsrc/cli/commands/local-invoke-agentcore.ts'sverifyJwtViaDiscoverycall passes{ warnedAt: new Map<string, number>() }instead of{ warned: new Set() }.sigV4WarnedForeignIdsis unchanged (still aSet<string>). No user-facing behavior change from the rename — the dedup window is engine-managed; the type swap was required purely to typecheck against the newcdk-local/internalsignatures. The agentcore--wsREPL UX polish (cdk-local#278) and the--wsauto-TTY-detection /--ws-interactive-drop (cdk-local#274) +start-api --assume-role-auto(cdk-local#271) land in cdkd through follow-up PRs (cdkd owns those command surfaces, so they are NOT auto-inherited by the bump). All 5405 unit tests pass against 0.77.1 with only the three call-site renames above.
Recently Implemented (2026-05-31):
✅
cdkd local start-service --watch/cdkd local start-alb --watch— sub-second reload for interpreted-handler source edits (Phase 4 of cdk-local#214; cdk-local bumped 0.64.0 → 0.69.0). Each watcher firing now runs a per-target classifier: source-only edits on interpreted-language handlers (Node / Python / Ruby / shell) inside a CDK image asset take a bind-mount FAST PATH (docker cpthe new source into each replica +docker restart, nodocker build, no shadow boot, typical end-to-end latency well under a second; classifier logsverdict=soft-reloadand the runner emitsSoft-reloaded replica … restart + TCP-ready probe complete). Dockerfile / dependency manifest / compiled-language source / asset-hash-unchanged / ambiguous edits keep running the Phase 1-3 rebuild rolling primitive verbatim (shadow boot under a bumped generation suffix + TCP-ready probe + atomic Service-Connect / Cloud Map / front-door pool swap; classifier logsverdict=rebuild (…)and the runner emitsRolling replica … swap complete). Either path rolls one replica at a time, so the multi-replica zero-connection-refusal guarantee is preserved.cdkd local start-servicepreviously did NOT expose--watchat all because cdkd was not calling cdk-local'saddStartServiceSpecificOptionshelper — this PR re-exports the helper fromsrc/cli/commands/ecs-service-emulator.tsand wires it intocreateLocalStartServiceCommand, so--host-port(cdk-local 0.62+) AND--watch(cdk-local 0.69+) now land incdkd local start-service --helpand any future start-service-only flag the helper adds inherits automatically.cdkd local start-alb --watch(already wired viaaddAlbSpecificOptions) inherits Phase 4 wording with no code change. No state-source / behavior change for users who do not pass--watch— the classifier only fires on a watcher reload. New integ fixturetests/integration/local-start-service-watch-fast/(modeled on cdk-local'stests/integration/local-start-service-watch-fast/): single-replica Node-22 ECS service with awebapp/server.cjsinterpreted handler (the.cjsextension keeps the committed source out oftests/integration/.gitignore's*.jssweep);verify.shbootscdkd local start-service --watch, rewritesserver.cjsv1 → v2 and assertsverdict=soft-reload+Soft-reloaded replica … complete+ the v1 → v2 transition oncurl /(with zero rebuild verdicts post-edit), then rewrites the Dockerfile and assertsverdict=rebuild (Dockerfile edit …)+Rolling replica … (swap|single-replica reload) complete+ the v2 → v3 transition + clean SIGTERM teardown. Unit testtests/unit/cli/local-start-service.test.tsextended with assertions that--host-port/--watchare declared and that--watchdefaults tofalse. Verified end-to-end via/run-integ local-start-service-watch-fast(Docker integ — no AWS deploy). Closes #743.⚠️ BREAKING (
cdkd local start-alb): HTTPS listener default flipped from auto-TLS-terminate to plain HTTP, matching cdk-local 0.64.0'scdkl start-alb(cdk-local#203). A cloud-HTTPS listener is now served over plain HTTP locally —X-Forwarded-Proto: httpsis preserved so the upstream app still sees the deployed listener protocol. Users who relied on the prior default to terminate TLS locally (auto-generating a self-signed cert) MUST add--tlsto restore TLS termination. New--tlsopt-in flag is auto-implied by--tls-cert/--tls-key. Refactor follow-up to PR #725 / PR #731: dropped cdkd's local definitions ofparseLbPortOverrides/resolveAlbTarget/albStrategy/pickStack/notFoundand the 5.addOption(...)blocks for ALB-specific flags (--lb-port/--tls-cert/--tls-key/--no-verify-auth/--bearer-token) — these moved to cdk-local 0.64.0's bundledaddAlbSpecificOptions+ ALB strategy/helper exports (cdk-local#203). cdkd'ssrc/cli/commands/local-start-alb.tscollapses from 421 LOC to ~110 LOC;src/cli/commands/ecs-service-emulator.tsre-exports the new ALB symbols fromcdk-local/internal.LocalStartAlbOptionsgainstls?: boolean. Net change: cdkd'sstart-albautomatically inherits any future ALB-only flag the upstreamcdkl start-albadds without manual.addOption(...)duplication. Unit testtests/unit/cli/local-start-alb.test.tstrimmed to cover only the cdkd-specific--from-state/--state-bucket/--state-prefixwiring + thecdkdExtraStateProviderssingleton-identity check (theparseLbPortOverrides/resolveAlbTarget/albStrategy.resolveBootsblocks moved to cdk-local's own test). Docslocal-emulation.mdupdated with the new--tlsrow + the listener-protocols section's default flip. Verified end-to-end via/run-integ local-start-alb-from-state(Docker integ — real AWS deploy +cdkd local start-alb --from-stateboot + plain-HTTP front-door curl +--from-statesubstitution + clean SIGTERM teardown).⚠️ BREAKING (
cdkd local start-api): SigV4 default flipped from fail-closed to warn-and-pass, matching cdk-local'scdkl start-api. The CLI flag is renamed:--allow-unverified-sigv4(opt OUT of fail-closed) is removed and replaced by--strict-sigv4(opt IN to fail-closed). Users who relied on the prior default to deny unverifiable AWS_IAM SigV4 requests against REST v1AuthorizationType: 'AWS_IAM'/ Function URLAuthType: 'AWS_IAM'MUST add--strict-sigv4to theircdkd local start-apiinvocation. The previous cdkd-divergent default (security review #484) drove embedConfig branching + per-flag plumbing that compounded drift across every shim slice; following cdk-local removes that maintenance cost. Plumbing changes:src/cli/commands/local-invoke.ts'sCDKD_EMBED_CONFIGflipssigV4StrictByDefault: true → falseandsigV4OptFlag: '--allow-unverified-sigv4' → '--strict-sigv4';LocalStartApiOptions.allowUnverifiedSigv4?: booleanrenames tostrictSigv4?: boolean; the twosigV4Strict: options.allowUnverifiedSigv4 !== truetranslation sites inlocal-start-api.tsflip tosigV4Strict: options.strictSigv4 === true; the.addOption(new Option('--allow-unverified-sigv4', ...))block becomes--strict-sigv4with the inverted help text; shim header comments insrc/local/http-server.ts/src/local/sigv4-verify.tsupdated;tests/unit/cli/local-embed-config.test.tsassertion updated to the new values. The memory rulefeedback_shim_blocked_by_unadopted_semantic_divergence.mdrecords the case-A → case-B retrofit pattern: even a deliberate documented divergence is worth re-examining when its maintenance cost compounds. Verified end-to-end via/run-integ local-start-api(Docker integ).✅ Property-coverage backfill (issue #609): wired 7 props on
AWS::Lambda::EventSourceMappingin one bundle —KmsKeyArn,LoggingConfig,MetricsConfig,ProvisionedPollerConfig,Queues,Topics,StartingPositionTimestamp— all previously silent-dropped byLambdaEventSourceMappingProvider. Per the AWS SDK shape audit (@aws-sdk/client-lambda3.xCreateEventSourceMappingRequestvsUpdateEventSourceMappingRequest), 4 of the 7 ride BOTH create + update (KmsKeyArn/LoggingConfig/MetricsConfig/ProvisionedPollerConfig) and 3 are create-only (Queues/Topics/StartingPositionTimestampare absent fromUpdateEventSourceMappingRequest— AWS rejects mutation, CFn replaces the resource on a template change, which cdkd's diff layer schedules independently). Wire-format casing flip: CFn schema spells the encryption key asKmsKeyArn(lower-casems), but the SDK field isKMSKeyArn(upper-caseMS); bothcreate()andupdate()do the flip andreadCurrentState()flips back so cdkd state stores the CFn-shaped key.StartingPositionTimestampcoercion: CFn supplies a Number (epoch seconds per the AWS::Lambda::EventSourceMapping schema), the SDK wants aDate;create()coerces (number/ISO-string/Date all accepted), andreadCurrentStateconverts back to epoch seconds so the drift comparator sees the same shape on both sides (a missed conversion would surface phantom drift on every clean run). Update gated onprev !== next: the 4 mutable props use!== undefined(not truthy) so explicit''reaches AWS as the documentedKMSKeyArnclear-back-to-AWS-owned-key sentinel rather than being silently dropped.readCurrentStateis emit-when-present for all 7 — AWS returns these only when the user set them, so a phantomKmsKeyArn: ''/LoggingConfig: { ...defaults }placeholder would force guaranteed drift on every clean run for the typical un-configured ESM. With this slice theAWS::Lambda::EventSourceMappingtype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json). 18 new unit tests across 3 files: 8 inlambda-eventsource-provider.test.ts(create command branches — KmsKeyArn casing flip, LoggingConfig/MetricsConfig/ProvisionedPollerConfig forwarding, Queues, Topics, StartingPositionTimestamp number / ISO-string / Date coercion, all-7-omit-when-absent), 4 inlambda-eventsource-provider-roundtrip.test.ts(KmsKeyArn casing flip mirrors create, empty-string KmsKeyArn clear-sentinel, LoggingConfig/MetricsConfig/ProvisionedPollerConfig update forwarding, 3-create-only-silent-omission from UpdateInput), 6 inlambda-eventsource-provider-readcurrentstate.test.ts(KMSKeyArn casing flip-back emit-when-present, KmsKeyArn omit-when-absent guard against false-positive drift, LoggingConfig/MetricsConfig/ProvisionedPollerConfig together, Queues/Topics array-clone, StartingPositionTimestamp Date→epoch-seconds conversion, all-7-omit-when-absent). Real-AWS verified via the existingtests/integration/dynamodb-streams/fixture — theDynamoEventSourceL2 gains a smallFilterCriteria(so AWS actually persistsKmsKeyArn— without filter criteria the key is a no-op and AWS silently doesn't surface it onget-event-source-mapping), a newkms.Keyfor the filter-criteria encryption with a Lambda-servicegrantEncryptDecrypt(so AWS authorizes the encryption op), andaddPropertyOverrideforKmsKeyArn+MetricsConfigon the synthesized L1 (the L2 doesn't surface these top-level props). The verify.sh extension asserts viaaws lambda get-event-source-mappingthat both props reached AWS. The other 5 props (LoggingConfig/ProvisionedPollerConfig/Queues/Topics/StartingPositionTimestamp) are source-kind-discriminated (Kafka / SQS / Kinesis-AT_TIMESTAMP-only) and don't apply to DynamoDB Streams; they are unit-test-covered.✅ Broader real-AWS integ fixture for
cdkd local start-alb --from-state+.claude/rules/code-layout.mdrestructure (follow-up to PR #731 Part B). New fixturetests/integration/local-start-alb-from-state/: one stack with VPC (2 AZs, public-only, no NAT) + ALB + 2 ApplicationListenerRules (default + path/orders/*) + 2 TargetGroups + 2 ECS Fargate services (Web + Orders,desiredCount: 0to avoid container compute cost) + IAM execution role + LogGroup + 2 SecurityGroups. Each service's TaskDefinition carries anALB_DNS_NAMEenv var withFn::GetAtt: [Alb, DNSName]so the engine's state-source dispatcher MUST substitute the resolved DNS name from cdkd's S3 state whencdkd local start-alb --from-stateboots the containers locally.verify.shdoes pre-flight Docker orphan sweep, deploys the stack via cdkd, validates ALB viaaws elbv2 describe-load-balancers, bootscdkd local start-alb '<stack>/Alb' --from-state --lb-port 80=8080in background, asserts the boot banner + theALB front-door: ...:8080listener banner (proves--lb-portoverride), curlshttp://127.0.0.1:8080/and asserts the response body containsservice=web alb=<deployed-alb-dns>(proves default-action routing +--from-statesubstitution reached the Web container), curlshttp://127.0.0.1:8080/orders/and assertsservice=orders alb=<deployed-alb-dns>(proves ListenerRule path routing + multi-target boot ordering +--from-statesubstitution reached the Orders container), SIGTERMs cdkd, asserts zero leftovercdkd-local-*containers + networks, runscdkd destroy, and verifies the cdkd S3 state for the stack is empty. Closes the gap memory rulefeedback_never_defer_integ_from_originating_pr.mdrecords: the engine's host-side wiring (serviceStrategyfactory +cdkdExtraStateProvidersmap +LocalStartAlbOptionsindex-signature extension) is uniquely exercised end-to-end here; the pure-local siblingtests/integration/local-start-alb/fixture cannot test substitution because there is no deployed state to read, and the upstream cdk-local engine's integ tests its own surface, not cdkd's shim. The fixture also drove discovery + fix of a verify.shset -o pipefailbug (aws s3 lsreturns exit 1 when the prefix has zero objects, which would have terminated the post-destroy state-verification step before printing the success banner)..claude/rules/code-layout.md's giantsrc/local/**bullet had its Service Connect / Cloud Map /ecs-service-runner.ts/ecs-service-resolver.ts/cloud-map-registry.ts/cloud-map-resolver.ts/createSharedSvcNetwork+SHARED_SVC_SUBNET_OCTETparagraph (originally added by issues #466 / #460 to describe the pre-refactor topology) replaced with a single sentence pointing forward to the PR #731 Part B changelog entry — the modules described there were deleted in #731, so the prose was stale at the head + the trailing "Part B annotation" sentence the PR #731 review flagged as suboptimal placement is no longer needed.✅ Property-coverage backfill (issue #609): wired
TagsonAWS::CloudFront::Distribution, whichCloudFrontDistributionProviderpreviously silent-dropped on write.Tagsis a standard CFn[{ Key, Value }]array; CloudFront's SDK gates tag-on-create behind a separate command class —CreateDistributionWithTagsCommand({ DistributionConfigWithTags: { DistributionConfig, Tags: { Items: Tag[] } } })— so the provider'screate()switches command class based on whetherproperties['Tags']is non-empty (an emptyTags: []from CFn is treated as "no tags" and routes through the plainCreateDistributionCommandto avoid hitting the tags-enabled control plane for nothing).update()gains a tag diff after the existingUpdateDistributionCommand: removals →UntagResourceCommand({ Resource: <ARN>, TagKeys: { Items: [...] } }), additions + value rewrites →TagResourceCommand({ Resource: <ARN>, Tags: { Items: [...] } })(TagResource overwrites a key's value on re-tag, so a same-key value rewrite is in the upsert set alone). The removal pass runs BEFORE the upsert pass so a renamed key (value-only edit on key K) is not accidentally cleared by a stale UntagResource.readCurrentStateis intentionally NOT added in this PR — CloudFront has noreadCurrentStatetoday (drift falls back to the CC-API path), and a partial implementation that reads onlyTagswhile ignoringDistributionConfigwould surface less drift than CC-API would; full readback is deferred to a separate PR. With this slice, theAWS::CloudFront::Distributiontype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json—Tagswas the only outstanding entry).Tagsmoves fromsilentDroptohandledinproperty-coverage.generated.ts(regenerated viavp run gen:property-coverage— the raw codegen formatting artifact is normalized byvp check --fix). 8 new unit tests incloudfront-distribution-provider.test.tscover the create command-class switch (with Tags →CreateDistributionWithTagsCommand, without and withTags: []→ plainCreateDistributionCommand), the update tag-diff (add-only → TagResource, removal-only → UntagResource, value-rewrite on same key → TagResource only, unchanged → neither, mixed adds + removes → Untag then Tag in that order). Real-AWS verified via the existingtests/integration/s3-cloudfront/fixture — the L2cloudfront.Distributiongains twocdk.Tags.of(distribution).add(...)calls; a NEWverify.shdeploys, resolves the distribution ARN viaaws cloudfront get-distribution, asserts both tags viaaws cloudfront list-tags-for-resource, then destroys clean.✅ Property-coverage backfill (issue #609): wired
ReservedConcurrentExecutionsonAWS::Lambda::Function, whichLambdaFunctionProviderpreviously silent-dropped on write. Real safety concern before this PR: a CDK template settingreservedConcurrentExecutions: 100to cap a function's concurrency would silently drop the cap on deploy via cdkd — production stacks could exceed their intended concurrency limits. Post-create control-plane API pattern (matches PR #719 RecursiveLoop):CreateFunctiondoes NOT accept the field; it requires a separatePutFunctionConcurrencyCommand({ FunctionName, ReservedConcurrentExecutions: number })call after function creation.create()issues the post-create call when the value is set (!== undefined, NOT a truthy gate — 0 is a meaningful value that throttles the function to zero concurrency); on failure issuesDeleteFunctionCommandasdelete-on-post-create-failureatomicity rollback (mirrors RecursiveLoop's pattern exactly).update()gates onprev !== nextstrict-compare; removal (prev: number, next: undefined) maps toDeleteFunctionConcurrencyCommand— unlike RecursiveLoop which has no clear API and just leaves the last-set value pinned, AWS provides a dedicatedDeleteFunctionConcurrencyso a user dropping the template prop actually un-throttles the function instead of silently leaving the limit in place.readCurrentStateadds a separateGetFunctionConcurrencyCommandcall after the primaryGetFunction, emit-when-present (the AWS response carriesReservedConcurrentExecutionsonly when the limit is set, so a typical un-throttled function correctly maps to omit-from-readback — no phantom drift). 9 new unit tests inlambda-function-provider.test.tscover create-send (with 50, with explicit 0, absent), atomicity rollback via DeleteFunction, update set / update clear via DeleteFunctionConcurrency / update no-diff, readback emit / omit. Real-AWS verified via/run-integ lambda(broad-set): the fixture'slambda.FunctiongainsreservedConcurrentExecutions: 5; verify.sh extends the existing RecursiveLoop assertion withaws lambda get-function-concurrency --query ReservedConcurrentExecutionsreturning5. 9 resources deployed clean, all 3 assertions pass (provisionedBy='sdk' + RecursiveLoop='Allow' + ReservedConcurrentExecutions=5), 9 destroyed with 0 errors / 0 orphans.✅
cdkd local start-service <targets...>refactor onto the shared ECS service emulator engine (follow-up to PR #725'sstart-albshim work, completes the symmetry the ALB PR explicitly deferred). The old 944-linesrc/cli/commands/local-start-service.ts— owning the per-replica boot loop + shared docker network + Cloud Map registry + per-targetcreateLocalStateProvider+ manual env-substitution + SIGINT single-flight cleanup — collapses to a ~120-line shim mirroringlocal-start-alb.ts: aLocalStartServiceOptionsinterface extending the engine'sEcsServiceEmulatorOptionswith cdkd's--from-state/--state-bucket/--state-prefix, a smallserviceStrategy(options): EmulatorStrategy(picker vialistTargets(stacks).ecsServices, picker text "Select one or more ECS services to run", trivialresolveBootsmapping each chosen target to{ target }since the engine'sbootOneTargetcallsresolveEcsServiceTargetinternally,lbPortOverrides: {}since services have no listener ports), and acreateLocalStartServiceCommand()that wires the sharedrunEcsServiceEmulator(targets, options, serviceStrategy(options), cdkdExtraStateProviders)engine entry. The shared engine + Cloud Map + sidecar machinery has lived in cdk-local since 0.62.0 (PR #725's pre-work) — Part B just adopts it for the second consumer. With this refactor, every per-replica boot orchestration / shared-network / sidecar-credentials / Cloud Map registry / state-provider-per-target / cross-stack-resolver / assume-task-role / profile-credentials-file / SIGINT-cleanup mechanic is owned by cdk-local for BOTHstart-serviceANDstart-alb— adding a feature now means changing one upstream module instead of two byte-identical command files. Now-dead code DELETED from cdkd's tree:src/local/ecs-service-runner.ts(959 lines — the entire per-replica orchestrator + Cloud Map publish + subnet allocator),src/local/ecs-service-resolver.ts(596 lines — service-discovery resolver, now in cdk-local's bundled engine),src/local/cloud-map-registry.ts(11-line shim no longer imported by anyone),src/local/cloud-map-resolver.ts(13-line shim no longer imported by anyone),tests/unit/local/ecs-service-runner.test.ts(1934 lines — every test is now exercised by cdk-local's own bundled test),tests/unit/local/ecs-service-resolver.test.ts(379 lines — same), andtests/unit/cli/local-start-service-profile-creds.test.ts(resolveSharedSidecarCredentialsis now sourced from cdk-local via theecs-service-emulator.tsshim — testing it from cdkd was dead-coverage).src/local/ecs-network.tskeeps itscreateTaskNetwork/destroyTaskNetwork/buildMetadataEnv/buildEndpointSubnetexports (used by the still-localecs-task-runner.tsforcdkd local run-task) but dropscreateSharedSvcNetwork+SHARED_SVC_SUBNET_OCTET(the start-service-specific shared-network factory) since the engine creates its own shared network from cdk-local's bundled equivalent. Net diff: -3500 LOC in cdkd's tree with zero behavior change for the user-facingcdkd local start-servicecommand — every flag (--cluster/--env-vars/--container-host/--assume-task-role/--no-pull/--ecr-role-arn/--platform/--max-tasks/--restart-policy/--from-state/--from-cfn-stack/--state-bucket/--state-prefix/--stack-region) and every behavior (replica boot / Cloud Map peer discovery / Service Connect aliasing / shared sidecar/role/<arn>credentials / profile-credentials-file bind-mount / cross-stackFn::ImportValuesubstitution / ^C teardown) renders identically post-refactor. Thetests/unit/cli/local-commands-dispatcher-wiring.test.ts(issue #611 dispatcher-wiring scan) now tracks only the three direct-dispatch commands (local-invoke/local-start-api/local-run-task);local-start-servicejoinslocal-start-albin the engine-wired category where the dispatcher invocation lives inside cdk-local'srunEcsServiceEmulatorand reaches cdkd's S3-backed--from-statefactory transparently via the sharedcdkdExtraStateProvidersmap. The pre-PRMAX_TASKS_SUBNET_RANGE_CAPexport fromlocal-start-service.tsis dropped (the engine's bundledparseMaxTasksenforces the same cap with the same error message). Real-AWS verified via the existingtests/integration/local-start-service/fixture (single-service replica boot + Cloud Map peer registration + ^C cleanup, against real Docker; AWS deploy is N/A for this pure-local fixture). A broader multi-service + ALB integ exercising--from-statesubstitution against deployed cdkd state is deferred to a follow-up PR — Part B's risk surface is concentrated in the smallserviceStrategy()factory + the dispatcher-wiring test update (both unit-tested), and the shared engine itself was already verified end-to-end against real AWS by PR #725 Part A.✅ Property-coverage backfill (issue #609): wired
TagsonAWS::S3Vectors::VectorBucket, whichS3VectorsProviderpreviously silent-dropped on write.Tagsis a standard CFn[{ Key, Value }]array. The AWS SDKCreateVectorBucketInput.tagsaccepts a flatRecord<string, string>shape;createVectorBucket()converts the CFn array → SDK map and passes it onCreateVectorBucketCommand(omit-when-absent — an emptyTags: []array sends notagsfield so no spurious CloudTrail event fires). VectorBucket has NOUpdateVectorBucketAPI (the provider'supdate()is already a no-op), so update-side wiring is intentionally not added — a tag change requires a destroy+recreate via cdkd's existing replacement path.readCurrentStateadds a second AWS call (ListTagsForResource(resourceArn=vectorBucketArn)) after the primaryGetVectorBucket, converts the SDKRecord<string, string>back to CFn[{ Key, Value }]shape, and emitsTags: []when AWS returns no tags or whenListTagsForResourceitself fails (best-effort; the drift comparator stays happy). With this slice, theAWS::S3Vectors::VectorBuckettype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json).Tagsmoves fromsilentDroptohandledinproperty-coverage.generated.ts(962 handled, 409 silent-drop). New unit tests intests/unit/provisioning/providers/s3-vectors-provider.test.ts(Tags forwarded as the SDK Record<string,string> shape; absent and empty-array variants both omittagsfrom the SDK input) andtests/unit/provisioning/s3-vectors-provider-readcurrentstate.test.ts(readback surfaces Tags via the new ListTagsForResource hop, reshapes SDK map to CFn[{Key, Value}], falls back toTags: []when ListTagsForResource fails). The two pre-existing roundtrip cases (Class 1 — readCurrentState does not emit KMSKeyArn on an AES256 bucket+readCurrentState emits both SSEType and KMSKeyArn on aws:kms) were updated to mock the new ListTagsForResource call alongside their existing GetVectorBucket mock and to expectTags: []in the result. Real-AWS verified by extending the existingtests/integration/s3-vectors/fixture — theCfnVectorBucketL1 gainstags: [{ key: 'env', value: 'cdkd-integ' }, { key: 'team', value: 'platform' }], and a NEWverify.shdeploys, resolves the bucket ARN viaaws s3vectors get-vector-bucket --query vectorBucket.vectorBucketArn, asserts both tags viaaws s3vectors list-tags-for-resource, then destroys clean (1 deployed, 1 destroyed, 0 errors, 0 orphans).✅ Property-coverage bookkeeping fix (issue #609): retired the stale
AWS::ECS::TaskDefinition:InferenceAcceleratorsentry fromtests/fixtures/cfn-schemas/_todo-backfill.json. The property was ALREADY declared inECSProvider.unhandledByDesign(with rationale "AWS Elastic Inference end-of-life 2024-04; use AWS Inferentia / Trainium accelerator instance families instead") but the backfill todo file still listed it, so the property-coverage strict-mode test was the only thing keeping the entry technically "tracked". Pure bookkeeping cleanup — zero src wire changes, no integ needed.property-coverage.generated.tsregenerated to reflect the move fromsilentDropto the implicit-handled set (961 handled, 410 silent-drop, down from 411).✅ New
cdkd local start-alb <targets...>command (issue #86): run an Application Load Balancer locally — name one or moreAWS::ElasticLoadBalancingV2::LoadBalancerresources, discover the ECS / Lambda targets behind each listener'sforwardaction, boot every backing ECS service via the shared enginelocal start-serviceuses, and stand up a per-listenernode:http(s)front-door that round-robins inbound requests across the running replicas and applies the listener rules (path / host / header / method / query-string / source-IP). The symmetric counterpart oflocal start-apifor ALB-fronted workloads. Models cdk-local'scdkl start-alb, ported into cdkd's command tree as a 2-shim + 1-command trio:src/local/elb-front-door-resolver.ts(re-exportsresolveAlbFrontDoor+isApplicationLoadBalancer+ the front-door type set fromcdk-local),src/cli/commands/ecs-service-emulator.ts(re-exports the sharedrunEcsServiceEmulatorengine +addCommonEcsServiceOptions+ theEcsServiceEmulatorOptions/EmulatorStrategy/Planned*types fromcdk-local/internal), and the 421-linesrc/cli/commands/local-start-alb.tscommand file (createLocalStartAlbCommand+ exportedparseLbPortOverrides/resolveAlbTarget/albStrategyhelpers). The cdk-local-side engine + resolver were released in coordinated upstream PR cdk-local#190 as cdk-local 0.62.0 so cdkd's shim consumer pattern works without inlining the ~1000-line front-door + per-replica boot orchestrator. Listener / action support: HTTP and HTTPS listeners (TLS terminated locally via--tls-cert/--tls-key, auto-generated self-signed cert as fallback cached under$XDG_CACHE_HOME/cdk-local/alb-https/); forward (single target group AND weighted forward across multiple target groups), redirect (301 / 302 with protocol / host / port / path / query overrides), fixed-response (configurable status code / content-type / body); all six rule condition fields (path-pattern, host-header, http-header, http-request-method, query-string, source-ip); ECS targets (viaAWS::ECS::Service.LoadBalancers[]binding the TG to a container + port) AND Lambda targets (viaTG.Targets[].Id = {Fn::GetAtt: [<FnLogicalId>, "Arn"]}); authenticate-cognito + authenticate-oidc actions enforce a local Bearer-JWT check (orAWSELBAuthSessionCookiepass-through) against the same JWKS / OIDC discovery URL the deployed ALB would (signature + iss + aud + exp). Per-listener host-port remap via--lb-port <listenerPort>=<hostPort>(repeatable) for macOS where privileged listener port < 1024 cannot bind without root (default: host port == listener port). State-source flags (--from-state/--from-cfn-stack/--state-bucket/--state-prefix/--stack-regionmirroringlocal start-service) ride through to the backing services via the shared engine — the engine internally callscreateLocalStateProvider(options, ..., extraStateProviders)per backing-service boot, and cdkd's S3-backed--from-statefactory is wired via the new exportcdkdExtraStateProviders({ fromState: fromStateFactory }) insrc/cli/commands/local-state-source.ts. The newLocalStartAlbOptionsinterface extendsEcsServiceEmulatorOptionswith cdkd-specificfromState/stateBucket/statePrefixfields (carried through cdk-local's[key: string]: unknownindex signature). Auth-guard opt-outs:--no-verify-authdisables the JWT check entirely;--bearer-token <jwt>injects a default Authorization header when the inbound request has none. New unit tests intests/unit/cli/local-start-alb.test.ts(30 cases:parseLbPortOverridesvalid / invalid / range / multi-entry semantics,resolveAlbTargetstack-prefix / multi-stack / non-ALB / missing-resource error paths, and the option-builder smoke test asserting the cdkd-specific--from-state/--state-bucket/--state-prefixflags are wired alongside the engine-inherited--from-cfn-stack/--stack-region/--lb-port/--max-tasks/--restart-policy/ etc.). Real-AWS verified via NEWtests/integration/local-start-alb/pure-local fixture (no AWS deploy): VPC-freeCfn*topology with one ALB + one HTTP:80 listener + one TargetGroup + one EC2-launchType ECS Service running busybox httpd on container port 80;verify.shbootscdkd local start-albwith--lb-port 80=8080, asserts the boot banner + the front-door listening banner, hitshttp://127.0.0.1:8080/and asserts the busybox container's fixed banner ("OK from cdkd-local-start-alb-fixture") routes correctly, then SIGTERMs and asserts clean teardown (zero leftovercdkd-local-*containers / networks). Thelocal-start-servicerefactor to also delegate to the shared engine (instead of its current per-replica boot loop) is deferred to a follow-up PR per scope.✅ Property-coverage backfill (issue #609): wired
HostedZoneFeaturesonAWS::Route53::HostedZone, whichRoute53Providerpreviously silent-dropped on write.HostedZoneFeaturesis{ AcceleratedRecoveryStatus: 'ENABLED' | 'DISABLED' }— the AcceleratedRecovery feature targets a 60-minute Recovery Time Objective (RTO) for DNS operations during us-east-1 service disruptions (per the AWS launch blog); the feature itself is free (no premium-tier billing — verified against Route 53 pricing and the launch blog's "There is no additional cost for using accelerated recovery" statement). Unlike the direct-on-create backfills this session, this rides on a separate post-create control-plane API —CreateHostedZonedoes NOT accept the feature; it requires a follow-upUpdateHostedZoneFeaturesCommand({ HostedZoneId, EnableAcceleratedRecovery: boolean }). The backfill follows the post-create control-plane pattern established in PR #719 (Lambda::Function:RecursiveLoop):create()issuesUpdateHostedZoneFeaturesAFTERCreateHostedZonesucceeds when the template requested'ENABLED'(calling withfalseis skipped — AWS default is DISABLED, so the explicit-toggle hop is unnecessary); on failure issuesDeleteHostedZoneasdelete-on-post-create-failureatomicity rollback before throwing (the next deploy retry sees no orphan zone).update()is extended with the missingpreviousPropertiesparameter and gatesUpdateHostedZoneFeaturesonprev !== next— a removal (prev: ENABLED, next: undefined) is treated asDISABLED(the AWS default state, matching CFn's omit-default convention).delete()gains a pre-delete guard — AWS rejectsDeleteHostedZonewhile AcceleratedRecovery is anything other thanDISABLED(Cannot delete a hosted zone with accelerated recovery enabled. Please disable first.), sodeleteHostedZoneprobes the current status, issuesUpdateHostedZoneFeatures(false)if needed, and polls until the AWS-side state settles toDISABLED(default 10-min timeout / 15s interval; env-overridable viaCDKD_R53_ACCEL_RECOVERY_POLL_TIMEOUT_MS/CDKD_R53_ACCEL_RECOVERY_POLL_INTERVAL_MS). Without this guard, ANY zone deployed withHostedZoneFeatures.AcceleratedRecoveryStatus: 'ENABLED'would be physically un-destroyable via cdkd (the create path opts in, the destroy path'sDeleteHostedZoneis then rejected indefinitely until manualaws route53 update-hosted-zone-features --no-enable-accelerated-recoveryrecovery). Genuinely failed statuses (ENABLE_FAILED/DISABLE_FAILED) hard-fail the delete with an actionable error pointing the operator at the manual recovery command; the*_HOSTED_ZONE_LOCKEDtransients are waited through (see the 2026-06-02 fix below).readHostedZonesurfaces it back fromGetHostedZone.HostedZone.Features.AcceleratedRecoveryStatusemit-when-present (gated on!== undefined, NOT a default-when-absent placeholder — zones older than the 2025 feature launch return undefined indefinitely, so a phantom{ AcceleratedRecoveryStatus: 'DISABLED' }would force guaranteed drift on every clean run for the typical zone that never opted in). With this slice, theAWS::Route53::HostedZonetype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json). New unit tests inroute53-provider.test.ts(create with ENABLED triggers post-createUpdateHostedZoneFeatures(true); absent omits; explicit DISABLED also omits — AWS default; failed UHF rolls back viaDeleteHostedZone+ProvisioningError; update prev=DISABLED→next=ENABLED fires UHF(true); update prev=ENABLED→next=undefined fires UHF(false) as the implicit-DISABLED transition; update with unchanged status does NOT fire UHF) androute53-provider-readcurrentstate.test.ts(readback emits whenGetHostedZone.HostedZone.Features.AcceleratedRecoveryStatusis present; omits when AWS returns noFeaturesblock). Real-AWS verified via the existingtests/integration/route53/fixture — theroute53.HostedZoneL2 gainsaddPropertyOverride('HostedZoneFeatures.AcceleratedRecoveryStatus', 'ENABLED')since CDK L2 does not expose the property;verify.shis extended (same style as the existing GeoProximityLocation / CidrRoutingConfig assertions) withaws route53 get-hosted-zone --query 'HostedZone.Features.AcceleratedRecoveryStatus'asserting'ENABLED'reached AWS, then destroys clean.✅ Property-coverage backfill (issue #609): wired
ServiceConnectDefaultsonAWS::ECS::Cluster, whichECSProviderpreviously silent-dropped on write.ServiceConnectDefaultsis the cluster-wide default{ Namespace }ARN that new ECS services use when they enable Service Connect without specifying their own namespace; pre-PR the property's existing comment inupdateClusterexplicitly deferred this slice ("ServiceConnectDefaultsis also accepted by UpdateClusterCommand but is intentionally NOT applied here — create() and readCurrentState() do not surface it either"). It rides DIRECTLY onCreateCluster/UpdateCluster(the single SDK calls the provider already makes forAWS::ECS::Cluster) — there is NO separate control-plane API. CFn{ Namespace }maps 1:1 to the SDK'sserviceConnectDefaults: { namespace }(casing flip only).createClusterforwardsproperties['ServiceConnectDefaults']when present (omit-when-absent).updateClusteradds it to the existingsettingsChanged || configChangedJSON-stringify diff gate alongside ClusterSettings / Configuration so aServiceConnectDefaults-only change triggers a singleUpdateClusterCommand; the removal case sends the AWS-documentednamespace: ''sentinel (perClusterServiceConnectDefaultsRequest.namespacedocs — "If you update the cluster with an empty string""for the namespace name, the cluster configuration for Service Connect is removed") so a user dropping the property from their template actually clears the AWS-side default instead of being silently treated as no-op.readCurrentStateClusterreads it back fromDescribeClusters.serviceConnectDefaults.namespaceemit-when-present (gated on!== undefined, NOT a default-when-absent placeholder — a cluster that never set a default Service Connect namespace returns noserviceConnectDefaultsfrom AWS, so a phantom{ Namespace: '' }would force guaranteed drift on every clean run). With this slice, theAWS::ECS::Clustertype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json—ServiceConnectDefaultswas the only outstanding entry).ServiceConnectDefaultsmoves fromsilentDroptohandledinproperty-coverage.generated.ts(regenerated viavp run gen:property-coverage— the raw codegen formatting artifact is normalized byvp check --fix). New unit tests inecs-provider.test.ts(create forwardsServiceConnectDefaults: { Namespace: '<arn>' }intoCreateClusterCommand; omit-when-absent),ecs-provider-roundtrip.test.ts(update emitsUpdateClusterCommandwithserviceConnectDefaults: { namespace: '<arn>' }on add; emits{ namespace: '' }clear-sentinel on removal; not present in input when only an unrelated field — ClusterSettings — changed), andecs-provider-readcurrentstate.test.ts(readback emitsServiceConnectDefaultswhen AWS returns it; omits for the typical cluster that did not configure a default namespace). Real-AWS verified by extendingtests/integration/ecs-fargate/verify.sh— the existingnew ecs.Cluster({ defaultCloudMapNamespace: { name: 'cdkd-test.local' } })synthesizes anAWS::ECS::ClusterwhoseServiceConnectDefaults.Namespacecarries the auto-createdAWS::ServiceDiscovery::PrivateDnsNamespace's Arn; the verify.sh extension asserts viaaws ecs describe-clusters --query 'clusters[0].serviceConnectDefaults.namespace'that the namespace ARN reached AWS (with a sanity check on thearn:*:servicediscovery:*:namespace/*shape), then destroys clean.✅ Property-coverage backfill (issue #609): wired
TypeonAWS::SecretsManager::Secret, whichSecretsManagerSecretProviderpreviously silent-dropped on write.Typeis a single optional string scalar — the partner identifier for AWS Secrets Manager managed external secrets (third-party-managed secrets registered through partners like Snowflake / Datadog / MongoDB; see the AWS docs reference in the SDK comments onCreateSecretRequest.Type). It rides DIRECTLY onCreateSecret/UpdateSecret(the single SDK calls the provider already makes) — there is NO separate control-plane API. The SDK field name (Type) and casing already match CFn, so the backfill is a straight field-forward:create()passesproperties['Type']tocreateParams.Typetruthy-gated (omit-when-absent — empty string is a no-op on AWS, so the truthy gate matches the field's semantics);update()adds it to the existing update input builder with the same truthy gate (an explicit clear is also a no-op on AWS, no client-side sanitize required).readCurrentStatereads it back fromDescribeSecret'sTypeemit-when-present (gated on!== undefined, NOT a default-when-absent placeholder — the typical secret is non-partner-managed and AWS returns noType, so an''placeholder would force guaranteed drift on every clean run). With this slice, theAWS::SecretsManager::Secrettype is now COMPLETE (itssilentDropset is empty and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json—Typewas the only outstanding entry).Typemoves fromsilentDroptohandledinproperty-coverage.generated.ts(regenerated viavp run gen:property-coverage— the raw codegen formatting artifact is normalized byvp check --fix). New unit tests extendsecretsmanager-secret-provider-roundtrip.test.ts(create sendsType: 'urn:partner:example'intoCreateSecretCommand; omit-when-absent; update sendsType: 'urn:partner:v2'intoUpdateSecretCommandon diff; omit-when-absent on update) andsecretsmanager-secret-provider-readcurrentstate.test.ts(readback emitsTypewhen AWS returns a partner identifier; omits for the typical non-partner-managed secret). Integ verified via/run-integ composite-stack(the existingAWS::SecretsManager::Secretrow in the fixture — noTypeset, so the integ exercises the omit-when-absent path end-to-end and confirms thehandledPropertiesaddition does not regress the existing secret deploy). TheTypewire itself is fully covered at unit level because the AWS-side validation of partner identifier strings is opaque (the field accepts only AWS-recognized partner IDs and would reject an arbitrary test value), so live verification of a real partner identifier is out of scope for this slice.✅ New
cdkd local invoke-agentcore <target>command: run a Bedrock AgentCore Runtime container locally and invoke it once over the AgentCore protocol declared by the target (HTTP/invocations/ MCP streamable-HTTP / A2A JSON-RPC / AGUI streaming / bidirectional WebSocket via--ws). Models cdk-local'scdkl invoke-agentcore, ported into cdkd's command tree as 8 new shim files undersrc/local/agentcore-*.ts(agentcore-resolver/agentcore-code-build/agentcore-s3-bundle/agentcore-sigv4-sign/agentcore-client/agentcore-mcp-client/agentcore-a2a-client/agentcore-ws-client) + 1 newsrc/local/target-picker.tsshim + an expandedsrc/local/cognito-jwt.tsshim (addsverifyJwtViaDiscoveryto the re-export list for inbound JWT auth) + the ~1650-linesrc/cli/commands/local-invoke-agentcore.tscommand file. The command supports the container artifact (fromContainerAsset/fromEcr) and theCodeConfigurationmanaged-runtime artifact (fromCodeAsset, built from source) on all 4 protocols, plus inbound JWT auth verification against the runtime's OIDC discovery URL (customJwtAuthorizer), outbound SigV4 signing of/invocations(--sigv4), per-request timeout (--timeout, default 120s), session-id header binding (--session-id), platform override (--platform, defaultlinux/arm64per AgentCore's required arch), state-source flags (--from-state/--from-cfn-stackmirroringcdkd local invoke), role-assumption flags (--assume-roleauto-resolves the runtime'sRoleArnfrom cdkd state when bare), and ECR cross-account image pulls (--ecr-role-arn). The shim pattern follows the established 33-file precedent from #713: every shim is a small re-export from'cdk-local/internal'so the actual implementation lives in cdk-local and cdkd consumes it verbatim. Two cdk-local-side exports were added in a coordinated upstream PR (cdk-local#177, released as cdk-local 0.61.0) —pickAgentCoreCandidateStack(image-uri candidate stack picker) andresolveSingleTarget(interactive picker for omitted target) — so cdkd's shim consumer pattern works without inlining 250+ lines of helpers.resolveExecutionRoleArnFromStateinsrc/cli/commands/local-invoke.tswas extended with an optionalrolePropertyparameter (defaulting to'Role') so the agentcore command can reuse it with'RoleArn'(the field name onAWS::BedrockAgentCore::Runtime). The cdkdlocal-state-source.tsshim addsresolveCfnFallbackRegionandExtraStateProvidersto its re-export list. Cross-cuttingsrc/local/docker-runner.tsextension for the new command's protocol diversity + secret-handling needs: adds optionalcontainerPort?: number(defaults to 8080 so the existing RIE Lambda local-invoke path is unchanged; MCP runtimes pass 8000, A2A runtimes pass 9000 so the docker-pflag publishes the right port) and optionalsensitiveEnvKeys?: ReadonlySet<string>(always unioned with the newSENSITIVE_ENV_KEYSconstant covering the AWS credential set, so decrypted SecureString SSM values + AWS creds are routed through docker's value-from-process-env form-e KEYrather than-e KEY=value— the values never appear on thedocker runargv /ps//proc/<pid>/cmdline/ verbose debug logs). New unit test intests/unit/cli/local-invoke-auto-assume-role.test.tscovers the 3rd-argrolePropertyextension's'RoleArn'case. Integ fixturetests/integration/local-invoke-agentcore/mirrors cdk-local's: EchoAgent (HTTP), ProtectedAgent (JWT auth), McpAgent (MCP), CodeAgent (CodeConfiguration source-build), A2aAgent (A2A), AguiAgent (AGUI) — verify.sh exercises 20 end-to-end scenarios against Docker. Out of scope (carried over from cdk-local): real Bedrock AgentCore SDK invocation against the cloud (cdkd local *is local-only by definition). The command does NOT replace the existingcdkddeploy path forAWS::BedrockAgentCore::Runtime(that usessrc/provisioning/providers/agentcore-runtime-provider.ts); they are distinct paths — the provider deploys agentcore to AWS, the new command runs an agentcore container locally for debugging.✅ Property-coverage backfill (issue #609): wired
LogConfigonAWS::Events::EventBus, whichEventBridgeBusProviderpreviously silent-dropped on write.LogConfigis a nested object{ IncludeDetail?: 'NONE' | 'FULL', Level?: 'OFF' | 'ERROR' | 'INFO' | 'TRACE' }that controls EventBridge's per-bus log emission to CloudWatch Logs / S3 / Firehose (separateAWS::Events::LogStreamresources route the output). It rides DIRECTLY onCreateEventBus/UpdateEventBus(the single SDK calls the provider already makes) — NO separate control-plane API.create()forwardsproperties['LogConfig']to the SDK input when present (omit-when-absent);update()adds it to the existing JSON-stringify diff gate alongsideDescription/KmsKeyIdentifier/DeadLetterConfig(so aLogConfig-only change triggers a singleUpdateEventBus);readCurrentStatesurfaces it back fromDescribeEventBus.LogConfigemit-when-present (NOT the always-emit-placeholder pattern that the siblingDeadLetterConfiguses — AWS only returnsLogConfigwhen set, so a phantom{ Level: 'OFF', IncludeDetail: 'NONE' }placeholder would round-trip into spurious drift on buses that never configured logging). Each sub-field is gated on!== undefinedindividually, so partial AWS responses surface only the user-controllable fields.LogConfigmoves fromsilentDroptohandledinproperty-coverage.generated.ts(regenerated viavp run gen:property-coverage— the raw codegen formatting artifact is normalized byvp check --fix);AWS::Events::EventBus'ssilentDropbecomes EMPTY (only entry wasLogConfig) and the whole key is dropped fromtests/fixtures/cfn-schemas/_todo-backfill.json. New unit tests ineventbridge-bus-provider-roundtrip.test.ts(create forwardsLogConfig: { Level: 'INFO', IncludeDetail: 'FULL' }intoCreateEventBusCommand; create omits when absent; update emits a singleUpdateEventBusCommandon diff; update-no-op produces zeroUpdateEventBuscalls) andeventbridge-bus-provider-readcurrentstate.test.ts(readback emits LogConfig when AWS returns it; omits when undefined). Real-AWS verified via a NEWtests/integration/eventbridge/verify.shthat deploys the existingEventBridgeStack(now withlogConfig: { level: events.Level.INFO, includeDetail: events.IncludeDetail.FULL }on the L2events.EventBus), asserts viaaws events describe-event-busthat both sub-fields reached AWS, then destroys clean.✅
NestedStackProvider.deletemarks its two UNHEALABLE deterministic refusals non-retryable, so a user-controlled child stack name can no longer make them look transient (issue #1849, thesrc/provisioning/**sibling of #1838). Scope deliberately stated as the two marked arms rather than as the whole message: the third arm — a plain, non-interrupted child failure — is left UNMARKED because a whole-child re-destroy can genuinely heal, and an unmarked error is classified by MESSAGE, so that arm remains name-dependent. There is nomarkRetryableto assert the healable reading with, and marking it would make the healable case terminal for everyone, so the residual is accepted rather than fixed.nestedStackChildFailureMessageinterpolates<parent>~<childLogicalId>into its text andretryable-errors.tsclassifies by SUBSTRING, so a nested stack named e.g.MyDependencyViolationSubmatchedDependencyViolation— the only whitespace-free entry inRETRYABLE_ERROR_MESSAGE_PATTERNS. The fix ismarkNonRetryableat the THROW SITE (not in the constructor and not innested-stack-messages.ts, which is required to stay import-free), applied per arm rather than to the class: theerrorCount > 0throw is marked ONLY when the child destroy was also interrupted — there a retry does not heal anything, it re-enters a destroy the user just aborted, becausedrainingindestroy-runner.tsis a per-invocation local with a per-invocation SIGINT listener. A plain (non-interrupted) child failure is left UNMARKED, because a whole-child re-destroy runs against the child's preserved state and a resource still draining on attempt 1 may genuinely be gone on attempt 2. Left unmarked its classification stays MESSAGE-driven, so — measured —Parent~MyDependencyViolationSubclassifies retryable there whileParent~PlainSubdoes not: that arm keeps the name-dependence, which is the residual described above rather than a second fix. The provider's other deterministic refusal — thewithNestedStackContextwiring check — is marked too, since AsyncLocalStorage is read inside the same async context on every attempt. Both marks are LATENT today (disableOuterRetry = truemakesdestroy-runner.tscomputemaxAttempts = 0, short-circuitsDeployEngine.withRetry, androllback-executor.tsdoes not wrap deletes at all) and are landed as the declaration that survives any of those opting back in — the same reasoning #1778 used for its six latent sites. Unit tests intests/unit/provisioning/nested-stack-provider.test.tspin the MARKER rather than the wording: each of the two MARKED arms is paired with a CONTROL built from the byte-identical message, so a future reword cannot satisfy the assertion while the mark is gone. The unmarked arm gets the opposite treatment — a row pinning BOTH halves of its name-dependence (poisoned id retryable, ordinary id not), so a later blanket-mark breaks loudly and the residual stays visible instead of being described only in prose.✅ Region canonicalization for every synthesized ARN / URI (issue #1850) —
AppSyncProvider.buildAppSyncArn, the fourCloudControlProvider.enrichResourceAttributessites (KMS KeyArn, ECR RepositoryArn, ECRRepositoryUri, Kinesis StreamArn), the fivesrc/utils/s3-endpoints.tshelpers, the syntheticAWS::StackId, and — widest — ONE fold atIntrinsicFunctionResolver.constructAttribute's destructure, which feeds every ARN / URI the resolver builds (DynamoDB, SNS, Logs, ECR, ECS, ...). All of them interpolated the region VERBATIM, socdkd deploy --region US-EAST-1recordedarn:aws:appsync:US-EAST-1:...as the resource'sFn::GetAttanswer. IAM policy matching IS case-sensitive, so such a value matches no policy and is rejected by every SDK call that takes it, and it is persisted intostate.json, so it outlives the deploy and is what every laterFn::GetAtt/cdkd driftreads. Each site folds throughcanonicalizeRegion(src/utils/aws-partition.ts), mirroring the fix issue #1824 applied toSSMParameterProvider. The PARTITION segment was already correct —derivePartitionAndUrlSuffixcanonicalizes its own input since issue #1795 — and double-folding is a no-op, which is what makes the two safe side by side. The S3 fold lives INSIDEs3-endpoints.ts, not at its callers, which was a review finding rather than the first cut: that module has TWO callers —S3BucketProvider.buildAttributes(what lands in state) and the resolver — so folding at the resolver alone made theFn::GetAttanswer disagree withreadCurrentState, the exact phantom drift the module exists to prevent (issue #1745). ForWebsiteURLit is not even a spelling difference: the separator comes from a case-SENSITIVE Set lookup, so an upper-cased region MISSES the legacy-dash set and takes the wrong separator. TheRepositoryUrihost matters for its own reason — the recorded URI is handed todockerand parsed back byparseEcrRegistryHost(src/utils/ecr-uri.ts), whose canonical-segment guards are exactly what an upper-cased label has to get past. Upgrade consequence, stated because it is user-visible: a stack already deployed with a non-canonical--regionkeeps its old value in state (constructAttributeruns only for UNCACHED attributes), so the next diff of a resource whose attribute is re-resolved sees a property change — and where that property is create-only (e.g.AWS::SNS::Subscription.TopicArn) that classifies as a REPLACEMENT. Deliberate: the recorded value is unusable, so converging it is the point. Two sibling classes are deliberately NOT covered and are filed rather than left silent: six SDK providers build ARNs fromclient.config.region()instead ofaccountInfo.region(issue #1881), and theAWS::Regionpseudo-parameter still returned the raw spelling so a user's ownFn::SubARN inherited it (issue #1882, CLOSED 2026-08-25 by a fold at its source). Two claims in this entry were corrected on 2026-08-25 and the correction is recorded here rather than by silent edit, because three follow-up issues inherited them verbatim: this entry said an upper-cased--regionis reachable "since DNS is case-insensitive and the SDK endpoint still resolves, so the deploy SUCCEEDS", and it is not — SigV4 scopes a credential to the region STRING and the service compares it case-sensitively, so every call is refused withSignatureDoesNotMatch: Credential should be scoped to a valid region(measured 2026-08-25 against this repo's vendored SDK on STS and CloudFormation, and the same day'scdkd-side measurement in issue #2065 had already recorded the S3 preflight dying the same way). The values this fold corrects are therefore reached from region sources OTHER than a raw--region— an explicitoverrideRegion, a library caller buildingAwsClientsdirectly — rather than from the flag; the fold is right either way, but the reachability story it shipped with was wrong. Unit tests pin BOTH polarities at every site (an upper-cased region folds; an already-canonical one comes out byte-identical), acn-north-1row per file so the partition and the region segment are proven to land together, one fully literalarn:aws-cn:expectation so a wrong-but-consistent partition cannot move with the bug, and theWebsiteURLlegacy-dash branch flip in both directions.✅
cdkd exportbackfills a pre-#1761AWS::EC2::SecurityGroupIngressrule id by live-reading AWS (issue #1791) —src/cli/commands/export.ts,tests/unit/cli/export-sg-ingress-rule-id-backfill.test.ts,docs/cli-reference.md,docs/state-management.md. A row written before #1761 hasattributes: {}, and export is all-or-nothing, so ONE such row made a whole stack un-exportable with no workaround (AWS returns thesgr-…id only fromAuthorizeSecurityGroupIngress, so a no-op re-deploy heals nothing).CompositePhysicalIdIdentifiergains an optional asyncbackfillreached ONLY after the state-onlyresolvethrew — so a row that already records theIdissues no AWS call — and theAWS::EC2::SecurityGroupIngressentry supplies one: a paginatedDescribeSecurityGroupRulesfiltered by the composite'sgroupId, matched on the composite's(ipProtocol, fromPort, toPort)tuple with the protocol canonicalized on BOTH sides (the #1643 fold, so a record packed fromIpProtocol: 6matches AWS'stcp) and egress rules excluded. EXACTLY ONE match is adopted; zero, more than one, an unparseable composite, a failed lookup and an exhausted page ceiling each REFUSE with a message naming the row and what was ambiguous — the same discipline #1761 applies on its already-exists arm, because two rules sharing that tuple are two rules cdkd's own physical id cannot tell apart either. The MATCHES are counted before any candidate is set aside for carrying an unusable id, so a genuine 2-match whose other candidate AWS reported without ansgr-…id refuses instead of adopting the survivor (and the zero case can no longer claim it "found NO ingress rule matching" a tuple that rules did match); a BLANK port segment is refused beforeNumber('')can read it as port0and look up a real tuple (EC2Provider.cfnIngressPortValuereads the same blank as-1, so the two layers did not agree on what it meant); the lookup is wrapped in the standard throttle-onlywithRetry, so oneRequestLimitExceededno longer aborts an all-or-nothing export and a persistent throttle is reported as a throttle rather than as a missingec2:DescribeSecurityGroupRules; the walk is memoized per GROUP for the lifetime of ONEbuildImportPlancall (never module-global, which would be shared across the stacks a concurrent run exports), caching the OUTCOME rather than anErrorso each row's refusal still names ITSELF; and the ambiguity refusal names the SECOND cause too — two DISTINCTAWS::EC2::SecurityGroupIngressresources differing only by source have byte-identical composite ids, so "split it per source" is not their remedy (repairattributes.Id, or drop the row) — and that two-cause remedy is appended to the unusable-id refusal as well whenever more than one rule matched, since such a row is ambiguous however readable its ids are and would otherwise be told only to "open a cdkd issue".✅ Fix: a WebAuthn-only User Pool no longer gets an invented
MfaConfiguration: OPTIONAL—src/provisioning/providers/cognito-provider.ts. Bug (issue #1920): the MFA-config family (EnabledMfas/EmailAuthentication*/WebAuthn*) is applied by a post-createSetUserPoolMfaConfigcall, and because that API is a full-replace — an omittedMfaConfigurationresets the pool to OFF and drops the factor blocks being enabled in the same call — cdkd defaulted the field toOPTIONALwhenever the template omitted it. That reasoning only holds when the call actually enables an MFA FACTOR. WebAuthn is not one, so a passkey-first pool (passkeyRelyingPartyIdwith nomfa, whose CloudFormation default forMfaConfigurationisOFF) had AWS reject the call withInvalid MFA Configuration given. SMS MFA, Email MFA, or Software Token MFA must be enabled.; the post-create atomicity path then deleted the freshly created pool, socdkd deployfailed on a template the CDK CLI deploys fine. Fix: when the template omitsMfaConfiguration, default toOPTIONALonly if the call enables a factor, else toOFF— CloudFormation's own default. An explicit template value still wins in every case. The factor test is deliberately TWO-part (EnabledMfasnon-empty OR a factor sub-block emitted), and both halves are load-bearing. The requested-set half keeps a MISSPELLED entry loud:EnabledMfas: ['SOFTWARE_TOKEN']emits no factor block, so a recognized-set-only test would have sentOFF, which AWS ACCEPTS — the pool would have shipped with MFA silently disabled and the declared factor dropped, turning a loud pre-existing failure into a silent one. The same hole exists via the property's SHAPE rather than its spelling: a hand-written YAML scalar (EnabledMfas: SOFTWARE_TOKEN_MFA) or a!Refto aStringparameter failsArray.isArray, so it too read as "no factor declared". A present-but-non-list value is therefore treated as factor intent as well (and warned about) — inhasMfaConfigPropstoo, so the call is not skipped outright and the factor dropped one layer earlier. Keying on what the template DECLARED rather than on what cdkd RECOGNIZES also keeps this forward-compatible with any factor AWS adds later, which is why an unrecognized entry WARNS rather than throwing. The warning is emitted AFTER the default resolves and reports the value actually being sent, because a warning written to PREDICT its outcome states the opposite in two branches: an explicit templateMfaConfiguration: OFFreally does deploy MFA-disabled, and a dropped entry riding alongside a recognized factor is not what fails the call. Both are named explicitly now, and the second arm says a factor block IS SENT rather than that a factor IS ENABLED, because that is all the code knows; predicting a rejection on the silent-drop path is the worst of the three things the message could say. Warn coverage is widened separately in issue #1932. The sub-block half deliberately leaves the email-OTP message/subject shape on its existingOPTIONALdefault:EmailMfaConfigurationis emitted for a bareEmailAuthenticationMessage/Subjectcustomization too, and whether AWS accepts that block underOFFis unverified — the integ cannot reach it (email-OTP needs a verified SES sender), so it was not flipped on an untested wire assumption (issue #1923). Applies to create and update alike (both route throughapplyMfaConfig). 23 new unit tests pin both polarities of the default (WebAuthn-only and an emptyEnabledMfas->OFF, on create AND update; a real factor, alone or alongside WebAuthn, and the SMS arm ->OPTIONAL), both polarities of the override (an explicitOFFbeats theOPTIONALdefault, an explicitONbeats theOFFone), the misspelled-entry guard, the non-list shape (its default, its warn, that it still issues the call at all, and that it keeps an explicit value offCreateUserPool),null/''as absence rather than intent, and both polarities of the warn; the WebAuthn-only test now pins the WHOLE request viatoEqual, since the original blind spot was precisely a field nobody asserted. Thecognitointeg fixture gained both arms: aPasskeyOnlyUserPool(WebAuthn, noMfaConfiguration) assertingOFFwith its WebAuthn config intact, and aFactorDefaultUserPool(a factor, noMfaConfiguration) assertingOPTIONAL— the inverse regression, where the fix would silently disable MFA on a pool that asked for it.BackfillUserPoolmoved fromOPTIONALto an explicitONand its assertion was pinned exactly, becauseOPTIONALis what the default produces anyway and so could not distinguish a threaded explicit value from a fired default.✅
update()can now report a PARTIAL outcome, and the ACM replacement orphan stops being invisible —src/types/resource.ts,src/deployment/update-outcome.ts,src/deployment/deploy-engine.ts,src/cli/commands/{deploy,drift}.ts,src/deployment/rollback-executor.ts, the ACM, two IAM and API Gateway providers. Gap (issue #1819):deletegained a skip channel in #1752;updatehad none, so an update that discovered it could not finish had two options — return normally (a lie) or throw (fail the resource). Four providers implement a REPLACEMENT insideupdate()by pairing create and delete, and by the time the inner delete runs the NEW resource already exists, so aborting would strand an untracked resource. The three create-then-delete providers therefore emitted alogger.warnand the deploy exited 0 with the old resource alive and no longer in cdkd state — discoverable only by reading the log. Live half (issue #1922): ACM refuses to delete a certificate a consumer still references (typically a CloudFront distribution in another stack not yet updated), so the replacement's delete throwsResourceInUseExceptionand lands in exactly that swallowing catch. Fix:ResourceUpdateResultis now a base intersected with a discriminated outcome union — omitted /'updated', or'partial'with a REQUIRED reason, mirroringResourceDeleteResultso the requirement is compiler-enforced rather than asserted in a doc comment. Omission still means clean, so the ~80 providers returning a bare{ physicalId, wasReplaced }are untouched. Deliberately no'skipped'member: an update that cannot touch the resource at all throws, so that value would be an unreachable enum member. The naming is load-bearing.'partial', not'skipped':RESOURCE_SKIPPED's documented invariant is "the resource this row names was not destroyed", and a partial update DID update its row's resource. #1922 proposed emittingRESOURCE_SKIPPEDfor the row, which would have put the events store at odds with its own contract. The engine instead emitsRESOURCE_SUCCEEDEDfor the updated row AND aRESOURCE_SKIPPEDnaming the SURVIVOR — for which the invariant is exactly true, and which carries the physical id a cleanup pass needs, since state now points at the new resource. No new event type was required. Consumption followsdelete-outcome.tsexactly: a leafupdate-outcome.ts(no imports beyond the type, because the engine,drift --revertand the rollback executor all consume it and already sit on a dense import ring), a counter kept apart from bothupdatedanddeleteSkipped, apartial (<reason>)status line at warn level, a summary row shown only when non-zero, and the shared run-levelskippedcountercdkd eventsrenders as⚠N. All FOURupdate()call sites consume it — the deploy engine,drift --revert, and BOTH rollback-executor arms (replayRollbackand the--revert-failedreplayFailedOperations, the latter found only in review) — so no entry point silently drops what another reports.sns-subscriptionis NOT wired: it is delete-then-create and aborts on a SKIPPED delete, but a THROWN one is still caught and it creates anyway, leaving two live subscriptions delivering every message twice. That is a behavior change (a currently-succeeding deploy would start failing) and is filed as #1967 rather than bundled here. Not included: the exit-code half of #1922 step 3. Deploy does not exit non-zero for its own skipped-DELETE case either, so making a partial UPDATE do so would be arbitrary, and making both do so is a behavior change to every deploy that skips a delete — filed separately. 14 new unit tests (helper, ACM producer, engine wiring), each carrying its clean-update control so none passes vacuously, and each mutation-proven: reverting the ACM producer to warn-only fails 2, always-partial fails 1, disabling the in-use classifier fails 1, counting a partial as clean fails 1, dropping the survivor event fails 1, dropping the status line fails 1.