Skip to content

Latest commit

 

History

History
143 lines (108 loc) · 148 KB

File metadata and controls

143 lines (108 loc) · 148 KB

cdkd changelog (extracted from CLAUDE.md)

Detailed per-PR notes split out from the project's main CLAUDE.md so that file fits within Claude Code's recommended ≤200-line CLAUDE.md size (official memory docs).

Each entry below describes a shipped change — the file at the top of the entry, the public-facing surface that changed, the user-visible behavior delta, the tests added, and (where present) the issue / PR number that drove it. Pre-PR behavior is described in the past tense and post-PR behavior in the present tense, so a reader reconstructing the history of any subsystem can read top-to-bottom by date and see when each capability landed.

The CLAUDE.md ## Known Limitations section retains the load-bearing summary ("NOT recommended for production use"); the per-PR detail moved here.


Recently Implemented (2026-06-13):

  • --region deprecation warning no longer contradicts the actual behavior (issue #818)src/cli/options.ts. warnIfDeprecatedRegion and the hidden deprecatedRegionOption help text both claimed --region "has no effect" on non-bootstrap commands, but every non-bootstrap command (deploy, destroy, diff, synth, list, state, force-unlock, publish-assets, import, export, orphan, drift, events, local *, …) actually consumes options.region as the highest-precedence region source: const region = options.region || process.env['AWS_REGION'] || 'us-east-1' feeds the provisioning / state-bucket SDK clients and the applyRoleArnIfSet STS hop, and deploy / destroy / import / export / orphan additionally inject it into process.env.AWS_REGION so the CDK synth subprocess inherits it (e.g. deploy.ts ~L167/L175/L341). The warning and the code therefore disagreed — a user passing --region was told it did nothing while it silently took effect. Investigation determined --region IS legitimately honored everywhere (option B in the issue), so the fix is purely in the warning + help text — no command implementation (deploy.ts etc.) was touched, keeping the change out of the integ-broad merge-gate scope and carrying zero behavior-change risk. The warning now reads "--region is deprecated and will be removed in a future release. It is still honored for now (it overrides AWS_REGION / your AWS profile), but prefer the AWS_REGION environment variable or your AWS profile…" and the option description drops the false "No effect" claim. The recommended mechanism is still AWS_REGION / the AWS profile; the flag stays hidden + deprecated, just honestly described. Docs corrected: two "deprecated and ignored" lines in docs/cli-reference.md and the --region bullet in .claude/rules/cli-internals.md. Tests: tests/unit/cli/options.test.ts — the existing message assertion updated, plus new assertions that neither the warning nor the option description contains "no effect" and that both mention the flag is "still honored" (issue #818).
  • destroy waits for NAT Gateway deletion before detaching / deleting the IGW + VPCGatewayAttachment (issue #817)src/analyzer/implicit-delete-deps.ts. Destroying a VPC + NAT Gateway + IGW stack attempted the VPCGatewayAttachment detach while the NAT Gateway's Elastic IP was still mapped to the VPC's public address space, failing with Network vpc-xxx has some mapped public address(es), after which the IGW delete hung (~19 min observed). This was the first-run failure split out of the #804 incident as a separate issue. The fix adds two type-based implicit delete-dependency edges so the shared deploy DELETE phase + standalone destroy command order the teardown like CloudFormation does: AWS::EC2::InternetGateway gains AWS::EC2::NatGateway (alongside its existing AWS::EC2::VPCGatewayAttachment dependee) and a new AWS::EC2::VPCGatewayAttachment key lists AWS::EC2::NatGateway — both are deleted AFTER the NAT Gateway is gone (NAT deletion releases / decouples the EIP). No type-based rule is needed for the EIP itself: the NAT Ref's its EIP via AllocationId, so the reversed delete traversal already deletes the NAT before the EIP is released. The injection logic (destroy-runner.ts / deploy-engine.ts) naturally produces no edge when no NatGateway is in state. Tests: 4 unit assertions in tests/unit/analyzer/implicit-delete-deps.test.ts (IGW-after-NAT edge, VPCGatewayAttachment-after-NAT edge, no NatGateway / EIP key registered; the existing no-self-cycle guard covers the new entries). Integ: the existing vpc-nat-gateway fixture (VPC + public/private subnets + IGW + NatGateway + EIP) exercises exactly this teardown end-to-end.
  • Exports index store resolves the state bucket's region before its write/remove (issue #819)src/state/export-index-store.ts. PR #803 fixed LockManager to resolve a cross-region state bucket's actual region via GetBucketLocation before any S3 op; the automated cross-region-state-bucket integ then surfaced that the exports index store (Fn::ImportValue cross-stack reference tracking, writes s3://{bucket}/{prefix}/_index/{region}/exports.json) still had the SAME unfixed bug. Its S3 client was pinned to the CLI base region, so against a state bucket in another region every index write (after a deploy save) and remove (after a destroy) hit S3's 301 PermanentRedirect, logged as Exports index remove failed (non-retryable): The bucket you are attempting to access must be addressed using the specified endpoint ...; continuing without index update. Non-fatal by design (the canonical state.json is written through the already-region-corrected S3StateBackend and stays correct; the index is a perf-only derived view that self-heals on the next lookup miss-and-patch / rebuild), so the run still passed — but the cross-region exports index was silently never maintained. The fix ports the LockManager.ensureClientForBucket() pattern into ExportIndexStore: before its first S3 read (readIndexRaw) or write (writeIndex) it resolves the bucket's region (cached process-wide via resolveBucketRegion, so when the state backend / lock manager already resolved the same bucket there's no extra GetBucketLocation call) and, if it differs from the supplied client's region, builds a private replacement S3Client for that region — reusing the caller's resolved credentials (so --profile / static creds carry over without threading client options through the four store call sites) and NOT destroying the shared AwsClients.s3 instance other components still hold. The resolution is memoized + single-flight (clientResolved / resolveInFlight), and degrades gracefully for a test double whose client lacks the SDK config.region() shape (skips resolution, uses the client unchanged) — so the store stays contained, with no ripple to deploy.ts / destroy.ts / state.ts / local-state-loader.ts. Tests: 4 new unit tests in tests/unit/state/export-index-store.test.ts (removeStack + updateForStack succeed through a region-corrected client when the bucket region differs — pre-fix 301; no client rebuild when the resolved region matches; the bucket region is resolved exactly once across multiple index ops). Integ: the cross-region-state-bucket fixture stack now publishes a CloudFormation Output with an Export.Name (an export-less stack short-circuits the index write entirely), and verify.sh greps the cdkd deploy + cdkd destroy --verbose output to assert the exports-index 301 warning is GONE on both paths AND that _index/{region}/exports.json was actually written to the cross-region bucket on deploy. New scenario tag exports-index-region-resolve.
  • destroy handles the first Ctrl-C gracefully — flushes state + releases the lock instead of stranding it (issue #816)src/cli/commands/destroy-runner.ts + src/cli/commands/destroy.ts + src/cli/commands/state.ts. This is the deferred "optional fix 3" from #804 (the incremental-state-persistence + CR fail-fast work shipped in PR #814). Before: cdkd destroy / cdkd state destroy had NO SIGINT handler, so a first Ctrl-C killed the process mid-destroy — the finally that releases the stack lock never ran, leaving the lock stranded for its full TTL, and any in-flight provider delete was severed abruptly. After (Terraform parity): the runner registers a per-call SIGINT handler that on the FIRST Ctrl-C sets a draining flag — the reverse-DAG delete loop checks it before scheduling each subsequent LEVEL (and, defense-in-depth, before dispatching each resource), so NO new delete is started; the deletes already in flight in the current level are awaited to completion (NOT cancelled). Control then falls through to the existing finally, which flushes the incremental save-chain from #804 (so the preserved state.json lists only the resources that still exist), stops the live renderer, and releases the lock. A SECOND Ctrl-C bypasses graceful shutdown (process.exit(130)). On a graceful interrupt the runner PRESERVES state (it does NOT deleteState, even though errorCount === 0, because resources remain) and surfaces the outcome via a new DestroyRunnerResult.interrupted flag; both destroy.ts and state.ts stop their multi-stack loop on the first interrupted stack and throw PartialFailureError (exit code 2) so scripts / CI see the destroy did not complete. The handler reads/writes only its own call's closure state and is removed via process.removeListener('SIGINT', ...) in the finally, so no listener leaks — important for nested-stack recursion, where NestedStackProvider.delete recurses into runDestroyForStack and registers one handler per level (Node delivers SIGINT to every listener, so the first Ctrl-C drains the parent AND every in-flight child). A re-run of cdkd destroy after a graceful interrupt resumes cleanly with no replay (the #814 incremental state already trimmed the deleted resources) and no wait for the lock TTL. Tests: 5 unit tests in tests/unit/cli/destroy-runner-sigint.test.ts (the SIGINT handler is captured by spying on process.on('SIGINT', ...) and invoked directly — no real OS signal is sent): first Ctrl-C finishes the in-flight delete + schedules no new deletes + preserves the trimmed state + releases the lock + marks interrupted; the level-boundary gate stops all subsequent levels; a second Ctrl-C force-quits via process.exit(130); a normal completion leaves interrupted: false and removes the listener; process.removeListener is invoked in the finally. Happy-path (uninterrupted) destroy is unchanged. Docs: destroy-interruption subsection in docs/state-management.md + the stale-lock note in docs/troubleshooting.md.
  • deploy retries the ECS CapacityProvider same-stack infrastructure-role IAM-propagation race (issue #805)src/deployment/retryable-errors.ts. cdkd's event-driven DAG dispatches the Cloud Control CreateResource for an AWS::ECS::CapacityProvider (Managed Instances) as soon as its same-stack infrastructure role finishes creating, and cdkd's fast SDK path creates the IAM role without waiting for propagation — so ECS tried to assume the just-created InfrastructureRoleArn before IAM had propagated it and rejected the create with Caught ServiceAccessDeniedException for ECSInfrastructureRole[arn:...]. The CC API handler classifies this as a terminal InvalidRequest (no internal retry, SDK Attempt Count: 1), and none of the existing message patterns matched it, so the deploy failed fast on a transient error. The fix adds 'Caught ServiceAccessDeniedException' to RETRYABLE_ERROR_MESSAGE_PATTERNS — mirroring the ENHANCED_MONITORING pattern added for #794 — so the deploy engine's existing withRetry (8 attempts, ~47s cumulative) absorbs the propagation window; the phrase is anchored on the CC-API/ECS handler wording so a genuine, permanent role misconfiguration only burns the bounded retries before surfacing. Generic by design: any Cloud-Control-provisioned type that validates a same-stack IAM role at create time and surfaces ServiceAccessDeniedException is covered. Tests: the exact wire message from the issue classifies retryable + a plain AccessDeniedException (without the handler's "Caught" anchor) stays non-retryable in retryable-errors.test.ts. Verified by the issue reporter against the real-world 33-resource stack that surfaced the bug (the capacity provider create retried through the window and completed).
  • AWS::ECS::TaskDefinition Volumes[].ConfiguredAtLaunch no longer silently dropped (issue #806)src/provisioning/providers/ecs-provider.ts. ECSProvider.convertVolumes mapped only Name / Host / EFSVolumeConfiguration when converting CFn Volumes to the RegisterTaskDefinition wire shape; ConfiguredAtLaunch was dropped, so the registered task definition had no configuredAtLaunch volume and a same-stack AWS::ECS::Service carrying VolumeConfigurations (a managed EBS volume — CDK's ServiceManagedVolume) failed to create with "Volume configuration provided but no matching configuredAtLaunch volume found in task definition". The pre-flight property-coverage gate could not catch this class: it works at top-level property granularity and Volumes IS in handledProperties — the gap was one level down, inside the handled property. convertVolumes now forwards configuredAtLaunch via a coerceBool helper (same pattern as EC2Provider's) that normalizes CFn boolean-ish values (true / "true" / false / "false") at the wire boundary and returns undefined for absent props so the field is omitted from the SDK input (AWS keeps its default). No parallel update() change is needed — ECS TaskDefinitions are immutable revisioned resources; property changes route through Replace (CREATE then DELETE). Tests: 4 unit tests (present-true forwarded, string "true" / "false" coerced, absent omitted, explicit false preserved as distinct from omit). The ecs-fargate integ fixture gains a ServiceManagedVolume (1 GiB gp3, XFS) mounted into the container and attached to the Service via service.addVolume() — synthesizing exactly the ConfiguredAtLaunch + VolumeConfigurations pairing the bug broke (with desiredCount: 0 no task launches, so no EBS volume is actually created); verify.sh asserts the registered task definition's ebs-data volume has configuredAtLaunch == true (probed via jq has() — the // operator would map an explicit false to the fallback) and that DescribeServices shows the deployment carrying the ebs-data volume configuration. Remaining convertVolumes sub-property gaps of the same class (DockerVolumeConfiguration / FSxWindowsFileServerVolumeConfiguration unmapped; Host / EFSVolumeConfiguration cast without PascalCase-to-camelCase conversion) are tracked separately per the issue.
  • AWS::ECS::TaskDefinition Volumes[] sub-configurations fully PascalCase-to-camelCase converted (issue #815)src/provisioning/providers/ecs-provider.ts. The remaining convertVolumes sub-property gaps deferred from #806 are now closed. Before: DockerVolumeConfiguration and FSxWindowsFileServerVolumeConfiguration were not mapped at all (silently dropped from RegisterTaskDefinition), and Host / EFSVolumeConfiguration were cast through raw — so their nested CFn-PascalCase keys (Host.SourcePath, EFSVolumeConfiguration.{FilesystemId, RootDirectory, TransitEncryption, TransitEncryptionPort, AuthorizationConfig.{AccessPointId, IAM}}) reached the ECS SDK still PascalCase and AWS dropped them. This is the same PascalCase-to-camelCase trap already fixed for the ContainerDefinitions sub-arrays (convertEnvironment / convertSecrets / convertMountPoints etc.). The property-coverage gate could not catch it — Volumes IS in handledProperties, so the gap was one level down inside the handled property. After: convertVolumes runs each volume sub-block through a dedicated explicit converter (convertVolumeHost / convertDockerVolumeConfiguration / convertEFSVolumeConfiguration + convertEFSAuthorizationConfig / convertFSxWindowsVolumeConfiguration + convertFSxWindowsAuthorizationConfig), matching the provider's existing per-type converter style. The case mapping is NOT a simple first-letter flip in two spots, verified against the CDK L1 *ToCloudFormation mappings: EFS uses FilesystemId (lowercase s) while FSx uses FileSystemId (capital S), and EFS AuthorizationConfig uses IAM (all caps), not Iam. Autoprovision (Docker) and TransitEncryptionPort (EFS) are coerced at the wire boundary (coerceBool / Number(...)) since CFn can carry them stringly-typed. readCurrentStateTaskDefinition now also normalizes the camelCase SDK volumes shape back to PascalCase via the new volumesToCfn SDK-to-CFn converter, so the readCurrentState drift snapshot matches the deploy-time template form (forward-looking — TaskDefinitions are immutable replace-only today, so no UPDATE path consumes it yet). Tests: 8 new unit tests (EFS full-shape conversion + stringly-typed TransitEncryptionPort coercion + Docker full-shape + stringly-typed Autoprovision coercion + FSx full-shape + Host.SourcePath + omit-when-absent for every sub-block) plus a readCurrentState normalization test asserting all four sub-block types round-trip back to PascalCase; the two pre-existing #806 volume tests were updated for the new omit-when-absent key set and the corrected PascalCase Host.SourcePath input. Integ: the ecs-fargate fixture gains an efs.FileSystem + efs.AccessPoint (public subnets, RemovalPolicy.DESTROY) and an efsVolumeConfiguration volume on the task definition; verify.sh asserts describe-task-definition shows the efs-data volume's efsVolumeConfiguration reached AWS with camelCase fileSystemId / transitEncryption: ENABLED / authorizationConfig.{accessPointId, iam: ENABLED}. EFS is the integ-verified path; DockerVolumeConfiguration (Docker-daemon-scoped, unsupported on Fargate) and FSxWindowsFileServerVolumeConfiguration (Windows / FSx-specific) are hard to integ on Fargate and are covered by the unit tests only.
  • Cloud Control UPDATE re-includes write-only properties in every patch document (issue #809)src/provisioning/cloud-control-provider.ts + new src/provisioning/write-only-properties.ts. Cloud Control applies UPDATE patches read-modify-write: the type's read handler returns the current model, the patch is applied on top, and the result becomes the desired state — but read handlers cannot return write-only properties, so any write-only property absent from cdkd's minimal previous-vs-desired patch silently vanished from the desired state on every CC-routed UPDATE. AWS::ECS::Service (writeOnlyProperties: ServiceConnectConfiguration / VolumeConfigurations / ForceNewDeployment) hard-failed: a task-definition-only change on a service with a managed EBS volume produced a patch without VolumeConfigurations, and UpdateService rejected with "Task definition has configuredAtLaunch volume but no volume configuration provided at runtime", wedging the stack (state still recorded the old properties, so every subsequent deploy retried the same failing patch). Types whose handler accepts the write-only-less state lost the configuration silently instead. The fix mirrors terraform-provider-awscc: CloudControlProvider.update now resolves the type's writeOnlyProperties from the registry schema via cloudformation:DescribeType (reduced to the top-level containing property — a nested path like /properties/Foo/Bar strips to Foo), removes those properties from the PREVIOUS side, and regenerates the patch — the generator then naturally emits add ops for every write-only property present in the desired properties, which is exactly what the CC read-modify-write contract requires. Only write-only properties are force-included (blanket-upserting all desired properties would risk false replacement signals on createOnlyProperties whose read-back form differs from the stored form). Only SUCCESSFUL DescribeType results are cached per resource type for the deploy lifetime in a module-level map, so repeated updates of the same type pay one throttled-API call; a DescribeType failure (missing IAM permission, transient throttle / 5xx) is NOT cached — it warns and falls back to the pre-#809 minimal patch for that update, and a later update of the same type retries DescribeType. Caching failures would let one transient throttle on the first CC-routed UPDATE silently disable write-only re-inclusion for every CC-routed type for the rest of the deploy, reintroducing the exact hard-fail this fixes. No regression for callers permanently without the new cloudformation:DescribeType permission — each update simply re-warns and re-falls-back. A DescribeType response without a Schema (e.g. a still-registering type) is treated as "no write-only properties" — a successful, cacheable, warning-free lookup. Removal-only write-only diffs skip the update entirely (CC cannot remove what its read handler never returns — pre-fix a remove op against a path absent from the current model would have failed), and the no-change fast path still skips without any DescribeType call. Tests: 11 unit tests in tests/unit/provisioning/cloud-control-provider.test.ts (unchanged write-only prop rides along as add; changed write-only prop not duplicated; nested-path top-level strip; no-write-only type keeps the minimal patch; DescribeType failure warning + fallback; per-type caching of successful lookups; failures NOT cached so a later update retries; retry-after-failure uses the populated set on success; Schema-less response = no write-only props + no warning; no-change skip; removal-only skip). Integ: the ecs-fargate fixture's ServiceManagedVolume + CDKD_TEST_UPDATE pass (issues #806/#807) exercises this exact path end-to-end once both land.
  • Replacement of a referenced resource now propagates to dependents diffed as NO_CHANGE (issue #807)src/analyzer/diff-calculator.ts + src/analyzer/template-parser.ts. Diff-time intrinsic resolution runs against CURRENT state, so a dependent whose only "change" was a Ref / Fn::GetAtt to a resource that gets a NEW physical ID on replacement (e.g. AWS::ECS::TaskDefinition — every revision is a new ARN) compared equal and landed on NO_CHANGE; the deploy engine excluded it from the execution DAG and never re-pointed it at the new physical resource. For ECS this meant a task definition change registered a new revision but UpdateService was never issued — the service kept running tasks on the old, now-deregistered revision. CloudFormation propagates the new physical ID to dependents; cdkd now mirrors that: after per-resource diffs are computed, DiffCalculator.promoteReplacementDependents walks reverse reference edges (built from the desired template's per-property Ref / Fn::GetAtt / Fn::Sub / nested-intrinsic references via the new public TemplateParser.extractReferences; DependsOn is excluded — pure ordering carries no value to propagate) from every replacement-triggering UPDATE and promotes NO_CHANGE dependents to UPDATE with synthetic PropertyChange entries for the referencing top-level properties. Each synthetic change is re-evaluated against ReplacementRulesRegistry with undefined old/new values — the referencing property's template value did not actually change (only its resolved physical ID / ARN will), so unconditional replacementProperties still fire on the property name while conditionalReplacements are NOT fed a phantom resolved-string → unresolved-intrinsic delta that would falsely report "changed" and spuriously enqueue the dependent's grandchildren (review Fix 1). A promoted dependent whose referencing property is itself immutable (e.g. a TaskDefinition whose ContainerDefinitions reference a replaced resource) becomes a replacement seed for its dependents — the walk is transitive, and the enqueued guard makes it terminate even on a reference cycle (A→B→A). Dependents that already had their own property changes stay UPDATE and gain the referencing-property entry (no duplicates); CREATE / DELETE dependents are untouched. Over-promotion is harmless by construction: the deploy engine's UPDATE path re-resolves desired properties against the in-flight state map (which by DAG order already carries the replaced dependency's new physical ID) and skips the provider call when the resolved properties are unchanged. Each synthetic PropertyChange carries replacementPropagated: true (a new optional field on the shared PropertyChange type) so cdkd diff annotates the property line [replacement propagated] — the apparent old-value → {Ref} delta reads as a propagated replacement rather than a literal value edit (review Fix 2). Tests: 8 unit tests (Ref promotion + unrelated NO_CHANGE stays, GetAtt promotion, transitive A→B→C with replacement re-evaluation, already-UPDATE dependent append-once, in-place update does NOT promote, replacementPropagated marker present, conditionalReplacement does NOT spuriously promote grandchildren, reference-cycle termination); the ecs-fargate integ fixture gains a CDKD_TEST_UPDATE=true Phase 1b (container command change → TaskDefinition replacement) whose verify.sh asserts the Service's taskDefinition tracks the new ACTIVE revision carrying the updated command.
  • Interrupted / partially-failed destroy no longer replays Custom Resource deletes against an already-deleted backing Lambda (issue #804)src/provisioning/providers/custom-resource-provider.ts + src/cli/commands/destroy-runner.ts. Two layered fixes:
    • CR provider delete fail-fast. Before: re-running a destroy whose first run had already deleted a Custom Resource AND its backing Lambda stalled ~10 minutes per CR — the delete entered waitForBackingLambdaReady, whose SDK v3 waiters classify ResourceNotFoundException as RETRY (no error acceptor) and poll GetFunction for the full maxWaitTime: 600, until the lenient delete catch swallowed the timeout. After: delete() issues ONE GetFunction pre-check before preparing the invocation; a definitive ResourceNotFoundException logs a warning and treats the Custom Resource as already deleted (warn-and-continue is the provider's existing delete policy), restoring re-run idempotency parity with every other resource type. Inconclusive pre-check errors (throttle, IAM) fall through to the normal invoke path; SNS-backed tokens skip the pre-check; create / update are unchanged (they must keep failing loudly against a missing function).
    • Incremental state persistence on destroy (Terraform parity, root fix). Before: destroy state handling was all-or-nothing — deleteState on full success, untouched full state on any failure / interrupt, so a preserved state still listed every already-deleted resource and the next run replayed them all. After: runDestroyForStack mirrors deploy's saveStateAfterResource — each successfully deleted resource (including the idempotent "not found → already deleted" path) is removed from a working copy of state.resources and the trimmed state is persisted to S3 per resource, serialized through a save chain under the already-held stack lock. Retained resources (DeletionPolicy: Retain) stay in every snapshot (their record is only dropped by the wholesale state-file delete at the end of a clean destroy, as before). Persist failures are warn-and-continue and never fail the destroy; the final write remains authoritative (deleteState on errorCount === 0, a final preserve-write of the remaining resources on errorCount > 0); the save chain is flushed before deleteState (no resurrection race) and before lock release. Nested stacks inherit the behavior automatically — NestedStackProvider.delete routes child destroys through the same runDestroyForStack. cdkd destroy and cdkd state destroy share the runner, so both get it.
    • Persisted destroy snapshots clear outputs / drop imports / outputReads (phantom-export fix). Both the incremental writes and the final partial-failure preserve-write now write outputs: {} and omit imports / outputReads. outputs is keyed by output NAME (not logical id) so it cannot be pruned per-resource as backing resources are deleted; a partially/fully destroyed stack has no meaningful outputs, and leaving them in the preserved state would advertise an export whose backing resource is gone — a phantom export the exports index or another producer's scanActiveConsumers strong-ref scan could pick up. The destroy's OWN strong-ref check is unaffected: it reads the in-memory state.outputs BEFORE the delete loop, and the in-memory state object is never mutated (only the persisted snapshot copies are cleared). On a clean destroy the exports-index entry is removed via exportIndexStore.removeStack; on a partial destroy the index may briefly carry stale entries (a perf-only derived view that self-heals), but the canonical state.json no longer carries the phantom outputs.
    • Issue's optional fix 3 (graceful SIGINT handling for destroy: stop scheduling, persist, release the lock) was deferred here and later shipped as issue #816 (see the dedicated entry above). The first-run IGW/NAT/EIP implicit delete-dependency gap mentioned in the issue is a separate issue.
    • Tests: 5 CR fail-fast unit tests (custom-resource-provider.test.ts — Lambda gone → 1 GetFunction, no waiter polls / no invoke / no S3, warn logged; Lambda present → unchanged 4-call path; inconclusive pre-check on Throttling / AccessDenied / generic 5xx → falls through to the normal invoke; SNS ServiceToken → no GetFunction pre-check issued) + 11 destroy-runner unit tests (destroy-runner-incremental-state.test.ts — per-resource trimmed persists then deleteState, partial-failure state contains only failed/remaining resources, "not found" removal, incremental + final persist failures are non-fatal, retained resources survive snapshots, persisted snapshots clear outputs/imports/outputReads while the in-memory state is preserved, mid-chain incremental persist failure doesn't poison later links, 3-concurrent-sibling snapshots shrink monotonically, nested-stack child drives its own state key and flushes before the parent deleteState).
  • Structured deployment events + cdkd events command (issue #808)src/types/deployment-events.ts, src/state/deployment-events-store.ts, src/cli/commands/events.ts, plus event-emission seams in src/deployment/deploy-engine.ts, src/cli/commands/deploy.ts, src/cli/commands/destroy-runner.ts, src/cli/commands/destroy.ts. cdkd now records a CloudFormation DescribeStackEvents-equivalent stream of structured deployment events to S3 for every cdkd deploy / cdkd destroy run, readable back with the new cdkd events <stack> command. Pre-PR the only durable artifact of a failed run was the (partial) state.json; per-resource lifecycle detail (which op failed, why, in what order, with what AWS error) existed only as transient stdout/stderr log output — making post-hoc troubleshooting (especially handing context to an AI agent on another machine / session) impossible.
    • Event types: RUN_STARTED / RUN_FINISHED (command, region, cdkd version, terminal result, per-op counts); RESOURCE_STARTED / RESOURCE_SUCCEEDED / RESOURCE_FAILED (logicalId, resourceType, provisionedBy, physicalId on success, durationMs, error metadata on failure); RESOURCE_RETAINED (destroy-side DeletionPolicy: Retain skip); ROLLBACK_STARTED / ROLLBACK_RESOURCE_SUCCEEDED / ROLLBACK_RESOURCE_FAILED / ROLLBACK_FINISHED. Failure events carry { name, message, awsErrorCode?, requestId? } extracted from the innermost AWS-SDK-shaped error in the thrown error's .cause chain.
    • Emitter seam: the DeployEngine emits per-resource + rollback events through an optional DeploymentEventRecorder injected via DeployEngineOptions.eventRecorder (around the existing provisionResource / performRollback paths — no logging rewrite); the destroy runner emits per-resource DELETE events through DestroyRunnerContext.eventRecorder; the deploy / destroy CLIs own the run-level RUN_STARTED / RUN_FINISHED events (they know the command / version / result) and finalize() the recorder in a finally.
    • S3 layout (no state schema bump): JSONL at s3://{bucket}/{prefix}/{stackName}/{region}/deployments/{runId}.jsonl + a small deployments/index.json (last 20 runs, newest first). Deliberately a separate key family from state.json — state stays at its current version (no integ-schema-migration gate), fully backward compatible. Event files survive cdkd destroy (state deletion does not touch deployments/), so a destroyed stack's failure history stays readable.
    • Best-effort, never blocking: record() is synchronous + buffers in memory; flushes are async (debounced timer + size threshold) serialized on a write chain; a failed S3 write warns at most once then degrades to debug — it can NEVER fail or block the deploy / destroy. No locking (per-run unique .jsonl keys; index.json is last-writer-wins — a derived view, the .jsonl files are the source of truth). No resource properties in events (secrets) — errors + metadata only; properties already live in state.json.
    • cdkd events <stack> [--run <id>] [--format json] [--stack-region <r>]: lists runs newest-first (from the index, falling back to {runId}.jsonl key enumeration); --run reads one run's ordered stream (skipping torn / malformed lines from an interrupted flush); --format json (alias --json) emits raw JSON for tooling / AI-agent hand-off. State-driven (no synth, no lock); region auto-discovered from the deployments/ key listing so it works for destroyed stacks. Registered in src/cli/index.ts.
    • Tests: tests/unit/state/deployment-events-store.test.ts (JSONL shape, no-properties-leaked, error-metadata capture, best-effort no-throw + one-shot warn, empty-run no-artifact, index newest-first + truncation-to-N, corrupt-index rebuild, reader listing / index-fallback / torn-line skip / region discovery), tests/unit/deployment/deployment-events-emission.test.ts (ordered emission, no-properties, AWS-error-metadata + rollback events on failure, no-recorder back-compat, throwing-recorder never breaks deploy), tests/unit/cli/commands/events.test.ts (list / read-one / --format json / not-found / no-history / multi-region ambiguity / --stack-region). Docs: new docs/deployment-events.md + a cdkd events section in docs/cli-reference.md. Out of scope (per the issue, deferred): cdkd doctor --bundle diagnostic bundle + MCP server exposure.
    • Follow-up (review fixes, same PR):
      • cdkd events no longer mislabels a successful run as FAILED in the index-fallback (user-visible correctness fix). When deployments/index.json is missing / corrupt, DeploymentEventsReader.listRuns rebuilds the run listing by enumerating the {runId}.jsonl keys. It previously stamped every fallback row result: 'FAILED' — so a run that genuinely SUCCEEDED but whose index.json write lost the last-writer-wins race showed as FAILED. The fallback now reads each run's JSONL and derives the true terminal result (command / cdkd version / timestamps / event count too) from the run's last RUN_FINISHED event; a run with no terminal RUN_FINISHED (interrupted, or index write lost) reports the new result: 'UNKNOWN' (added to a DeploymentRunSummaryResult = DeploymentRunResult | 'UNKNOWN' type used only on the summary; the run-level emitters still only ever produce SUCCEEDED / FAILED) and is colored neutrally in the run listing rather than red.
      • Run-level bracket extracted to src/cli/commands/deployment-events-run.ts (startRunRecorder / recordRunSucceeded / recordRunFailed) so the RUN_STARTED / RUN_FINISHED + --dry-run-skips-recorder + extractDeploymentEventError-on-failure contract is directly unit-testable and shared by both deploy.ts and destroy.ts (behavior identical to the prior inline code).
      • Added tests: tests/unit/types/deployment-events.test.ts (extractDeploymentEventError deepest-AWS-shaped-error extraction, bounded-depth-10 + cyclic-chain guard, non-Error inputs), tests/unit/cli/destroy-runner-events.test.ts (destroy-runner RESOURCE_STARTED / SUCCEEDED / FAILED + RESOURCE_RETAINED for a DeletionPolicy: Retain skip + no-recorder back-compat), tests/unit/cli/deployment-events-run.test.ts (run-level bracket: dry-run = no recorder, RUN_STARTED at create, success RUN_FINISHED with counts, failure RUN_FINISHED with result: 'FAILED' + error metadata, no-properties-leak), a ROLLBACK_RESOURCE_FAILED case in deployment-events-emission.test.ts, the no-FAILED-fabrication + UNKNOWN-on-torn cases in deployment-events-store.test.ts, and a listRawKeys multi-page ContinuationToken pagination case in tests/unit/state/s3-state-backend.test.ts.
  • LockManager resolves the state bucket's actual region before lock operations (issue #803)src/state/lock-manager.ts. PR #60 taught S3StateBackend to resolve a cross-region state bucket's real region via GetBucketLocation and rebuild its S3 client, but LockManager was left out: it kept using the raw client pinned to the CLI's base region (AWS_REGION / fallback us-east-1), so against a bucket in another region every state read/write succeeded while every lock acquisition failed with S3's 301 PermanentRedirect ("must be addressed using the specified endpoint") — contradicting the documented "the state bucket can live in any AWS region" guarantee. LockManager now has its own ensureClientForBucket() (awaited at the top of acquireLock / getLockInfo / releaseLock / deleteLock) mirroring the state backend's pattern with two deliberate differences: the replacement client reuses the original client's resolved credentials provider (so --profile / static credentials carry over without threading client options through the 8 new LockManager(...) call sites), and the original client is NOT destroyed (it is the shared AwsClients.s3 instance other components still hold). resolveBucketRegion caches per bucket name, so when the state backend already resolved the same bucket the lock path adds no extra GetBucketLocation call. The fix is contained entirely inside LockManager — none of the 8 call sites changed. Unit tests: region-mismatch rebuild (pre-fix 301 path — the PutObject goes through the rebuilt client), same-region no-rebuild, single resolution across multiple lock ops, and resolver receives the caller's credentials + fallback region. The cross-region-state-bucket integ fixture is now AUTOMATED: its new verify.sh creates a temporary uniquely-named state bucket in us-west-2, runs deploy / state ls / destroy with AWS_REGION=us-east-1, asserts state.json written + lock.json released in the cross-region bucket, and deletes the bucket at the end (EXIT trap covers failure paths) — previously the fixture was manual-only and its 2026-06-02 ledger PASS ran against the default same-region bucket, never exercising the scenario it is named after. Docs: state-management.md (State Bucket Region + Lock Mechanism), troubleshooting.md (lock-path 301 symptom + fix note).

Recently Implemented (2026-06-10):

  • AWS::EC2::Instance security-prop backfill: DisableApiTermination / MetadataOptions / Monitoring / EbsOptimized / CreditSpecification (issue #609)src/provisioning/providers/ec2-provider.ts. Five security-focused properties that were silent-dropped pre-PR are now wired through EC2Provider's create() + update() + readCurrentState() and added to handledProperties for AWS::EC2::Instance (the type stays open for the remaining ~26 props in tests/fixtures/cfn-schemas/_todo-backfill.json). All five are mutable in-place, so each has an update() path diffed against previousProperties (the cdkd drift --revert no-op round-trip stays free of mutating SDK calls):
    • DisableApiTermination — termination protection (pre-PR a silent-drop let a user believe the instance was protected when it was not). Rides on RunInstances at create; ModifyInstanceAttribute on update; readback via the existing DescribeInstanceAttribute(disableApiTermination) call. The destroy-side flip-off already lived in ec2-termination-protection.ts.
    • MetadataOptions — IMDSv2 enforcement (HttpTokens=required) mitigates SSRF credential theft. RunInstances at create; ModifyInstanceMetadataOptions on update; reverse-mapped from DescribeInstances .MetadataOptions on readback, excluding the AWS-managed State field to avoid false-positive drift.
    • Monitoring — detailed CloudWatch monitoring. RunInstances { Enabled } at create; MonitorInstances / UnmonitorInstances on update; readback already mapped .Monitoring.State to a boolean.
    • EbsOptimized — dedicated EBS throughput. RunInstances at create; ModifyInstanceAttribute on update; readback emit-when-present.
    • CreditSpecification — T-family burstable CPU credit mode. RunInstances at create; ModifyInstanceCreditSpecification on update; readback via DescribeInstanceCreditSpecifications (best-effort: non-burstable families error and fall back to omitting the key). Accepts the canonical CFn CPUCredits key and the SDK-style CpuCredits key.
    • CFn boolean-ish (true / "true") and numeric (HttpPutResponseHopLimit) values are coerced at the wire boundary. The ec2-instance integ fixture is rewritten to author the instance as a raw L1 ec2.CfnInstance: the L2 ec2.Instance construct always emits an AvailabilityZone property (a cdkd silent-drop) which under the #614 routing rule flips the whole resource onto the Cloud Control path, bypassing the SDK backfill this slice verifies. The new verify.sh asserts each prop reached AWS post-deploy and that provisionedBy stayed sdk, then exercises the destroy path with --remove-protection (the instance is termination-protected). Tests: 14 create/update unit tests + 5 readback unit tests.

Recently Implemented (2026-06-09):

  • ✅ Property-coverage backfill (issue #609): wired 6 top-level properties on AWS::EFS::FileSystem in one bundle — AvailabilityZoneName, LifecyclePolicies, BackupPolicy, FileSystemPolicy, BypassPolicyLockoutSafetyCheck, and FileSystemProtection — all previously silent-dropped by EFSProvider. One prop is deferred as unhandledByDesign: ReplicationConfiguration (cross-region EFS replication provisions a separate destination file system in another region with its own lifecycle / KMS key / AZ placement — a multi-resource, cross-region orchestration out of scope for the single-resource SDK provider; tracked as a #609 follow-up). With this slice, AWS::EFS::FileSystem's remaining silentDrop set is exactly { ReplicationConfiguration }.

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

    • #784 (cdk-local #483) — host.docker.internal reachability on invoke / run-task — REQUIRED cdkd code. A Lambda / ECS task container can now reach a server bound on the host loopback (an AWS_ENDPOINT_URL_* local endpoint, or a tunneled VPC resource) via host.docker.internal. Docker Desktop resolves it natively; Linux native dockerd needs the explicit --add-host host.docker.internal:host-gateway (Docker 20.10+), silently skipped on an older / unavailable daemon (never throws). cdkd adopts cdk-local's resolveHostGatewayExtraHosts() (re-exported via src/local/docker-version.ts alongside HOST_DOCKER_INTERNAL_GATEWAY) into cdkd local invoke (threaded into runDetached's extraHosts) and cdkd local run-task (set on RunEcsTaskOptions.hostGatewayExtraHosts, merged with the Cloud Map peer-discovery --add-host flags by the new pure mergeHostGatewayAddHostFlags helper in ecs-task-runner.ts). start-service / start-alb inherit the same reachability automatically from cdk-local's bundled ECS service emulator engine (cdkd's ecs-service-emulator.ts is a re-export shim — no local resolve site). Tests: a mergeHostGatewayAddHostFlags unit suite + a source-level binding test (tests/unit/cli/host-gateway-extra-hosts-binding.test.ts) pinning the resolve + thread at each cdkd-owned run site — the reachability only differs on Linux, so a Docker-Desktop integ cannot catch a dropped wiring (per memory feedback_site_level_binding_test.md). Docs: local-emulation.md "Reaching a server on the host" note.
    • #785 (cdk-local #485) — start-service/start-alb same-stack-ECR boot WARN fires once, not twice — AUTO-inherited. cdkd's start-service / start-alb consume cdk-local's ECS service emulator engine, so the dedup lands with the bump; no cdkd source change.
    • #786 (cdk-local #488) — start-service/start-alb listener WARN: WARN: doubled-prefix collapses to one — AUTO-inherited. Same engine-inheritance path as #785; no cdkd source change.
    • #787 (cdk-local #490) — studio pinUnresolved browser hint — N/A for cdkd. cdkd does not embed cdk-local's studio command, so this createLocalStudioCommand-only change has no cdkd surface.
    • The remaining cdk-local 0.143.x-0.147.0 commits (logger WARN:/ERROR: prefix [#478] surfaces only through inherited-command warn output; studio readability passes; test-infra reverts) carry no cdkd-owned behavior change. Verified end-to-end via /run-integ local-invoke + /run-integ local-run-task against real Docker (the host-gateway mapping is added on a host-gateway-capable daemon and the containers run cleanly).
  • cdkd local start-cloudfront WARNs when --cache-origin is set without --from-cfn-stack (issue #782) — bumps cdk-local ^0.140.0 -> ^0.142.0. cdkd local start-cloudfront is a THIN pass-through to cdk-local's createLocalStartCloudFrontCommand factory, so cdk-local's #476 is inherited with no cdkd source-logic change — only the dep bump + the local-emulation.md --cache-origin doc line were updated. Behavior delta (cat 4 in #782): start-cloudfront ... --cache-origin with no --from-cfn-stack was previously a fully silent no-op (--cache-origin only feeds the deployed-S3 read-through reader, which is built solely under --from-cfn-stack); it now logs one boot-time WARN (--cache-origin has no effect without --from-cfn-stack: ...). Non-fatal — no error, no exit-code change. cdkd does not post-process / match on start-cloudfront's stderr (the local-start-cloudfront integ verify.sh greps only the boot banner + specific GET response bodies/headers, and uses neither --cache-origin nor --from-cfn-stack), so the new WARN never fires in the fixture and no test change was needed. The cdk-local 0.141.0 studio change (#472, auto-render editable controls) is irrelevant to cdkd (cdkd does not embed cdk-local's studio command); the rest of the 0.141.0 / 0.142.0 commits are test / docs / chore.

  • cdkd local start-cloudfront --kvs-file accepts a construct path / bare construct id (issue #780) — bumps cdk-local ^0.139.0 -> ^0.140.0. cdkd local start-cloudfront is a THIN pass-through to cdk-local's createLocalStartCloudFrontCommand factory, so cdk-local's #467 is inherited with no cdkd source-logic change — only the dep bump + the local-emulation.md --kvs-file doc line were updated. Behavior delta (cat 4 in #780): the <key> left-hand side of --kvs-file <key>=<file.json> previously HAD to be the hash-suffixed AWS::CloudFront::KeyValueStore resource logical id, and an unrecognized key was silently ignored (the store stayed unbound and the cf.kvs() read failed at runtime). It now accepts the logical id, the construct path (MyStack/RoutesKvs), or the bare construct id (RoutesKvs) — normalized to the logical id before binding — and an unrecognized key (or an ambiguous bare id) now fails FAST with an error listing the distribution's KeyValueStore candidates. cdk-local also exports normalizeKvsFileKeys from cdk-local/internal (cat 3 in #780) for a host building its own --kvs-file flow; cdkd just wraps the command, so it does NOT consume it. The studio fixes in cdk-local 0.139.1 / 0.139.2 / 0.139.3 are irrelevant to cdkd (cdkd does not embed cdk-local's studio command). cdkd's unit test asserts only that --kvs-file is a registered option (no assertion on the old silent-ignore behavior), so no test change was needed; the local-start-cloudfront integ fixture does not exercise --kvs-file (no KeyValueStore in the distribution).

  • cdkd local start-agentcore follows cdk-local #454 (warm serve generalization) + #455 (CodeConfiguration build no-install) — issues #774 / #775 / #776 / #777 / #778 — bumps cdk-local ^0.128.0 -> ^0.139.0. cdkd local start-agentcore is a THIN pass-through to cdk-local's createLocalStartAgentCoreCommand factory and every src/local/agentcore-*.ts module is a re-export shim over cdk-local/internal, so the entire serve generalization is inherited with no cdkd source-logic change — only the command's doc comment + the user docs (local-emulation.md / README.md) and the local-start-agentcore integ verify.sh were updated. Behavior deltas inherited (verified end-to-end via /run-integ local-start-agentcore against real Docker):

    • #775 (slice 1, cdk-local#458) — warm HTTP serve. The container boots once and stays warm; HTTP / AGUI runtimes now serve POST /invocations + GET /ping (proxied to the warm container, session-id / boot-resolved Authorization injected, request/response incl. SSE streamed) alongside the /ws bridge, both on the same host port. A new HTTP contract served on http://... ready line is printed; the existing Server listening on ws://... line is kept verbatim. (Was: /ws-only.)
    • #776 (slice 2, cdk-local#459) — MCP + A2A warm serve. MCP runtimes serve POST /mcp (container port 8000), A2A serve POST / (port 9000), with no /ws bridge. (Was: rejected up front with LOCAL_START_AGENTCORE_PROTOCOL_UNSUPPORTED.) cdkd never special-cased the old rejection, so nothing to drop.
    • #777 (slice 4a, cdk-local#461) — per-request inbound JWT + --sigv4. A customJwtAuthorizer runtime now boots without a token and verifies each contract request's Authorization per request (401 missing / 403 invalid / forwarded on pass; GET /ping unauthenticated); --bearer-token is the default-when-missing fallback. --sigv4 (new flag, auto-inherited via addStartAgentCoreSpecificOptions) signs each forwarded request with AWS SigV4 (service bedrock-agentcore) when no customJwtAuthorizer is declared; mutually exclusive with --bearer-token. (Was: boot-time --bearer-token validation, rejected at boot if missing.)
    • #778 (slice 4b, cdk-local#462) — --watch. New flag (auto-inherited) re-synths + reloads the warm container in place on a CDK source change, keeping the host serve up (per-firing rebuild / soft-reload classifier, the same machinery as invoke-agentcore --ws --watch). (Was: ran until ^C with no reload.)
    • #774 (cat 4, cdk-local#455 / cdk-local#456) — CodeConfiguration builds no longer install deps. The fromCodeAsset / fromS3 source build (buildAgentCoreCodeImage, shared by both invoke-agentcore and start-agentcore) now runs the bundle as-is — no pip install / npm install — matching the AWS managed runtime, which resolves deps vendored into the bundle at deploy time. A bundle declaring a dependency manifest without vendored deps now fails locally with ModuleNotFoundError the same way it fails deployed (instead of passing locally only because of the local install), and cdkd emits a warning with the vendoring recipe. Container artifacts (fromContainerAsset / fromEcr) are unaffected.
    • Tests: tests/integration/local-start-agentcore/verify.sh extended to probe the new warm HTTP contract (the HTTP contract served on http://... ready line, GET /ping -> 200, POST /invocations echo round-trip with the bridge-injected session-id) and a second --sigv4 boot asserting the forwarded request carries an AWS4-HMAC-SHA256 Authorization header — on top of the existing header-less /ws bridge probe. No unit-test change (all behavior is upstream-owned + upstream-tested; cdkd's surface is the unchanged factory pass-through). tests/unit/cli/local-start-agentcore.test.ts continues to assert the cdkd --from-state / --state-bucket / --state-prefix flags + the inherited option block.

Recently Implemented (2026-06-05):

  • cdkd local invoke / cdkd local start-api pin a ZIP Lambda's --platform to its declared Architectures (issue #768) — follows cdk-local's #428 for cdkd's OWN local-execution paths. Before: cdkd's ZIP container run never set --platform (only the IMAGE path did — lambda-resolver.ts captured Architectures only on the IMAGE variant), so a ZIP container ran at the host's native arch; a provided.* custom-runtime bootstrap compiled for the other architecture failed with fork/exec /var/runtime/bootstrap: exec format error / Runtime.InvalidEntrypoint on an arch-mismatched host. After: ResolvedZipLambda / ResolvedStartApiZipLambda carry architecture (parsed by a shared extractArchitecture / extractStartApiArchitecture helper, default x86_64, arm64 honored, unsupported values rejected), and both the cdkd local invoke ZIP plan (resolveZipImagePlan) and the cdkd local start-api warm-container spec thread architectureToPlatform(architecture) to docker run --platform, so Docker emulates the function's declared arch. This was NOT auto-inherited from the cdk-local bump — cdkd does not embed cdk-local's invoke / start-api factories; those paths are cdkd-local code (lambda-resolver.ts / local-invoke.ts / local-start-api.ts). (cdkd local start-alb / start-cloudfront use cdk-local's engine / factory and already inherited #428 via the pinned cdk-local 0.126.6.) Tests: ZIP-arch capture (lambda-resolver.test.ts arm64 / default-x86_64 / reject; local-start-api-container.test.ts same for the start-api resolver) + the ZIP plan --platform threading (local-invoke-zip-platform.test.ts). Verified end-to-end via /run-integ local-invoke-provided.

  • cdkd local start-cloudfront --from-state — closes the start-cloudfront half of issue #766 — bumps cdk-local ^0.126.6 -> ^0.128.0 and threads cdkd's S3-backed --from-state factory into the start-cloudfront pass-through, mirroring start-agentcore / start-alb / start-service. cdk-local 0.128.0 (go-to-k/cdk-local#426 / #436) added the extraStateProviders seam to CreateLocalStartCloudFrontCommandOptions (the factory now passes it through to its two internal createLocalStateProvider calls — the KVS resolver + the S3-origin/Function-URL resolver), which start-cloudfront previously lacked (the reason it shipped --from-state-exempt in the start-agentcore PR #767). src/cli/commands/local-start-cloudfront.ts now passes { embedConfig, extraStateProviders: cdkdExtraStateProviders } to the factory and adds the cdkd-specific --from-state / --state-bucket / --state-prefix flags on top of cdk-local's inherited --from-cfn-stack / --stack-region / --assume-role. So a CloudFront distribution's Lambda Function URL origin (backing Lambda) and deployed-S3 origin (bucket name) can be bound to cdkd-managed state after a cdkd deploy, not only to a CloudFormation stack. The two state sources stay mutually exclusive (enforced by cdk-local's createLocalStateProvider). Tests: tests/unit/cli/local-start-cloudfront.test.ts flips its "exempt from #766" assertions to assert the three cdkd flags are present + defaulted (--from-state false, --state-prefix cdkd); the dispatcher-wiring comment now groups start-cloudfront with the other three factory-seam pass-throughs. Docs (local-emulation.md / README.md / .claude/rules/code-layout.md) drop the --from-state-exemption language. Verified via the local-start-cloudfront integ (the command boots + serves cleanly on cdk-local 0.128.0; the --from-state substitution path is the shared cdk-local mechanism already real-AWS-verified by local-start-alb-from-state). No cdkd source change beyond the wrapper + the dep bump.

  • Bump cdk-local ^0.126.0 -> ^0.126.6 so the factory pass-through local commands read aws-cdk-lib 2.258.0 (cloud-assembly schema v54) — aws-cdk-lib 2.258.0 (released 2026-06-04) bumped the cloud-assembly schema to v54. The cdk-local-factory-based local commands (start-agentcore / start-cloudfront / start-alb / start-service) synth through cdk-local's toolkit-lib-based Synthesizer, which (via @aws-cdk/toolkit-lib@1.26.2 -> cloud-assembly-schema@53.27.0, max v53) rejected v54 with AssemblyVersionMismatch: Maximum schema version supported is 53.x.x, but found 54.0.0. cdkd's own deploy / synth and the cdkd-implemented local commands (invoke / start-api / run-task / invoke-agentcore) were never affected — cdkd's core Synthesizer is self-implemented (reads manifest.json directly with no schema validation) and tolerates v54 (verified by synthesizing a 2.258.0 app through cdkd synth). The fix is upstream in cdk-local (go-to-k/cdk-local#430 / #431, released as cdk-local 0.126.6): bump @aws-cdk/toolkit-lib to ^1.28.0 (cloud-assembly-schema >=54.2.0) + align @aws-cdk/cloud-assembly-api to ^2.2.5. cdkd inherits it by bumping the cdk-local floor to ^0.126.6 (brings @aws-cdk/toolkit-lib@1.28.0 into cdkd's tree; cdkd's manifest.json-direct reader needs no cloud-assembly-api dedup of its own). The tests/integration/local-start-agentcore/ fixture's interim aws-cdk-lib pin (~2.257.0, added in the start-agentcore PR to dodge the v54 break) is relaxed back to ^2.257.0 so it floats to current aws-cdk-lib — verified end-to-end: the fixture now resolves aws-cdk-lib 2.258.0 and cdkd local start-agentcore serves /ws through the bridge cleanly (/run-integ local-start-agentcore, 0 container leaks). No cdkd source change — a dependency bump + fixture unpin.

  • cdkd local start-agentcore + --from-state for the factory pass-throughs (issues #765 / #766) — bumps cdk-local ^0.106.0 -> ^0.126.0 and adds the long-running serve counterpart of cdkd local invoke-agentcore. cdkd local start-agentcore [target] boots the Bedrock AgentCore Runtime container (same image / env / credential resolution as invoke-agentcore) and fronts its bidirectional /ws WebSocket endpoint with a host WebSocket bridge that injects the AgentCore session-id (and, under a customJwtAuthorizer, the Authorization header) on the container upgrade — so a header-less client (e.g. a browser WebSocket, which cannot set custom upgrade headers) can hold an interactive multi-frame session. HTTP / AGUI protocols only (MCP / A2A runtimes have no /ws). New src/cli/commands/local-start-agentcore.ts is a THIN pass-through to cdk-local's createLocalStartAgentCoreCommand factory (cdk-local#420, released in cdk-local 0.125.0); cdkd re-hands the active embed config (so branding stays cdkd) and — UNLIKE start-cloudfront — threads its S3-backed --from-state factory through the factory's extraStateProviders seam, layering the cdkd-specific --from-state / --state-bucket / --state-prefix flags on top of cdk-local's inherited --from-cfn-stack / --stack-region. Registered in createLocalCommand() between invoke-agentcore and start-alb. The agentcore-specific option block (--port / --host / --session-id / --bearer-token / --no-verify-auth / --env-vars / --platform / --no-pull / --no-build / --container-host / --timeout / --assume-role / --ecr-role-arn) auto-inherits from cdk-local's addStartAgentCoreSpecificOptions. The studio agentcore-ws serve kind (cdk-local 0.126.0 / cdk-local#422) spawns cdkl start-agentcore, but cdkd does NOT embed cdk-local's studio command, so that surface is not exposed by the cdkd CLI (no cdkd-side wiring needed). Tests: tests/unit/cli/local-start-agentcore.test.ts (subcommand name, optional single positional target, inherited agentcore + CFn state-source flags, the cdkd --from-state / --state-bucket / --state-prefix declarations + defaults, flag parsing). New integ fixture tests/integration/local-start-agentcore/ (adapted from cdk-local's): builds the EchoAgent container from a local Dockerfile, boots cdkd local start-agentcore --port 0, connects a header-less Node global-WebSocket probe (browser path), asserts the bridge injects a session-id + a second frame round-trips through the bridge (loop-echo:<text>), then SIGTERMs and asserts no cdkd-local-agentcore-* container leaks. Verified end-to-end via /run-integ local-start-agentcore.

  • ⚠️ cdkd local start-cloudfront gains Lambda Function URL + deployed-S3 origins (inherited from the cdk-local bump, cdk-local#380); --from-state stays exempt (#766) — the ^0.106.0 -> ^0.126.0 cdk-local bump changes the thin-pass-through start-cloudfront's surface: it now serves a distribution's Lambda Function URL origins (the backing Lambda runs locally via the RIE container, so Docker is required for that case) and its deployed-S3 origins, and so inherits cdk-local's --from-cfn-stack / --stack-region / --assume-role state-source flags + --kvs-file / --cache-origin / --no-pull. It is no longer "pure-local, no AWS call" — a CloudFront-Functions + S3-origin-only distribution still serves fully in-process (no Docker), but a Function-URL-fronted distribution does not. cdkd does NOT wire its S3-backed --from-state into start-cloudfront: cdk-local's CreateLocalStartCloudFrontCommandOptions accepts only embedConfig, not the extraStateProviders seam, so start-cloudfront stays exempt from #766 until cdk-local exposes that seam (decided with the user; the start-agentcore / start-alb / start-service pass-throughs DO thread --from-state). The command's doc comment + tests/unit/cli/local-start-cloudfront.test.ts were updated to the new contract (asserts the inherited CFn flags are present AND cdkd's --from-state / --state-bucket / --state-prefix are absent), and the local-emulation.md / README.md / cli-reference.md "no Docker / no state binding" claims were corrected. No other cdkd-owned surface broke on the 20-version bump (typecheck + full unit suite clean).

Recently Implemented (2026-06-02):

  • destroy --remove-protection clears EC2 DisableApiTermination on BOTH the SDK and Cloud Control delete paths, retrying through the flip-off propagation race — fixes a real destroy --remove-protection failure surfaced by the remove-protection integ. cdkd flips DisableApiTermination off (ModifyInstanceAttribute) and then deletes the instance, but AWS's modify WRITE lags the delete READ, so the delete 400s with The instance ... may not be terminated. Modify its 'disableApiTermination' instance attribute and try again. even though cdkd just cleared it (empirically: a manual modify-instance-attribute --no-disable-api-termination reports success and describe-instance-attribute reads true for ~25s, yet a terminate-instances immediately after succeeds — the attribute READ is eventually consistent). cdkd's fast SDK path outruns the propagation window, exactly like the IAM / Route53 races elsewhere. Crucially, an AWS::EC2::Instance is frequently routed through Cloud Control (its template trips the #614 silent-drop routing — confirmed via provisionedBy: cc-api in the integ's state), and CloudControlProvider.delete had NO DisableApiTermination handling at all — so the original SDK-only EC2Provider.deleteInstance flip-off never ran for the integ's instance. The fix adds a shared src/provisioning/ec2-termination-protection.ts helper (disableInstanceApiTermination + isTerminationProtectionPropagationError + TERMINATION_PROTECTION_MAX_ATTEMPTS) used by BOTH EC2Provider.deleteInstance (SDK path) and CloudControlProvider.delete (CC-API path): when context.removeProtection === true and the type is AWS::EC2::Instance, flip the attribute off, then retry the delete up to 5 times with increasing backoff (re-flipping each attempt) to close the propagation window. The 400 is deliberately NOT in the generic retryable set — a protected instance destroyed WITHOUT --remove-protection must fail fast so the user is told to pass the flag — so the retry is gated on removeProtection === true. Without this, the un-terminable instance blocked the entire VPC teardown (the IGW / VPCGatewayAttachment then hit their own 6m/30m delete timeouts). Tests: tests/unit/provisioning/ec2-provider-instance-protection-retry.test.ts (SDK path: retry-then-succeed re-flips each attempt; no---remove-protection fails fast with no flip-off; non-protection error fails fast; gives up after the 5-attempt budget) + tests/unit/provisioning/ec2-termination-protection.test.ts (the shared helper's flip-off send + error-classification). Verified end-to-end via the remove-protection integ (whose instance is CC-API-routed).

  • CLI exits cleanly when a downstream consumer closes stdout/stderr early (EPIPE) — piping any cdkd command into a reader that stops reading (cdkd state list | grep -q foo, ... | head, ... | less then q) closes the pipe while cdkd is still writing; Node then emitted an unhandled 'error' (EPIPE) on the stream and the process crashed with a stack trace + non-zero exit. That is normal Unix behavior for the consumer to stop reading, so the CLI must treat it as success. New installPipeCloseHandler() (src/cli/pipe-close-handler.ts, called once at the top of main() in src/cli/index.ts) attaches an 'error' listener to process.stdout / process.stderr that process.exit(0)s on EPIPE and re-throws every other (real) stream error unchanged. Surfaced by the remove-protection integ, whose cdkd state list | grep -q <stack> assertion crashed cdkd on EPIPE and the test misread the non-zero exit as a "state stripped despite destroy failing" failure (the state was actually preserved correctly — grep -q closing the pipe after its first match was the real cause). Tests: tests/unit/cli/pipe-close-handler.test.ts (EPIPE → exit 0; non-EPIPE → re-throw; handler installed on every supplied stream). Verified end-to-end via the remove-protection integ.

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

  • AWS::SSM::Parameter deploy no longer crashes on Tags (CFn SSM Tags is a key->value MAP, not a list) — fixes a hard deploy failure surfaced by the 2026-06-02 regression sweep (fixtures context-test AND infra-security, both never-run integs until this session). Any SSM Parameter with tags failed to create with Failed to create SSM parameter <id>: properties.Tags.map is not a function. Root cause: unlike almost every other CFn resource (whose Tags is a [{Key,Value}] list), AWS::SSM::Parameter.Tags is a key->value map ({ "Env": "prod" }) — CDK synthesizes the map form, and SSMParameterProvider.create() / update() did properties['Tags'].map(...), which throws because an object has no .map. The bug was never caught because the provider's unit tests + the only SSM-with-tags integ fixtures used the (wrong) list shape, and context-test / infra-security had never been run as integs. Fix: a cfnTagsToSdkTags() helper normalizes the CFn value into the SDK {Key,Value}[] shape, accepting BOTH the map (canonical) and the list (defensive), coercing non-string values to strings (SSM tag values must be strings), and dropping aws:-prefixed reserved keys; create() / update() route through it. readCurrentState() now ALSO emits Tags as the map shape (matching the template shape cdkd stores in state) instead of the {Key,Value}[] list — an array readback would false-positive cdkd drift on every clean run for a tagged parameter (state map vs observed list never compare equal). Tests: new tests/unit/provisioning/ssm-parameter-provider-tags-map.test.ts (create accepts the map shape + applies it as SDK Tag[]; defensive list-shape still works; aws:* keys dropped; empty map fires no AddTags; non-string values coerced; update diffs map shapes for add/remove; unchanged map is a no-op) + updated ssm-parameter-provider-readcurrentstate.test.ts assertions to the map shape. Real-AWS verified via the context-test integ (deploy+destroy clean). The list-shape create input the existing partial-create-cleanup / roundtrip tests use is still accepted, so the (incorrect-but-tolerated) list form does not regress.

  • Custom Resources retry on transient IAM-authorization failures (CR-internal retry + exec-env recycle) — fixes a hard deploy failure surfaced by the 2026-06-02 regression sweep (fixture custom-resource-provider). A CDK cr.Provider-framework custom resource failed to create with 403 lambda:GetFunction ... no identity-based policy allows even though the framework role's inline policy (which DOES grant it — present since aws-cdk v2.178.1 / aws-cdk#26838) was deployed byte-correct. Root cause is NOT a missing permission or a statement-drop: cdkd's fast SDK path attaches the role's inline policy and creates+invokes the backing Lambda ~0.7s later, so the function cold-starts before IAM propagates the policy to its assumed-role session and caches stale, policy-less credentials for the warm container's whole life — the framework's first invoke / waitUntilFunctionActive then 403s. CloudFormation never hits this because its deployment latency lets IAM settle (confirmed: the outbound.js framework runtime is byte-identical across 2.250.0 -> 2.257.0, so a version bump does NOT fix it; SimulatePrincipalPolicy is NOT a valid signal either — it reports allowed while the live assumed-role session still 403s, because IAM's policy-evaluation store and STS credential vending propagate independently). Fix: invokeCustomResourceWithRetry() in src/provisioning/providers/custom-resource-provider.ts re-invokes (default 2 retries; CDKD_CR_AUTHZ_MAX_RETRIES, 0 disables) when the FAILED reason matches a NARROW IAM-authz signal set (CR_TRANSIENT_AUTHZ_SIGNALS: not authorized to perform / no identity-based policy allows / not in the state functionActive / cannot be assumed / is unable to assume — generic timeouts and handler bugs are deliberately NOT retried, so genuine failures still surface fast). Each retry derives a fresh pre-signed URL/RequestId (preserving the disableOuterRetry invariant that guards against stranding a response at an unpolled S3 key) AND recycles the backing function's execution environment via a no-op UpdateFunctionConfiguration so the next cold start re-assumes the role with the now-propagated policy (a plain re-invoke would reuse the same stale warm container). This is the CR-path analogue of the IAM-propagation retry cdkd's withRetry already applies to every other resource — the CR path opts out of withRetry (disableOuterRetry) so it retries internally instead. Tests: tests/unit/provisioning/custom-resource-provider-authz-retry.test.ts (retry-then-succeed, no-retry-on-generic-FAILED, give-up-after-max, =0 disables, narrow classifier). Real-AWS verified via the custom-resource-provider integ: deploy 37s (attempt 1 -> 403 -> recycle -> attempt 2 created), destroy 17 deleted / 0 errors / 0 orphans (was a hard FAIL before).

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

  • deploy --all / destroy --all order stacks by cross-stack references (Fn::ImportValue / Fn::GetStackOutput), not just manifest addDependency — fixes a real failure surfaced by the 2026-06-02 regression sweep. Previously --all ordered stacks ONLY by the cloud-assembly manifest's declared dependencies (CDK addDependency). A stack linked to another ONLY via a RAW cdk.Fn.importValue('<name>') / Fn::GetStackOutput (no addDependency) created no manifest dependency, so under the default --stack-concurrency 4 the consumer deployed before the producer and failed: deploy --all errored Fn::ImportValue: export 'X' not found / Fn::GetStackOutput: stack 'Y' not found, and destroy --all destroyed the producer before the consumer (StackHasActiveImportsError -> partial destroy + orphan). New src/analyzer/cross-stack-deps.ts inferCrossStackStackDeps(stacks) derives consumer->producer edges from the synthesized templates (map exportName -> producerStack from every stack's Outputs[*].Export.Name; match literal Fn::ImportValue export names + read each Fn::GetStackOutput {StackName} target; edges only between stacks both in the set; non-literal / external / self refs ignored, so resolution of already-deployed external exports via the runtime index is unchanged). deploy.ts unions these with stack.dependencyNames at both --all sites (auto-include walk + inter-stack DAG edges, in-set guard preserved); destroy.ts reverse-sorts (consumer before producer, exported orderConsumersBeforeProducers, guarded to the synth path so the state-only fallback keeps original order). Manifest addDependency behavior is byte-unchanged when there are no raw cross-stack refs; runtime intrinsic resolution untouched. Unit tests: tests/unit/analyzer/cross-stack-deps.test.ts + tests/unit/cli/destroy-order-consumers.test.ts. Real-AWS verified: multi-stack-deps (Fn::ImportValue) + cross-stack-references (Fn::GetStackOutput) now deploy/destroy clean via --all (producer-first deploy, consumer-first destroy, 0 orphans) — both FAILED before. Known minor: a pathological mutual raw-import (A<->B, unbuildable on AWS) now surfaces as the WorkGraph generic "Deadlock detected" rather than a cross-stack-cycle-specific message.

  • Integ-run ledger (docs/_generated/integ-last-run.tsv) + /pick-integ skill — a committed (NOT gitignored), update-type ledger records, one row per integration test, when it last ran (last_run_iso), its result (PASS/FAIL), duration_s, flow (verify.sh / standard), and a short note. /run-integ now has a MANDATORY step 13 that writes the ledger on EVERY run (pass or fail) using a portable awk update (drop the test's old row + append the new one — NOT grep -P, which is unavailable on macOS BSD grep, a trap hit while building this). The ledger answers "has this integ run recently? / it hasn't run in months, it's risky to trust" without trawling CI history, and is the input to the new /pick-integ skill: it ranks tests by staleness (older than the 14-day integ-gate TTL window), last result (FAIL / never-run), and the code areas a recent diff touches (a path→test heuristic table — cross-cutting deploy/destroy → BROAD set, src/provisioning/providers/<Svc>* → that service's integ, src/local/**local-*, src/state/** → schema-migration + cross-stack, etc.), then prints a prioritized /run-integ plan (P0 changed+stale, P1 changed+green, P2 hygiene). No new markgate gate was added — the mandatory /run-integ step plus the committed file (a PR that ran integ but skipped the ledger is visible in review) plus /pick-integ treating absent/old rows as stale make enforcement unnecessary. The ledger is seeded with a 2026-06-02 broad regression sweep of 35 tests (29 PASS) — see the sweep findings below.

  • cdkd local invoke-agentcore --watch — re-synth + reload the agent container on CDK source edits, following cdk-local#270. cdk-local's runAgentCoreWatchLoop hard-couples to cdk-local's OWN Synthesizer / LocalInvokeAgentCoreOptions types (it lives inside cdk-local's own command), so it cannot be shimmed; instead cdkd owns a watch loop (src/local/invoke-agentcore-watch-loop.ts) built on cdk-local's already-exported watch primitives (createFileWatcher / createWatchPredicates / resolveWatchConfig / classifySourceChange + the ReloadVerdict / ReloadAssetContext types) — the SAME pattern cdkd local start-api --watch uses. A per-firing classifier picks the reload primitive: an interpreted-language source edit inside a CodeConfiguration source tree takes a soft-reload FAST PATH (docker cp the freshly-synthed source into the running container's WORKDIR + docker restart, no rebuild, container ID + host port preserved), while a Dockerfile / compiled-source / asset-hash-changed / ambiguous edit (or a fromS3 / non-CDK-asset runtime, or any classifier-context failure) forces a full rebuild (SIGTERM + docker rm -f + re-resolve the image + fresh docker run). --watch applies to BOTH the --ws session path (the active socket is closed cleanly on each reload via the abort signal, then re-opened against the new container) AND the default one-shot POST /invocations (the reload re-runs the single shot — cdkd extends the loop here; cdk-local treats single-shot HTTP as a no-op WARN). For MCP / A2A runtimes --watch is a no-op WARN and the single shot proceeds (those protocols run once and exit with no reconnect surface). Reloads are chain-serialized (no parallel reloads); a reload-callback failure exits the loop cleanly instead of blocking on a stale port. Plumbing: the cold-boot container sequence (image resolution → env build → runDetached → log stream) is hoisted into the exported bootAgentCoreContainer(...) so the rebuild callback re-runs it against a fresh synth; the existing one-shot --ws / /invocations behavior is byte-for-byte unchanged (the watch path is purely additive). loadAgentCoreAssetContext + deriveOldAssetHash are NOT exported from cdk-local/internal so they are copied into the cdkd module (verified against node_modules/cdk-local/dist/internal.d.ts). New --watch Option (default false) registered near --ws; watch?: boolean added to LocalInvokeAgentCoreOptions. Unit tests: tests/unit/local/invoke-agentcore-watch-loop.test.ts (14 cases — classifier soft-reload vs rebuild dispatch, classifier-failure rebuild fallback, reload-chain serialization, clean WS abort on reload, rebuild-failure loop exit, benign-close exit, the softReloadAgentContainer docker-cp + restart wiring, the isAgentCoreWatchEligible MCP/A2A no-op predicate, and flag registration). The local-invoke-agentcore integ verify.sh gains a --watch scenario (Test 21): open a long-lived --ws --watch session against the EchoAgent in loop mode, edit the agent source to inject a unique marker, and assert the watcher logs a reload verdict + the re-opened session surfaces the new marker.

  • ⚠️ BREAKING (cdkd local invoke-agentcore): the --ws-interactive flag is removed; --ws now auto-detects a TTY and enables the interactive REPL automatically, following cdk-local (cdk-local#274 / cdk-local#278). When stdin is a TTY, lines typed after the initial --event frame are sent as follow-up text frames (one per line, blank lines skipped), and each received frame is printed with a trailing newline + a '> ' prompt — a multi-turn REPL on the same /ws connection. When stdin is piped / redirected (CI), only the initial frame is sent and output stays wire-faithful (no extra newlines / prompts) — the one-shot behavior. Migration: users who passed --ws-interactive must drop it (REPL is now implicit in a TTY); to force one-shot inside a TTY, redirect stdin from /dev/null (cdkd local invoke-agentcore <t> --ws </dev/null). Plumbing: dropped the wsInteractive option field, the --ws-interactive Option registration, and the wsInteractive && !ws warn guard; the --ws branch computes const interactive = process.stdin.isTTY === true and threads it through frameSource creation + the new exported wrapWsOnMessage(sink, interactive) helper (+ WS_REPL_PROMPT = '> '); readStdinLines() now skips strictly-empty lines. --watch on /ws (cdk-local#270) was deferred from this PR (cdk-local does not export its runAgentCoreWatchLoop, which lives inside cdk-local's own command tightly coupled to its synth / image-build / container-lifecycle internals) — it shipped in the follow-up cdkd local invoke-agentcore --watch entry above (a cdkd-owned watch loop on top of cdk-local's exported watch primitives, not a shim of runAgentCoreWatchLoop). Unit tests: 7 new cases for wrapWsOnMessage (interactive newline+prompt / non-interactive identity / no double-newline) and readStdinLines (skips empty, keeps whitespace-only) in tests/unit/cli/local-invoke-agentcore-pure-helpers.test.ts. The local-invoke-agentcore integ verify.sh Test 18 was rewritten to drop --ws-interactive (piped stdin = non-TTY = one-shot: asserts the initial ack is present and the piped lines do NOT become follow-up frames). docs/local-emulation.md updated.

  • cdkd local start-api --assume-role-auto — ports cdk-local's start-api --assume-role-auto flag (cdk-local#271, issue #256 Option 1) into cdkd's OWN start-api command (cdkd does not use cdk-local's start-api command, so the flag is not auto-inherited by the bump). The bare boolean auto-resolves EACH routed Lambda's own execution role per-Lambda instead of a single global default: per-Lambda boot tries the synthesized template's literal-ARN Properties.Role first, then falls back to a deployed-state lookup (resolveExecutionRoleArnFromState, reused from local-invoke.ts), and warns-and-passes-through to the developer's shell credentials when neither recovers the ARN. Precedence: per-Lambda override (--assume-role <LogicalId>=<arn>) > (--assume-role-auto OR global default --assume-role <arn>) > unset. --assume-role-auto is mutually exclusive with the global-default --assume-role <arn> form (errors at boot via the new normalizeStartApiAssumeRole guard in src/cli/options.ts) but compatible with per-Lambda --assume-role <LogicalId>=<arn> overrides (the map wins for named Lambdas, auto-resolve handles the rest). Plumbing: AssumeRoleOption gains bareAutoResolve?: boolean; LocalStartApiOptions gains assumeRoleAuto?: boolean; new exported resolveStartApiAssumeRoleArn(...) replaces the bare effectiveAssumeRoleArn(...) call in buildContainerSpec. assumeLambdaExecutionRole is unchanged (region-only). New unit test tests/unit/cli/local-start-api-assume-role-auto.test.ts (15 cases) covers the normalization guard, the full resolver precedence, the literal-ARN + state-lookup + miss paths, and flag registration. Slower boot (one STS call per Lambda) but the right shape when each Lambda's deployed role differs.

  • cdk-local bumped 0.69.0 → 0.77.1 — cdkd follows the upstream local-emulation engine forward. The bulk of the delta is auto-inherited through the cdk-local/internal leaf-module shims and the ECS service-emulator option helpers (addStartServiceSpecificOptions / addAlbSpecificOptions) that cdkd local start-service / start-alb already call, so the new behavior lands with no cdkd .addOption(...) duplication. Newly inherited on cdkd local start-service / cdkd local start-alb: the --image-override family (--image-override <target>=<imageRef|dir|Dockerfile> plus per-service --image-build-arg / --image-build-secret / --image-target variants — pin or locally build a replica's image instead of the deployed registry tag; cdk-local#241 / #244), --shadow-ready-timeout <ms> (per-invocation override of the shadow-replica TCP-ready probe budget, default raised to 60s; cdk-local#266), live streaming of each replica's container stdout / stderr to the host terminal (cdk-local#231), and the no-rule-matched 404 now explaining which listener fields were evaluated on start-alb (cdk-local#229). Also inherited across the shimmed modules: an interactive spinner during long docker build / docker pull (cdk-local#269), the source-change classifier now defaulting TypeScript edits to a rebuild (precompiled setups were left stale by a soft-reload; cdk-local#236), --profile now fully threaded across every STSClient site (cdk-local#254), and auth / watcher / HTTPv2 / classifier rejection reasons now surfaced instead of swallowed into debug-log fallbacks (cdk-local#253). Required cdkd call-site adaptation (cdk-local #252/#253 type change): cdk-local switched its pass-through JWKS / discovery warn-dedup state from a Set<string> (warn once ever per URL) to a WarnedAt = Map<string, number> (warn once per time-window). src/cli/commands/local-start-api.ts renames its local jwksWarnedUrls = new Set<string>() to jwksWarnedAt = new Map<string, number>() (passed to startApiServer's renamed jwksWarnedAt option at both the HTTP-API and WebSocket server-construction sites), and src/cli/commands/local-invoke-agentcore.ts's verifyJwtViaDiscovery call passes { warnedAt: new Map<string, number>() } instead of { warned: new Set() }. sigV4WarnedForeignIds is unchanged (still a Set<string>). No user-facing behavior change from the rename — the dedup window is engine-managed; the type swap was required purely to typecheck against the new cdk-local/internal signatures. The agentcore --ws REPL UX polish (cdk-local#278) and the --ws auto-TTY-detection / --ws-interactive-drop (cdk-local#274) + start-api --assume-role-auto (cdk-local#271) land in cdkd through follow-up PRs (cdkd owns those command surfaces, so they are NOT auto-inherited by the bump). All 5405 unit tests pass against 0.77.1 with only the three call-site renames above.

Recently Implemented (2026-05-31):

  • cdkd local start-service --watch / cdkd local start-alb --watch — sub-second reload for interpreted-handler source edits (Phase 4 of cdk-local#214; cdk-local bumped 0.64.0 → 0.69.0). Each watcher firing now runs a per-target classifier: source-only edits on interpreted-language handlers (Node / Python / Ruby / shell) inside a CDK image asset take a bind-mount FAST PATH (docker cp the new source into each replica + docker restart, no docker build, no shadow boot, typical end-to-end latency well under a second; classifier logs verdict=soft-reload and the runner emits Soft-reloaded replica … restart + TCP-ready probe complete). Dockerfile / dependency manifest / compiled-language source / asset-hash-unchanged / ambiguous edits keep running the Phase 1-3 rebuild rolling primitive verbatim (shadow boot under a bumped generation suffix + TCP-ready probe + atomic Service-Connect / Cloud Map / front-door pool swap; classifier logs verdict=rebuild (…) and the runner emits Rolling replica … swap complete). Either path rolls one replica at a time, so the multi-replica zero-connection-refusal guarantee is preserved. cdkd local start-service previously did NOT expose --watch at all because cdkd was not calling cdk-local's addStartServiceSpecificOptions helper — this PR re-exports the helper from src/cli/commands/ecs-service-emulator.ts and wires it into createLocalStartServiceCommand, so --host-port (cdk-local 0.62+) AND --watch (cdk-local 0.69+) now land in cdkd local start-service --help and any future start-service-only flag the helper adds inherits automatically. cdkd local start-alb --watch (already wired via addAlbSpecificOptions) inherits Phase 4 wording with no code change. No state-source / behavior change for users who do not pass --watch — the classifier only fires on a watcher reload. New integ fixture tests/integration/local-start-service-watch-fast/ (modeled on cdk-local's tests/integration/local-start-service-watch-fast/): single-replica Node-22 ECS service with a webapp/server.cjs interpreted handler (the .cjs extension keeps the committed source out of tests/integration/.gitignore's *.js sweep); verify.sh boots cdkd local start-service --watch, rewrites server.cjs v1 → v2 and asserts verdict=soft-reload + Soft-reloaded replica … complete + the v1 → v2 transition on curl / (with zero rebuild verdicts post-edit), then rewrites the Dockerfile and asserts verdict=rebuild (Dockerfile edit …) + Rolling replica … (swap|single-replica reload) complete + the v2 → v3 transition + clean SIGTERM teardown. Unit test tests/unit/cli/local-start-service.test.ts extended with assertions that --host-port / --watch are declared and that --watch defaults to false. Verified end-to-end via /run-integ local-start-service-watch-fast (Docker integ — no AWS deploy). Closes #743.

  • ⚠️ BREAKING (cdkd local start-alb): HTTPS listener default flipped from auto-TLS-terminate to plain HTTP, matching cdk-local 0.64.0's cdkl start-alb (cdk-local#203). A cloud-HTTPS listener is now served over plain HTTP locally — X-Forwarded-Proto: https is preserved so the upstream app still sees the deployed listener protocol. Users who relied on the prior default to terminate TLS locally (auto-generating a self-signed cert) MUST add --tls to restore TLS termination. New --tls opt-in flag is auto-implied by --tls-cert / --tls-key. Refactor follow-up to PR #725 / PR #731: dropped cdkd's local definitions of parseLbPortOverrides / resolveAlbTarget / albStrategy / pickStack / notFound and the 5 .addOption(...) blocks for ALB-specific flags (--lb-port / --tls-cert / --tls-key / --no-verify-auth / --bearer-token) — these moved to cdk-local 0.64.0's bundled addAlbSpecificOptions + ALB strategy/helper exports (cdk-local#203). cdkd's src/cli/commands/local-start-alb.ts collapses from 421 LOC to ~110 LOC; src/cli/commands/ecs-service-emulator.ts re-exports the new ALB symbols from cdk-local/internal. LocalStartAlbOptions gains tls?: boolean. Net change: cdkd's start-alb automatically inherits any future ALB-only flag the upstream cdkl start-alb adds without manual .addOption(...) duplication. Unit test tests/unit/cli/local-start-alb.test.ts trimmed to cover only the cdkd-specific --from-state / --state-bucket / --state-prefix wiring + the cdkdExtraStateProviders singleton-identity check (the parseLbPortOverrides / resolveAlbTarget / albStrategy.resolveBoots blocks moved to cdk-local's own test). Docs local-emulation.md updated with the new --tls row + the listener-protocols section's default flip. Verified end-to-end via /run-integ local-start-alb-from-state (Docker integ — real AWS deploy + cdkd local start-alb --from-state boot + plain-HTTP front-door curl + --from-state substitution + clean SIGTERM teardown).

  • ⚠️ BREAKING (cdkd local start-api): SigV4 default flipped from fail-closed to warn-and-pass, matching cdk-local's cdkl start-api. The CLI flag is renamed: --allow-unverified-sigv4 (opt OUT of fail-closed) is removed and replaced by --strict-sigv4 (opt IN to fail-closed). Users who relied on the prior default to deny unverifiable AWS_IAM SigV4 requests against REST v1 AuthorizationType: 'AWS_IAM' / Function URL AuthType: 'AWS_IAM' MUST add --strict-sigv4 to their cdkd local start-api invocation. The previous cdkd-divergent default (security review #484) drove embedConfig branching + per-flag plumbing that compounded drift across every shim slice; following cdk-local removes that maintenance cost. Plumbing changes: src/cli/commands/local-invoke.ts's CDKD_EMBED_CONFIG flips sigV4StrictByDefault: true → false and sigV4OptFlag: '--allow-unverified-sigv4' → '--strict-sigv4'; LocalStartApiOptions.allowUnverifiedSigv4?: boolean renames to strictSigv4?: boolean; the two sigV4Strict: options.allowUnverifiedSigv4 !== true translation sites in local-start-api.ts flip to sigV4Strict: options.strictSigv4 === true; the .addOption(new Option('--allow-unverified-sigv4', ...)) block becomes --strict-sigv4 with the inverted help text; shim header comments in src/local/http-server.ts / src/local/sigv4-verify.ts updated; tests/unit/cli/local-embed-config.test.ts assertion updated to the new values. The memory rule feedback_shim_blocked_by_unadopted_semantic_divergence.md records the case-A → case-B retrofit pattern: even a deliberate documented divergence is worth re-examining when its maintenance cost compounds. Verified end-to-end via /run-integ local-start-api (Docker integ).

  • ✅ Property-coverage backfill (issue #609): wired 7 props on AWS::Lambda::EventSourceMapping in one bundle — KmsKeyArn, LoggingConfig, MetricsConfig, ProvisionedPollerConfig, Queues, Topics, StartingPositionTimestamp — all previously silent-dropped by LambdaEventSourceMappingProvider. Per the AWS SDK shape audit (@aws-sdk/client-lambda 3.x CreateEventSourceMappingRequest vs UpdateEventSourceMappingRequest), 4 of the 7 ride BOTH create + update (KmsKeyArn / LoggingConfig / MetricsConfig / ProvisionedPollerConfig) and 3 are create-only (Queues / Topics / StartingPositionTimestamp are absent from UpdateEventSourceMappingRequest — AWS rejects mutation, CFn replaces the resource on a template change, which cdkd's diff layer schedules independently). Wire-format casing flip: CFn schema spells the encryption key as KmsKeyArn (lower-case ms), but the SDK field is KMSKeyArn (upper-case MS); both create() and update() do the flip and readCurrentState() flips back so cdkd state stores the CFn-shaped key. StartingPositionTimestamp coercion: CFn supplies a Number (epoch seconds per the AWS::Lambda::EventSourceMapping schema), the SDK wants a Date; create() coerces (number/ISO-string/Date all accepted), and readCurrentState converts back to epoch seconds so the drift comparator sees the same shape on both sides (a missed conversion would surface phantom drift on every clean run). Update gated on prev !== next: the 4 mutable props use !== undefined (not truthy) so explicit '' reaches AWS as the documented KMSKeyArn clear-back-to-AWS-owned-key sentinel rather than being silently dropped. readCurrentState is emit-when-present for all 7 — AWS returns these only when the user set them, so a phantom KmsKeyArn: '' / LoggingConfig: { ...defaults } placeholder would force guaranteed drift on every clean run for the typical un-configured ESM. With this slice the AWS::Lambda::EventSourceMapping type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json). 18 new unit tests across 3 files: 8 in lambda-eventsource-provider.test.ts (create command branches — KmsKeyArn casing flip, LoggingConfig/MetricsConfig/ProvisionedPollerConfig forwarding, Queues, Topics, StartingPositionTimestamp number / ISO-string / Date coercion, all-7-omit-when-absent), 4 in lambda-eventsource-provider-roundtrip.test.ts (KmsKeyArn casing flip mirrors create, empty-string KmsKeyArn clear-sentinel, LoggingConfig/MetricsConfig/ProvisionedPollerConfig update forwarding, 3-create-only-silent-omission from UpdateInput), 6 in lambda-eventsource-provider-readcurrentstate.test.ts (KMSKeyArn casing flip-back emit-when-present, KmsKeyArn omit-when-absent guard against false-positive drift, LoggingConfig/MetricsConfig/ProvisionedPollerConfig together, Queues/Topics array-clone, StartingPositionTimestamp Date→epoch-seconds conversion, all-7-omit-when-absent). Real-AWS verified via the existing tests/integration/dynamodb-streams/ fixture — the DynamoEventSource L2 gains a small FilterCriteria (so AWS actually persists KmsKeyArn — without filter criteria the key is a no-op and AWS silently doesn't surface it on get-event-source-mapping), a new kms.Key for the filter-criteria encryption with a Lambda-service grantEncryptDecrypt (so AWS authorizes the encryption op), and addPropertyOverride for KmsKeyArn + MetricsConfig on the synthesized L1 (the L2 doesn't surface these top-level props). The verify.sh extension asserts via aws lambda get-event-source-mapping that both props reached AWS. The other 5 props (LoggingConfig / ProvisionedPollerConfig / Queues / Topics / StartingPositionTimestamp) are source-kind-discriminated (Kafka / SQS / Kinesis-AT_TIMESTAMP-only) and don't apply to DynamoDB Streams; they are unit-test-covered.

  • ✅ Broader real-AWS integ fixture for cdkd local start-alb --from-state + .claude/rules/code-layout.md restructure (follow-up to PR #731 Part B). New fixture tests/integration/local-start-alb-from-state/: one stack with VPC (2 AZs, public-only, no NAT) + ALB + 2 ApplicationListenerRules (default + path /orders/*) + 2 TargetGroups + 2 ECS Fargate services (Web + Orders, desiredCount: 0 to avoid container compute cost) + IAM execution role + LogGroup + 2 SecurityGroups. Each service's TaskDefinition carries an ALB_DNS_NAME env var with Fn::GetAtt: [Alb, DNSName] so the engine's state-source dispatcher MUST substitute the resolved DNS name from cdkd's S3 state when cdkd local start-alb --from-state boots the containers locally. verify.sh does pre-flight Docker orphan sweep, deploys the stack via cdkd, validates ALB via aws elbv2 describe-load-balancers, boots cdkd local start-alb '<stack>/Alb' --from-state --lb-port 80=8080 in background, asserts the boot banner + the ALB front-door: ...:8080 listener banner (proves --lb-port override), curls http://127.0.0.1:8080/ and asserts the response body contains service=web alb=<deployed-alb-dns> (proves default-action routing + --from-state substitution reached the Web container), curls http://127.0.0.1:8080/orders/ and asserts service=orders alb=<deployed-alb-dns> (proves ListenerRule path routing + multi-target boot ordering + --from-state substitution reached the Orders container), SIGTERMs cdkd, asserts zero leftover cdkd-local-* containers + networks, runs cdkd destroy, and verifies the cdkd S3 state for the stack is empty. Closes the gap memory rule feedback_never_defer_integ_from_originating_pr.md records: the engine's host-side wiring (serviceStrategy factory + cdkdExtraStateProviders map + LocalStartAlbOptions index-signature extension) is uniquely exercised end-to-end here; the pure-local sibling tests/integration/local-start-alb/ fixture cannot test substitution because there is no deployed state to read, and the upstream cdk-local engine's integ tests its own surface, not cdkd's shim. The fixture also drove discovery + fix of a verify.sh set -o pipefail bug (aws s3 ls returns exit 1 when the prefix has zero objects, which would have terminated the post-destroy state-verification step before printing the success banner). .claude/rules/code-layout.md's giant src/local/** bullet had its Service Connect / Cloud Map / ecs-service-runner.ts / ecs-service-resolver.ts / cloud-map-registry.ts / cloud-map-resolver.ts / createSharedSvcNetwork + SHARED_SVC_SUBNET_OCTET paragraph (originally added by issues #466 / #460 to describe the pre-refactor topology) replaced with a single sentence pointing forward to the PR #731 Part B changelog entry — the modules described there were deleted in #731, so the prose was stale at the head + the trailing "Part B annotation" sentence the PR #731 review flagged as suboptimal placement is no longer needed.

  • ✅ Property-coverage backfill (issue #609): wired Tags on AWS::CloudFront::Distribution, which CloudFrontDistributionProvider previously silent-dropped on write. Tags is a standard CFn [{ Key, Value }] array; CloudFront's SDK gates tag-on-create behind a separate command class — CreateDistributionWithTagsCommand({ DistributionConfigWithTags: { DistributionConfig, Tags: { Items: Tag[] } } }) — so the provider's create() switches command class based on whether properties['Tags'] is non-empty (an empty Tags: [] from CFn is treated as "no tags" and routes through the plain CreateDistributionCommand to avoid hitting the tags-enabled control plane for nothing). update() gains a tag diff after the existing UpdateDistributionCommand: removals → UntagResourceCommand({ Resource: <ARN>, TagKeys: { Items: [...] } }), additions + value rewrites → TagResourceCommand({ Resource: <ARN>, Tags: { Items: [...] } }) (TagResource overwrites a key's value on re-tag, so a same-key value rewrite is in the upsert set alone). The removal pass runs BEFORE the upsert pass so a renamed key (value-only edit on key K) is not accidentally cleared by a stale UntagResource. readCurrentState is intentionally NOT added in this PR — CloudFront has no readCurrentState today (drift falls back to the CC-API path), and a partial implementation that reads only Tags while ignoring DistributionConfig would surface less drift than CC-API would; full readback is deferred to a separate PR. With this slice, the AWS::CloudFront::Distribution type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.jsonTags was the only outstanding entry). Tags moves from silentDrop to handled in property-coverage.generated.ts (regenerated via vp run gen:property-coverage — the raw codegen formatting artifact is normalized by vp check --fix). 8 new unit tests in cloudfront-distribution-provider.test.ts cover the create command-class switch (with Tags → CreateDistributionWithTagsCommand, without and with Tags: [] → plain CreateDistributionCommand), the update tag-diff (add-only → TagResource, removal-only → UntagResource, value-rewrite on same key → TagResource only, unchanged → neither, mixed adds + removes → Untag then Tag in that order). Real-AWS verified via the existing tests/integration/s3-cloudfront/ fixture — the L2 cloudfront.Distribution gains two cdk.Tags.of(distribution).add(...) calls; a NEW verify.sh deploys, resolves the distribution ARN via aws cloudfront get-distribution, asserts both tags via aws cloudfront list-tags-for-resource, then destroys clean.

  • ✅ Property-coverage backfill (issue #609): wired ReservedConcurrentExecutions on AWS::Lambda::Function, which LambdaFunctionProvider previously silent-dropped on write. Real safety concern before this PR: a CDK template setting reservedConcurrentExecutions: 100 to cap a function's concurrency would silently drop the cap on deploy via cdkd — production stacks could exceed their intended concurrency limits. Post-create control-plane API pattern (matches PR #719 RecursiveLoop): CreateFunction does NOT accept the field; it requires a separate PutFunctionConcurrencyCommand({ FunctionName, ReservedConcurrentExecutions: number }) call after function creation. create() issues the post-create call when the value is set (!== undefined, NOT a truthy gate — 0 is a meaningful value that throttles the function to zero concurrency); on failure issues DeleteFunctionCommand as delete-on-post-create-failure atomicity rollback (mirrors RecursiveLoop's pattern exactly). update() gates on prev !== next strict-compare; removal (prev: number, next: undefined) maps to DeleteFunctionConcurrencyCommand — unlike RecursiveLoop which has no clear API and just leaves the last-set value pinned, AWS provides a dedicated DeleteFunctionConcurrency so a user dropping the template prop actually un-throttles the function instead of silently leaving the limit in place. readCurrentState adds a separate GetFunctionConcurrencyCommand call after the primary GetFunction, emit-when-present (the AWS response carries ReservedConcurrentExecutions only when the limit is set, so a typical un-throttled function correctly maps to omit-from-readback — no phantom drift). 9 new unit tests in lambda-function-provider.test.ts cover create-send (with 50, with explicit 0, absent), atomicity rollback via DeleteFunction, update set / update clear via DeleteFunctionConcurrency / update no-diff, readback emit / omit. Real-AWS verified via /run-integ lambda (broad-set): the fixture's lambda.Function gains reservedConcurrentExecutions: 5; verify.sh extends the existing RecursiveLoop assertion with aws lambda get-function-concurrency --query ReservedConcurrentExecutions returning 5. 9 resources deployed clean, all 3 assertions pass (provisionedBy='sdk' + RecursiveLoop='Allow' + ReservedConcurrentExecutions=5), 9 destroyed with 0 errors / 0 orphans.

  • cdkd local start-service <targets...> refactor onto the shared ECS service emulator engine (follow-up to PR #725's start-alb shim work, completes the symmetry the ALB PR explicitly deferred). The old 944-line src/cli/commands/local-start-service.ts — owning the per-replica boot loop + shared docker network + Cloud Map registry + per-target createLocalStateProvider + manual env-substitution + SIGINT single-flight cleanup — collapses to a ~120-line shim mirroring local-start-alb.ts: a LocalStartServiceOptions interface extending the engine's EcsServiceEmulatorOptions with cdkd's --from-state / --state-bucket / --state-prefix, a small serviceStrategy(options): EmulatorStrategy (picker via listTargets(stacks).ecsServices, picker text "Select one or more ECS services to run", trivial resolveBoots mapping each chosen target to { target } since the engine's bootOneTarget calls resolveEcsServiceTarget internally, lbPortOverrides: {} since services have no listener ports), and a createLocalStartServiceCommand() that wires the shared runEcsServiceEmulator(targets, options, serviceStrategy(options), cdkdExtraStateProviders) engine entry. The shared engine + Cloud Map + sidecar machinery has lived in cdk-local since 0.62.0 (PR #725's pre-work) — Part B just adopts it for the second consumer. With this refactor, every per-replica boot orchestration / shared-network / sidecar-credentials / Cloud Map registry / state-provider-per-target / cross-stack-resolver / assume-task-role / profile-credentials-file / SIGINT-cleanup mechanic is owned by cdk-local for BOTH start-service AND start-alb — adding a feature now means changing one upstream module instead of two byte-identical command files. Now-dead code DELETED from cdkd's tree: src/local/ecs-service-runner.ts (959 lines — the entire per-replica orchestrator + Cloud Map publish + subnet allocator), src/local/ecs-service-resolver.ts (596 lines — service-discovery resolver, now in cdk-local's bundled engine), src/local/cloud-map-registry.ts (11-line shim no longer imported by anyone), src/local/cloud-map-resolver.ts (13-line shim no longer imported by anyone), tests/unit/local/ecs-service-runner.test.ts (1934 lines — every test is now exercised by cdk-local's own bundled test), tests/unit/local/ecs-service-resolver.test.ts (379 lines — same), and tests/unit/cli/local-start-service-profile-creds.test.ts (resolveSharedSidecarCredentials is now sourced from cdk-local via the ecs-service-emulator.ts shim — testing it from cdkd was dead-coverage). src/local/ecs-network.ts keeps its createTaskNetwork / destroyTaskNetwork / buildMetadataEnv / buildEndpointSubnet exports (used by the still-local ecs-task-runner.ts for cdkd local run-task) but drops createSharedSvcNetwork + SHARED_SVC_SUBNET_OCTET (the start-service-specific shared-network factory) since the engine creates its own shared network from cdk-local's bundled equivalent. Net diff: -3500 LOC in cdkd's tree with zero behavior change for the user-facing cdkd local start-service command — every flag (--cluster / --env-vars / --container-host / --assume-task-role / --no-pull / --ecr-role-arn / --platform / --max-tasks / --restart-policy / --from-state / --from-cfn-stack / --state-bucket / --state-prefix / --stack-region) and every behavior (replica boot / Cloud Map peer discovery / Service Connect aliasing / shared sidecar /role/<arn> credentials / profile-credentials-file bind-mount / cross-stack Fn::ImportValue substitution / ^C teardown) renders identically post-refactor. The tests/unit/cli/local-commands-dispatcher-wiring.test.ts (issue #611 dispatcher-wiring scan) now tracks only the three direct-dispatch commands (local-invoke / local-start-api / local-run-task); local-start-service joins local-start-alb in the engine-wired category where the dispatcher invocation lives inside cdk-local's runEcsServiceEmulator and reaches cdkd's S3-backed --from-state factory transparently via the shared cdkdExtraStateProviders map. The pre-PR MAX_TASKS_SUBNET_RANGE_CAP export from local-start-service.ts is dropped (the engine's bundled parseMaxTasks enforces the same cap with the same error message). Real-AWS verified via the existing tests/integration/local-start-service/ fixture (single-service replica boot + Cloud Map peer registration + ^C cleanup, against real Docker; AWS deploy is N/A for this pure-local fixture). A broader multi-service + ALB integ exercising --from-state substitution against deployed cdkd state is deferred to a follow-up PR — Part B's risk surface is concentrated in the small serviceStrategy() factory + the dispatcher-wiring test update (both unit-tested), and the shared engine itself was already verified end-to-end against real AWS by PR #725 Part A.

  • ✅ Property-coverage backfill (issue #609): wired Tags on AWS::S3Vectors::VectorBucket, which S3VectorsProvider previously silent-dropped on write. Tags is a standard CFn [{ Key, Value }] array. The AWS SDK CreateVectorBucketInput.tags accepts a flat Record<string, string> shape; createVectorBucket() converts the CFn array → SDK map and passes it on CreateVectorBucketCommand (omit-when-absent — an empty Tags: [] array sends no tags field so no spurious CloudTrail event fires). VectorBucket has NO UpdateVectorBucket API (the provider's update() is already a no-op), so update-side wiring is intentionally not added — a tag change requires a destroy+recreate via cdkd's existing replacement path. readCurrentState adds a second AWS call (ListTagsForResource(resourceArn=vectorBucketArn)) after the primary GetVectorBucket, converts the SDK Record<string, string> back to CFn [{ Key, Value }] shape, and emits Tags: [] when AWS returns no tags or when ListTagsForResource itself fails (best-effort; the drift comparator stays happy). With this slice, the AWS::S3Vectors::VectorBucket type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json). Tags moves from silentDrop to handled in property-coverage.generated.ts (962 handled, 409 silent-drop). New unit tests in tests/unit/provisioning/providers/s3-vectors-provider.test.ts (Tags forwarded as the SDK Record<string,string> shape; absent and empty-array variants both omit tags from the SDK input) and tests/unit/provisioning/s3-vectors-provider-readcurrentstate.test.ts (readback surfaces Tags via the new ListTagsForResource hop, reshapes SDK map to CFn [{Key, Value}], falls back to Tags: [] when ListTagsForResource fails). The two pre-existing roundtrip cases (Class 1 — readCurrentState does not emit KMSKeyArn on an AES256 bucket + readCurrentState emits both SSEType and KMSKeyArn on aws:kms) were updated to mock the new ListTagsForResource call alongside their existing GetVectorBucket mock and to expect Tags: [] in the result. Real-AWS verified by extending the existing tests/integration/s3-vectors/ fixture — the CfnVectorBucket L1 gains tags: [{ key: 'env', value: 'cdkd-integ' }, { key: 'team', value: 'platform' }], and a NEW verify.sh deploys, resolves the bucket ARN via aws s3vectors get-vector-bucket --query vectorBucket.vectorBucketArn, asserts both tags via aws s3vectors list-tags-for-resource, then destroys clean (1 deployed, 1 destroyed, 0 errors, 0 orphans).

  • ✅ Property-coverage bookkeeping fix (issue #609): retired the stale AWS::ECS::TaskDefinition:InferenceAccelerators entry from tests/fixtures/cfn-schemas/_todo-backfill.json. The property was ALREADY declared in ECSProvider.unhandledByDesign (with rationale "AWS Elastic Inference end-of-life 2024-04; use AWS Inferentia / Trainium accelerator instance families instead") but the backfill todo file still listed it, so the property-coverage strict-mode test was the only thing keeping the entry technically "tracked". Pure bookkeeping cleanup — zero src wire changes, no integ needed. property-coverage.generated.ts regenerated to reflect the move from silentDrop to the implicit-handled set (961 handled, 410 silent-drop, down from 411).

  • ✅ New cdkd local start-alb <targets...> command (issue #86): run an Application Load Balancer locally — name one or more AWS::ElasticLoadBalancingV2::LoadBalancer resources, discover the ECS / Lambda targets behind each listener's forward action, boot every backing ECS service via the shared engine local start-service uses, and stand up a per-listener node:http(s) front-door that round-robins inbound requests across the running replicas and applies the listener rules (path / host / header / method / query-string / source-IP). The symmetric counterpart of local start-api for ALB-fronted workloads. Models cdk-local's cdkl start-alb, ported into cdkd's command tree as a 2-shim + 1-command trio: src/local/elb-front-door-resolver.ts (re-exports resolveAlbFrontDoor + isApplicationLoadBalancer + the front-door type set from cdk-local), src/cli/commands/ecs-service-emulator.ts (re-exports the shared runEcsServiceEmulator engine + addCommonEcsServiceOptions + the EcsServiceEmulatorOptions / EmulatorStrategy / Planned* types from cdk-local/internal), and the 421-line src/cli/commands/local-start-alb.ts command file (createLocalStartAlbCommand + exported parseLbPortOverrides / resolveAlbTarget / albStrategy helpers). The cdk-local-side engine + resolver were released in coordinated upstream PR cdk-local#190 as cdk-local 0.62.0 so cdkd's shim consumer pattern works without inlining the ~1000-line front-door + per-replica boot orchestrator. Listener / action support: HTTP and HTTPS listeners (TLS terminated locally via --tls-cert / --tls-key, auto-generated self-signed cert as fallback cached under $XDG_CACHE_HOME/cdk-local/alb-https/); forward (single target group AND weighted forward across multiple target groups), redirect (301 / 302 with protocol / host / port / path / query overrides), fixed-response (configurable status code / content-type / body); all six rule condition fields (path-pattern, host-header, http-header, http-request-method, query-string, source-ip); ECS targets (via AWS::ECS::Service.LoadBalancers[] binding the TG to a container + port) AND Lambda targets (via TG.Targets[].Id = {Fn::GetAtt: [<FnLogicalId>, "Arn"]}); authenticate-cognito + authenticate-oidc actions enforce a local Bearer-JWT check (or AWSELBAuthSessionCookie pass-through) against the same JWKS / OIDC discovery URL the deployed ALB would (signature + iss + aud + exp). Per-listener host-port remap via --lb-port <listenerPort>=<hostPort> (repeatable) for macOS where privileged listener port < 1024 cannot bind without root (default: host port == listener port). State-source flags (--from-state / --from-cfn-stack / --state-bucket / --state-prefix / --stack-region mirroring local start-service) ride through to the backing services via the shared engine — the engine internally calls createLocalStateProvider(options, ..., extraStateProviders) per backing-service boot, and cdkd's S3-backed --from-state factory is wired via the new export cdkdExtraStateProviders ({ fromState: fromStateFactory }) in src/cli/commands/local-state-source.ts. The new LocalStartAlbOptions interface extends EcsServiceEmulatorOptions with cdkd-specific fromState / stateBucket / statePrefix fields (carried through cdk-local's [key: string]: unknown index signature). Auth-guard opt-outs: --no-verify-auth disables the JWT check entirely; --bearer-token <jwt> injects a default Authorization header when the inbound request has none. New unit tests in tests/unit/cli/local-start-alb.test.ts (30 cases: parseLbPortOverrides valid / invalid / range / multi-entry semantics, resolveAlbTarget stack-prefix / multi-stack / non-ALB / missing-resource error paths, and the option-builder smoke test asserting the cdkd-specific --from-state / --state-bucket / --state-prefix flags are wired alongside the engine-inherited --from-cfn-stack / --stack-region / --lb-port / --max-tasks / --restart-policy / etc.). Real-AWS verified via NEW tests/integration/local-start-alb/ pure-local fixture (no AWS deploy): VPC-free Cfn* topology with one ALB + one HTTP:80 listener + one TargetGroup + one EC2-launchType ECS Service running busybox httpd on container port 80; verify.sh boots cdkd local start-alb with --lb-port 80=8080, asserts the boot banner + the front-door listening banner, hits http://127.0.0.1:8080/ and asserts the busybox container's fixed banner ("OK from cdkd-local-start-alb-fixture") routes correctly, then SIGTERMs and asserts clean teardown (zero leftover cdkd-local-* containers / networks). The local-start-service refactor to also delegate to the shared engine (instead of its current per-replica boot loop) is deferred to a follow-up PR per scope.

  • ✅ Property-coverage backfill (issue #609): wired HostedZoneFeatures on AWS::Route53::HostedZone, which Route53Provider previously silent-dropped on write. HostedZoneFeatures is { AcceleratedRecoveryStatus: 'ENABLED' | 'DISABLED' } — the AcceleratedRecovery feature targets a 60-minute Recovery Time Objective (RTO) for DNS operations during us-east-1 service disruptions (per the AWS launch blog); the feature itself is free (no premium-tier billing — verified against Route 53 pricing and the launch blog's "There is no additional cost for using accelerated recovery" statement). Unlike the direct-on-create backfills this session, this rides on a separate post-create control-plane APICreateHostedZone does NOT accept the feature; it requires a follow-up UpdateHostedZoneFeaturesCommand({ HostedZoneId, EnableAcceleratedRecovery: boolean }). The backfill follows the post-create control-plane pattern established in PR #719 (Lambda::Function:RecursiveLoop): create() issues UpdateHostedZoneFeatures AFTER CreateHostedZone succeeds when the template requested 'ENABLED' (calling with false is skipped — AWS default is DISABLED, so the explicit-toggle hop is unnecessary); on failure issues DeleteHostedZone as delete-on-post-create-failure atomicity rollback before throwing (the next deploy retry sees no orphan zone). update() is extended with the missing previousProperties parameter and gates UpdateHostedZoneFeatures on prev !== next — a removal (prev: ENABLED, next: undefined) is treated as DISABLED (the AWS default state, matching CFn's omit-default convention). delete() gains a pre-delete guard — AWS rejects DeleteHostedZone while AcceleratedRecovery is anything other than DISABLED (Cannot delete a hosted zone with accelerated recovery enabled. Please disable first.), so deleteHostedZone probes the current status, issues UpdateHostedZoneFeatures(false) if needed, and polls until the AWS-side state settles to DISABLED (default 10-min timeout / 15s interval; env-overridable via CDKD_R53_ACCEL_RECOVERY_POLL_TIMEOUT_MS / CDKD_R53_ACCEL_RECOVERY_POLL_INTERVAL_MS). Without this guard, ANY zone deployed with HostedZoneFeatures.AcceleratedRecoveryStatus: 'ENABLED' would be physically un-destroyable via cdkd (the create path opts in, the destroy path's DeleteHostedZone is then rejected indefinitely until manual aws route53 update-hosted-zone-features --no-enable-accelerated-recovery recovery). Genuinely failed statuses (ENABLE_FAILED / DISABLE_FAILED) hard-fail the delete with an actionable error pointing the operator at the manual recovery command; the *_HOSTED_ZONE_LOCKED transients are waited through (see the 2026-06-02 fix below). readHostedZone surfaces it back from GetHostedZone.HostedZone.Features.AcceleratedRecoveryStatus emit-when-present (gated on !== undefined, NOT a default-when-absent placeholder — zones older than the 2025 feature launch return undefined indefinitely, so a phantom { AcceleratedRecoveryStatus: 'DISABLED' } would force guaranteed drift on every clean run for the typical zone that never opted in). With this slice, the AWS::Route53::HostedZone type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json). New unit tests in route53-provider.test.ts (create with ENABLED triggers post-create UpdateHostedZoneFeatures(true); absent omits; explicit DISABLED also omits — AWS default; failed UHF rolls back via DeleteHostedZone + ProvisioningError; update prev=DISABLED→next=ENABLED fires UHF(true); update prev=ENABLED→next=undefined fires UHF(false) as the implicit-DISABLED transition; update with unchanged status does NOT fire UHF) and route53-provider-readcurrentstate.test.ts (readback emits when GetHostedZone.HostedZone.Features.AcceleratedRecoveryStatus is present; omits when AWS returns no Features block). Real-AWS verified via the existing tests/integration/route53/ fixture — the route53.HostedZone L2 gains addPropertyOverride('HostedZoneFeatures.AcceleratedRecoveryStatus', 'ENABLED') since CDK L2 does not expose the property; verify.sh is extended (same style as the existing GeoProximityLocation / CidrRoutingConfig assertions) with aws route53 get-hosted-zone --query 'HostedZone.Features.AcceleratedRecoveryStatus' asserting 'ENABLED' reached AWS, then destroys clean.

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

  • ✅ Property-coverage backfill (issue #609): wired Type on AWS::SecretsManager::Secret, which SecretsManagerSecretProvider previously silent-dropped on write. Type is a single optional string scalar — the partner identifier for AWS Secrets Manager managed external secrets (third-party-managed secrets registered through partners like Snowflake / Datadog / MongoDB; see the AWS docs reference in the SDK comments on CreateSecretRequest.Type). It rides DIRECTLY on CreateSecret / UpdateSecret (the single SDK calls the provider already makes) — there is NO separate control-plane API. The SDK field name (Type) and casing already match CFn, so the backfill is a straight field-forward: create() passes properties['Type'] to createParams.Type truthy-gated (omit-when-absent — empty string is a no-op on AWS, so the truthy gate matches the field's semantics); update() adds it to the existing update input builder with the same truthy gate (an explicit clear is also a no-op on AWS, no client-side sanitize required). readCurrentState reads it back from DescribeSecret's Type emit-when-present (gated on !== undefined, NOT a default-when-absent placeholder — the typical secret is non-partner-managed and AWS returns no Type, so an '' placeholder would force guaranteed drift on every clean run). With this slice, the AWS::SecretsManager::Secret type is now COMPLETE (its silentDrop set is empty and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.jsonType was the only outstanding entry). Type moves from silentDrop to handled in property-coverage.generated.ts (regenerated via vp run gen:property-coverage — the raw codegen formatting artifact is normalized by vp check --fix). New unit tests extend secretsmanager-secret-provider-roundtrip.test.ts (create sends Type: 'urn:partner:example' into CreateSecretCommand; omit-when-absent; update sends Type: 'urn:partner:v2' into UpdateSecretCommand on diff; omit-when-absent on update) and secretsmanager-secret-provider-readcurrentstate.test.ts (readback emits Type when AWS returns a partner identifier; omits for the typical non-partner-managed secret). Integ verified via /run-integ composite-stack (the existing AWS::SecretsManager::Secret row in the fixture — no Type set, so the integ exercises the omit-when-absent path end-to-end and confirms the handledProperties addition does not regress the existing secret deploy). The Type wire itself is fully covered at unit level because the AWS-side validation of partner identifier strings is opaque (the field accepts only AWS-recognized partner IDs and would reject an arbitrary test value), so live verification of a real partner identifier is out of scope for this slice.

  • ✅ New cdkd local invoke-agentcore <target> command: run a Bedrock AgentCore Runtime container locally and invoke it once over the AgentCore protocol declared by the target (HTTP /invocations / MCP streamable-HTTP / A2A JSON-RPC / AGUI streaming / bidirectional WebSocket via --ws). Models cdk-local's cdkl invoke-agentcore, ported into cdkd's command tree as 8 new shim files under src/local/agentcore-*.ts (agentcore-resolver / agentcore-code-build / agentcore-s3-bundle / agentcore-sigv4-sign / agentcore-client / agentcore-mcp-client / agentcore-a2a-client / agentcore-ws-client) + 1 new src/local/target-picker.ts shim + an expanded src/local/cognito-jwt.ts shim (adds verifyJwtViaDiscovery to the re-export list for inbound JWT auth) + the ~1650-line src/cli/commands/local-invoke-agentcore.ts command file. The command supports the container artifact (fromContainerAsset / fromEcr) and the CodeConfiguration managed-runtime artifact (fromCodeAsset, built from source) on all 4 protocols, plus inbound JWT auth verification against the runtime's OIDC discovery URL (customJwtAuthorizer), outbound SigV4 signing of /invocations (--sigv4), per-request timeout (--timeout, default 120s), session-id header binding (--session-id), platform override (--platform, default linux/arm64 per AgentCore's required arch), state-source flags (--from-state / --from-cfn-stack mirroring cdkd local invoke), role-assumption flags (--assume-role auto-resolves the runtime's RoleArn from cdkd state when bare), and ECR cross-account image pulls (--ecr-role-arn). The shim pattern follows the established 33-file precedent from #713: every shim is a small re-export from 'cdk-local/internal' so the actual implementation lives in cdk-local and cdkd consumes it verbatim. Two cdk-local-side exports were added in a coordinated upstream PR (cdk-local#177, released as cdk-local 0.61.0) — pickAgentCoreCandidateStack (image-uri candidate stack picker) and resolveSingleTarget (interactive picker for omitted target) — so cdkd's shim consumer pattern works without inlining 250+ lines of helpers. resolveExecutionRoleArnFromState in src/cli/commands/local-invoke.ts was extended with an optional roleProperty parameter (defaulting to 'Role') so the agentcore command can reuse it with 'RoleArn' (the field name on AWS::BedrockAgentCore::Runtime). The cdkd local-state-source.ts shim adds resolveCfnFallbackRegion and ExtraStateProviders to its re-export list. Cross-cutting src/local/docker-runner.ts extension for the new command's protocol diversity + secret-handling needs: adds optional containerPort?: number (defaults to 8080 so the existing RIE Lambda local-invoke path is unchanged; MCP runtimes pass 8000, A2A runtimes pass 9000 so the docker -p flag publishes the right port) and optional sensitiveEnvKeys?: ReadonlySet<string> (always unioned with the new SENSITIVE_ENV_KEYS constant covering the AWS credential set, so decrypted SecureString SSM values + AWS creds are routed through docker's value-from-process-env form -e KEY rather than -e KEY=value — the values never appear on the docker run argv / ps / /proc/<pid>/cmdline / verbose debug logs). New unit test in tests/unit/cli/local-invoke-auto-assume-role.test.ts covers the 3rd-arg roleProperty extension's 'RoleArn' case. Integ fixture tests/integration/local-invoke-agentcore/ mirrors cdk-local's: EchoAgent (HTTP), ProtectedAgent (JWT auth), McpAgent (MCP), CodeAgent (CodeConfiguration source-build), A2aAgent (A2A), AguiAgent (AGUI) — verify.sh exercises 20 end-to-end scenarios against Docker. Out of scope (carried over from cdk-local): real Bedrock AgentCore SDK invocation against the cloud (cdkd local * is local-only by definition). The command does NOT replace the existing cdkd deploy path for AWS::BedrockAgentCore::Runtime (that uses src/provisioning/providers/agentcore-runtime-provider.ts); they are distinct paths — the provider deploys agentcore to AWS, the new command runs an agentcore container locally for debugging.

  • ✅ Property-coverage backfill (issue #609): wired LogConfig on AWS::Events::EventBus, which EventBridgeBusProvider previously silent-dropped on write. LogConfig is a nested object { IncludeDetail?: 'NONE' | 'FULL', Level?: 'OFF' | 'ERROR' | 'INFO' | 'TRACE' } that controls EventBridge's per-bus log emission to CloudWatch Logs / S3 / Firehose (separate AWS::Events::LogStream resources route the output). It rides DIRECTLY on CreateEventBus / UpdateEventBus (the single SDK calls the provider already makes) — NO separate control-plane API. create() forwards properties['LogConfig'] to the SDK input when present (omit-when-absent); update() adds it to the existing JSON-stringify diff gate alongside Description / KmsKeyIdentifier / DeadLetterConfig (so a LogConfig-only change triggers a single UpdateEventBus); readCurrentState surfaces it back from DescribeEventBus.LogConfig emit-when-present (NOT the always-emit-placeholder pattern that the sibling DeadLetterConfig uses — AWS only returns LogConfig when set, so a phantom { Level: 'OFF', IncludeDetail: 'NONE' } placeholder would round-trip into spurious drift on buses that never configured logging). Each sub-field is gated on !== undefined individually, so partial AWS responses surface only the user-controllable fields. LogConfig moves from silentDrop to handled in property-coverage.generated.ts (regenerated via vp run gen:property-coverage — the raw codegen formatting artifact is normalized by vp check --fix); AWS::Events::EventBus's silentDrop becomes EMPTY (only entry was LogConfig) and the whole key is dropped from tests/fixtures/cfn-schemas/_todo-backfill.json. New unit tests in eventbridge-bus-provider-roundtrip.test.ts (create forwards LogConfig: { Level: 'INFO', IncludeDetail: 'FULL' } into CreateEventBusCommand; create omits when absent; update emits a single UpdateEventBusCommand on diff; update-no-op produces zero UpdateEventBus calls) and eventbridge-bus-provider-readcurrentstate.test.ts (readback emits LogConfig when AWS returns it; omits when undefined). Real-AWS verified via a NEW tests/integration/eventbridge/verify.sh that deploys the existing EventBridgeStack (now with logConfig: { level: events.Level.INFO, includeDetail: events.IncludeDetail.FULL } on the L2 events.EventBus), asserts via aws events describe-event-bus that both sub-fields reached AWS, then destroys clean.

  • ✅ New cdkd local invoke-agentcore <target> command: run a Bedrock AgentCore Runtime container locally and invoke it once over the AgentCore protocol declared by the target (HTTP /invocations / MCP streamable-HTTP / A2A JSON-RPC / AGUI streaming / bidirectional WebSocket via --ws). Models cdk-local's cdkl invoke-agentcore, ported into cdkd's command tree as 8 new shim files under src/local/agentcore-*.ts (agentcore-resolver / agentcore-code-build / agentcore-s3-bundle / agentcore-sigv4-sign / agentcore-client / agentcore-mcp-client / agentcore-a2a-client / agentcore-ws-client) + 1 new src/local/target-picker.ts shim + an expanded src/local/cognito-jwt.ts shim (adds verifyJwtViaDiscovery to the re-export list for inbound JWT auth) + the ~1650-line src/cli/commands/local-invoke-agentcore.ts command file. The command supports the container artifact (fromContainerAsset / fromEcr) and the CodeConfiguration managed-runtime artifact (fromCodeAsset, built from source) on all 4 protocols, plus inbound JWT auth verification against the runtime's OIDC discovery URL (customJwtAuthorizer), outbound SigV4 signing of /invocations (--sigv4), per-request timeout (--timeout, default 120s), session-id header binding (--session-id), platform override (--platform, default linux/arm64 per AgentCore's required arch), state-source flags (--from-state / --from-cfn-stack mirroring cdkd local invoke), role-assumption flags (--assume-role auto-resolves the runtime's RoleArn from cdkd state when bare), and ECR cross-account image pulls (--ecr-role-arn). The shim pattern follows the established 33-file precedent from #713: every shim is a small re-export from 'cdk-local/internal' so the actual implementation lives in cdk-local and cdkd consumes it verbatim. Two cdk-local-side exports were added in a coordinated upstream PR (cdk-local#177, released as cdk-local 0.61.0) — pickAgentCoreCandidateStack (image-uri candidate stack picker) and resolveSingleTarget (interactive picker for omitted target) — so cdkd's shim consumer pattern works without inlining 250+ lines of helpers. resolveExecutionRoleArnFromState in src/cli/commands/local-invoke.ts was extended with an optional roleProperty parameter (defaulting to 'Role') so the agentcore command can reuse it with 'RoleArn' (the field name on AWS::BedrockAgentCore::Runtime). The cdkd local-state-source.ts shim adds resolveCfnFallbackRegion and ExtraStateProviders to its re-export list. Cross-cutting src/local/docker-runner.ts extension for the new command's protocol diversity + secret-handling needs: adds optional containerPort?: number (defaults to 8080 so the existing RIE Lambda local-invoke path is unchanged; MCP runtimes pass 8000, A2A runtimes pass 9000 so the docker -p flag publishes the right port) and optional sensitiveEnvKeys?: ReadonlySet<string> (always unioned with the new SENSITIVE_ENV_KEYS constant covering the AWS credential set, so decrypted SecureString SSM values + AWS creds are routed through docker's value-from-process-env form -e KEY rather than -e KEY=value — the values never appear on the docker run argv / ps / /proc/<pid>/cmdline / verbose debug logs). New unit test in tests/unit/cli/local-invoke-auto-assume-role.test.ts covers the 3rd-arg roleProperty extension's 'RoleArn' case. Integ fixture tests/integration/local-invoke-agentcore/ mirrors cdk-local's: EchoAgent (HTTP), ProtectedAgent (JWT auth), McpAgent (MCP), CodeAgent (CodeConfiguration source-build), A2aAgent (A2A), AguiAgent (AGUI) — verify.sh exercises 20 end-to-end scenarios against Docker. Out of scope (carried over from cdk-local): real Bedrock AgentCore SDK invocation against the cloud (cdkd local * is local-only by definition). The command does NOT replace the existing cdkd deploy path for AWS::BedrockAgentCore::Runtime (that uses src/provisioning/providers/agentcore-runtime-provider.ts); they are distinct paths — the provider deploys agentcore to AWS, the new command runs an agentcore container locally for debugging.