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-06-13):
- ✅
--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.
- 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). 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. -
✅ 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.