@go-to-k/cdkd 0.288.6 → 0.289.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
- import { $ as ProviderRegistry, $n as formatDockerLoginError, $r as PartialFailureError, $t as STATE_SOURCED_BASELINE_RULES, A as renderStatefulReason, An as stringifyValue, Ar as PARTITION_TABLE, At as configStringRefusal, B as red, Bn as getBootstrapMarkerKey, Br as setAwsClients, Bt as s3BucketDomainName, C as refusesFinalSnapshot, Cn as CUSTOM_RESOURCE_RESPONSE_PREFIX, Cr as CFN_TEMPLATE_URL_LIMIT, Ct as refStateLookupFromResource, D as MULTI_REGION_RECREATE_BLOCKED_TYPES, Dn as importableOutputs, Dr as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, Dt as assertRegionMatch, E as extractDeploymentEventError, En as importableOutputKeys, Er as uploadCfnTemplate, Et as resolveExplicitPhysicalId, F as formatResourceLine, Fn as rewriteTemplateAssetReferences, Ft as requireConfigString, G as isExportAliasCollision, Gn as validateContainerRepoName, Gt as DiffCalculator, H as collectDeclaredOutputNames, Hn as parseBootstrapMarker, Hr as CdkdError, Ht as s3BucketRegionalDomainName, I as bold, In as AssetModeResolver, Ir as resolveBucketRegion, It as classifyReplaySecretRegion, J as secretSafeKeyDisplay, Jn as buildDockerImage, Jr as IntrinsicResolutionRefusalError, Jt as findSilentDropProperties, K as secretBearing, Kn as buildDenyExternalAccessPolicy, Kr as DeployCancelledError, Kt as INTRINSIC_KEYS, L as cyan, Ln as BOOTSTRAP_MARKER_PREFIX, Lr as AwsClients, Lt as producerRegionsFromState, M as coerceWarmThroughput$1, Mn as buildAssetRedirectMap, Mr as derivePartitionAndUrlSuffix, Mt as replayWarn, N as isWarmThroughputDecrease, Nn as createAssetRedirectResolver, Nr as AssemblyReader, Nt as requireConfigArray, O as isStatefulRecreateTargetForReplace, On as shouldRetainResource, Or as displaySafe, Ot as coerceCfnBoolean, P as toFiniteNumber, Pn as loadPublishableAssetManifest, Pr as processStackMessages, Pt as requireConfigObject, Q as clearOnUpdateRemoval, Qn as dockerSpawnEnvWithSensitive, Qr as NestedStackChildDirectDestroyError, Qt as TemplateParser, R as gray, Rn as assertAssetBucketRegion, Rr as getAwsClients, Rt as withSharedDrainBudget, S as isFinalSnapshotError, Sn as shellQuote, Sr as CFN_TEMPLATE_BODY_LIMIT, St as parameterTypeMayLoseSecretIdentity, T as makeCanonicalizePropertiesFn, Tn as exportNamesCarriedFrom, Tr as findLargeInlineResources, Tt as normalizeAwsTagsToCfn, U as collectPublishedOutputNames, Un as readBootstrapMarkerBody, Ut as s3BucketWebsiteUrl, V as yellow, Vn as isCrossRegionRedirect, Vt as s3BucketDualStackDomainName, W as exportAliasCollisionScrubWarning, Wn as validateAssetBucketName, Wr as CrossAccountSecretRefusalError, Wt as applyRoleArnIfSet, X as IAMRoleProvider, Xn as describeDockerExecFailure, Xr as LocalStartServiceError, Xt as withRetry, Y as getCurrentResourceSecrets, Yn as describeDockerCapturedOutput, Yr as LocalInvokeBuildError$1, Yt as describeTypeWithThrottleRetry, Z as collectInlinePolicyNamesManagedBySiblings, Zn as describeDockerFailure, Zt as DagBuilder, _ as ATOMIC_FINAL_SNAPSHOT_TYPES, _i as retryClassificationText, _n as rebuildClientForBucketRegion, _r as resolveStateBucketWithDefault, _t as carriesDynamicReference, a as DeploymentEventsStore, an as dynamicReferenceTokens, ar as escapeRegExp, at as endCommandInterruptScope, b as ccRoutedFinalSnapshotError, bn as buildLockContentionMessage, br as stateBucketExistenceConfirmed, bt as getAccountInfo, c as replayFailedOperations, cn as isSingleDynamicReferenceToken, cr as getDockerImageBySourceHash, ct as startInterruptWatch, d as updatePartialReason, di as isMarkedNonRetryable, dn as recordMaskOnlyValue, dr as getDefaultStateBucketName, dt as UNSPECIFIED_SKIP_REASON, ei as ProvisioningError, en as STATE_SOURCED_CROSS_GENERATION_RULES, er as getDockerCmd, et as wouldReturnToSdkProvider, f as withResourceDeadline, fi as isRetryableTransientError, fn as recoverMaskedOutput, fr as getLegacyStateBucketName, ft as deleteIndeterminateGuards, g as bindingSkippedOutputs, gi as markRedactedCause, gn as S3StateBackend, gr as resolveSkipPrefix, gt as IntrinsicFunctionResolver, h as computeImplicitDeleteEdges, hi as markNonRetryable, hn as LockManager, hr as resolveCaptureObservedState, ht as isTerminationProtectionPropagationError, i as DeploymentEventsReader, ii as StackTerminationProtectionError, in as createSecretMasker, ir as runDockerStreaming, it as beginCommandInterruptScope, j as WARM_THROUGHPUT_MEMBERS, jn as WorkGraph, jr as canonicalizeRegion, jt as readConfigString, k as isStatefulRecreateTargetSync, kn as AssetPublisher, kr as expectedOwnerParam, kt as configBooleanRefusal, l as replayRollback, li as normalizeAwsError, ln as maskSecretsInError, lr as Synthesizer, lt as CloudControlProvider, m as IMPLICIT_DELETE_DEPENDENCIES, mi as isTransientServerError, mn as scrubResourceRecord, mr as resolveAutoAssetStorage, mt as disableInstanceApiTermination, n as DEFAULT_RESOURCE_WARN_AFTER_MS, ni as ResourceUpdateNotSupportedError, nn as TEMPLATE_SOURCED_RULES, nr as redactDockerArgvValues, nt as maskDeep, o as planFailedOps, on as errorCauseChain, or as stripControlChars, ot as interruptWatchListenerCount, p as maskingRetryLogger, pi as isThrottlingError, pn as redactSecretsForState, pr as resolveApp, pt as deleteSkipReason, q as secretBearingStateKeyWarning, qn as describeAwsFailure, qr as DynamicReferenceRegionAmbiguousError, qt as findActionableSilentDrops, r as DeployEngine, ri as StackHasActiveImportsError, rn as carriesSecretMask, rr as runDockerForeground, rt as maskerOrIdentity, s as planRollback, sn as identityKeyFor, sr as AssetManifestLoader, st as isInterruptedWaitError, t as DEFAULT_RESOURCE_TIMEOUT_MS, ti as ResourceTimeoutError, tn as STATE_SOURCED_READBACK_RULES, tr as partitionSensitiveEnv, tt as createMaskedRetryLogger, u as updatePartialMessage, ui as withErrorHandling, un as maskSecretsInText, ur as synthesisStatusMessage, ut as slowCcOperationTimeoutMs, v as PRE_DELETE_SNAPSHOT_TYPES, vn as UNRENDERABLE, vr as resolveStateBucketWithDefaultAndSource, vt as cfnRefValueFromPhysicalId, w as unsupportedFinalSnapshotError, wn as DEFAULT_STATE_PREFIX, wr as MIGRATE_TMP_PREFIX, wt as WAFv2WebACLProvider, x as createPreDeleteFinalSnapshot, xn as forceQuitRecoveryClause, xr as warnDeprecatedNoPrefixCliFlag, xt as isUnboundTemplateParameter, y as buildFinalSnapshotIdentifier, yn as buildForceUnlockCommand, yr as resolveUseCdkBootstrapAssets, yt as coerceParameterTypedValue, z as green, zn as ensureAssetStorage, zr as resetAwsClients, zt as s3BucketArn } from "./deploy-engine-umIP3xus.js";
2
- import { _ as withStackName, c as PATTERN_B_NAME_OPTIONS, f as generateResourceName, g as withSkipPrefix, i as reserveStdoutForPayload, l as PATTERN_B_NAME_PROPERTIES, m as getCurrentStackName, n as getLogger, o as runStackBuffered, p as generateResourceNameWithFallback, s as getLiveRenderer, u as PATTERN_B_RESOURCE_TYPES } from "./logger-Dw-3y48G.js";
1
+ import { $ as collectInlinePolicyNamesManagedBySiblings, $n as describeDockerCapturedOutput, $r as LocalInvokeBuildError$1, $t as DagBuilder, A as isStatefulRecreateTargetForReplace, An as orphansAfterRollback, Ar as uploadCfnTemplate, At as coerceCfnBoolean, B as gray, Bn as AssetModeResolver, Br as resolveBucketRegion, Bt as withSharedDrainBudget, C as refusesFinalSnapshot, Cn as forceQuitRecoveryClause, Cr as resolveUseCdkBootstrapAssets, Ct as isUnboundTemplateParameter, D as makeSiblingClaimReader, Dn as exportNamesCarriedFrom, Dr as CFN_TEMPLATE_URL_LIMIT, Dt as normalizeAwsTagsToCfn, E as extractDeploymentEventError, En as DEFAULT_STATE_PREFIX, Er as CFN_TEMPLATE_BODY_LIMIT, Et as WAFv2WebACLProvider, F as isWarmThroughputDecrease, Fn as WorkGraph, Fr as canonicalizeRegion, Ft as requireConfigArray, G as collectPublishedOutputNames, Gn as isCrossRegionRedirect, Gt as s3BucketWebsiteUrl, H as red, Hn as assertAssetBucketRegion, Hr as getAwsClients, Ht as s3BucketDomainName, I as toFiniteNumber, In as buildAssetRedirectMap, Ir as derivePartitionAndUrlSuffix, It as requireConfigObject, J as secretBearing, Jn as validateAssetBucketName, Jr as CrossAccountSecretRefusalError, Jt as INTRINSIC_KEYS, K as exportAliasCollisionScrubWarning, Kn as parseBootstrapMarker, Kr as CdkdError, Kt as applyRoleArnIfSet, L as formatResourceLine, Ln as createAssetRedirectResolver, Lr as AssemblyReader, Lt as requireConfigString, M as renderStatefulReason, Mn as shouldRetainResource, Mr as displaySafe, Mt as configStringRefusal, N as WARM_THROUGHPUT_MEMBERS, Nn as AssetPublisher, Nr as expectedOwnerParam, Nt as readConfigString, O as planOrphanAdoption, On as importableOutputKeys, Or as MIGRATE_TMP_PREFIX, Ot as resolveExplicitPhysicalId, P as coerceWarmThroughput$1, Pn as stringifyValue, Pr as PARTITION_TABLE, Pt as replayWarn, Q as IAMRoleProvider, Qn as buildDockerImage, Qr as IntrinsicResolutionRefusalError, Qt as withRetry, R as bold, Rn as loadPublishableAssetManifest, Rr as processStackMessages, Rt as classifyReplaySecretRegion, S as isFinalSnapshotError, Sn as buildLockContentionMessage, Sr as resolveStateBucketWithDefaultAndSource, St as getAccountInfo, T as makeCanonicalizePropertiesFn, Tn as CUSTOM_RESOURCE_RESPONSE_PREFIX, Tr as warnDeprecatedNoPrefixCliFlag, Tt as refStateLookupFromResource, U as yellow, Un as ensureAssetStorage, Ur as resetAwsClients, Ut as s3BucketDualStackDomainName, V as green, Vn as BOOTSTRAP_MARKER_PREFIX, Vr as AwsClients, Vt as s3BucketArn, W as collectDeclaredOutputNames, Wn as getBootstrapMarkerKey, Wr as setAwsClients, Wt as s3BucketRegionalDomainName, X as secretSafeKeyDisplay, Xn as buildDenyExternalAccessPolicy, Xr as DeployCancelledError, Xt as findSilentDropProperties, Y as secretBearingStateKeyWarning, Yn as validateContainerRepoName, Yt as findActionableSilentDrops, Z as getCurrentResourceSecrets, Zn as describeAwsFailure, Zr as DynamicReferenceRegionAmbiguousError, Zt as describeTypeWithThrottleRetry, _ as ATOMIC_FINAL_SNAPSHOT_TYPES, _i as isThrottlingError, _n as LockManager, _r as resolveApp, _t as isTerminationProtectionPropagationError, a as DeploymentEventsStore, ai as ResourceTimeoutError, an as carriesSecretMask, ar as partitionSensitiveEnv, at as maskerOrIdentity, b as ccRoutedFinalSnapshotError, bi as markRedactedCause, bn as UNRENDERABLE, br as resolveSkipPrefix, bt as cfnRefValueFromPhysicalId, c as replayFailedOperations, ci as StackTerminationProtectionError, cn as errorCauseChain, cr as runDockerStreaming, ct as interruptWatchListenerCount, d as updatePartialReason, dn as maskSecretsInError, dr as AssetManifestLoader, dt as CloudControlProvider, ei as LocalStartServiceError, en as TemplateParser, er as describeDockerExecFailure, et as clearOnUpdateRemoval, f as withResourceDeadline, fn as maskSecretsInText, fr as getDockerImageBySourceHash, ft as slowCcOperationTimeoutMs, g as bindingSkippedOutputs, gi as isRetryableTransientError, gn as scrubResourceRecord, gr as getLegacyStateBucketName, gt as disableInstanceApiTermination, h as computeImplicitDeleteEdges, hi as isMarkedNonRetryable, hn as redactSecretsForState, hr as getDefaultStateBucketName, ht as deleteSkipReason, i as DeploymentEventsReader, ii as ProvisioningError, in as TEMPLATE_SOURCED_RULES, ir as getDockerCmd, it as maskDeep, j as isStatefulRecreateTargetSync, jn as orphansCarriedFrom, jr as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, jt as configBooleanRefusal, k as MULTI_REGION_RECREATE_BLOCKED_TYPES, kn as importableOutputs, kr as findLargeInlineResources, kt as assertRegionMatch, l as replayRollback, ln as identityKeyFor, lr as escapeRegExp, lt as isInterruptedWaitError, m as IMPLICIT_DELETE_DEPENDENCIES, mi as withErrorHandling, mn as recoverMaskedOutput, mr as synthesisStatusMessage, mt as deleteIndeterminateGuards, n as DEFAULT_RESOURCE_WARN_AFTER_MS, ni as NestedStackChildDirectDestroyError, nn as STATE_SOURCED_CROSS_GENERATION_RULES, nr as dockerSpawnEnvWithSensitive, nt as wouldReturnToSdkProvider, o as planFailedOps, oi as ResourceUpdateNotSupportedError, on as createSecretMasker, or as redactDockerArgvValues, ot as beginCommandInterruptScope, p as maskingRetryLogger, pi as normalizeAwsError, pn as recordMaskOnlyValue, pr as Synthesizer, pt as UNSPECIFIED_SKIP_REASON, q as isExportAliasCollision, qn as readBootstrapMarkerBody, qt as DiffCalculator, r as DeployEngine, ri as PartialFailureError, rn as STATE_SOURCED_READBACK_RULES, rr as formatDockerLoginError, rt as createMaskedRetryLogger, s as planRollback, si as StackHasActiveImportsError, sn as dynamicReferenceTokens, sr as runDockerForeground, st as endCommandInterruptScope, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as STATE_SOURCED_BASELINE_RULES, tr as describeDockerFailure, tt as ProviderRegistry, u as updatePartialMessage, un as isSingleDynamicReferenceToken, ur as stripControlChars, ut as startInterruptWatch, v as PRE_DELETE_SNAPSHOT_TYPES, vi as isTransientServerError, vn as S3StateBackend, vr as resolveAutoAssetStorage, vt as IntrinsicFunctionResolver, w as unsupportedFinalSnapshotError, wn as shellQuote, wr as stateBucketExistenceConfirmed, wt as parameterTypeMayLoseSecretIdentity, x as createPreDeleteFinalSnapshot, xi as retryClassificationText, xn as buildForceUnlockCommand, xr as resolveStateBucketWithDefault, xt as coerceParameterTypedValue, y as buildFinalSnapshotIdentifier, yi as markNonRetryable, yn as rebuildClientForBucketRegion, yr as resolveCaptureObservedState, yt as carriesDynamicReference, z as cyan, zn as rewriteTemplateAssetReferences, zt as producerRegionsFromState } from "./deploy-engine-B6pEfNd9.js";
2
+ import { _ as withSkipPrefix, c as PATTERN_B_NAME_OPTIONS, f as explicitNamePropertyFor, h as getCurrentStackName, i as reserveStdoutForPayload, l as PATTERN_B_NAME_PROPERTIES, m as generateResourceNameWithFallback, n as getLogger, o as runStackBuffered, p as generateResourceName, s as getLiveRenderer, u as PATTERN_B_RESOURCE_TYPES, v as withStackName } from "./logger-D2Md1kTq.js";
3
3
  import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
4
- import { t as getCdkdVersion } from "./version-4xTzOaA1.js";
5
- import { r as protectedReplacementAdvice, t as ASGProvider } from "./asg-provider-CC6BMuNK.js";
4
+ import { t as getCdkdVersion } from "./version-CCKo6fK2.js";
5
+ import { r as protectedReplacementAdvice, t as ASGProvider } from "./asg-provider-1XVtAjhm.js";
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import { createHash, randomBytes, randomUUID } from "node:crypto";
8
8
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, NotFound, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -692,15 +692,70 @@ const allowUnsupportedTypesOption = new Option("--allow-unsupported-types <types
692
692
  * time so the user sees the error immediately.
693
693
  */
694
694
  const RESOURCE_PROPERTY_FORMAT = /^[A-Z][A-Za-z0-9]+(::[A-Z][A-Za-z0-9]+)+:[A-Z][A-Za-z0-9]*$/;
695
- function parseAllowUnsupportedPropertiesToken(value, previous) {
695
+ /**
696
+ * The flag the errors below name themselves after.
697
+ *
698
+ * Both spellings share one parser, so the message has to say which one the user
699
+ * typed — quoting the canonical name at someone who typed the deprecated alias
700
+ * sends them looking for a flag they did not use.
701
+ */
702
+ function parseRouteEntries(value, previous, flag) {
696
703
  const parsed = value.split(",").map((s) => s.trim()).filter(Boolean);
697
704
  for (const token of parsed) {
698
- if (!RESOURCE_PROPERTY_FORMAT.test(token)) throw new Error(`Invalid --allow-unsupported-properties value "${token}": expected <ResourceType>:<PropertyName> with PascalCase on both halves (e.g. AWS::Lambda::Function:RuntimeManagementConfig).`);
699
- if (token.startsWith("Custom::")) throw new Error(`Invalid --allow-unsupported-properties value "${token}": Custom:: resources are routed through cfn-response and have no write-side silent drop at cdkd, so the flag would have no effect. Use --allow-unsupported-types for type-level escape hatches instead.`);
705
+ if (!RESOURCE_PROPERTY_FORMAT.test(token)) throw new Error(`Invalid ${flag} value "${token}": expected <ResourceType>:<PropertyName> with PascalCase on both halves (e.g. AWS::Lambda::Function:RuntimeManagementConfig).`);
706
+ if (token.startsWith("Custom::")) throw new Error(`Invalid ${flag} value "${token}": Custom:: resources are routed through cfn-response and have no write-side silent drop at cdkd, so the flag would have no effect. Use --allow-unsupported-types for type-level escape hatches instead.`);
700
707
  }
701
708
  return [...previous ?? [], ...parsed];
702
709
  }
703
- const allowUnsupportedPropertiesOption = new Option("--allow-unsupported-properties <entries>", "Comma-separated <ResourceType>:<PropertyName> tokens to accept as silently dropped at deploy time. Escape hatch — the property will NOT be written to AWS, the deployed resource will be missing the field. Example: --allow-unsupported-properties AWS::Lambda::Function:RuntimeManagementConfig,AWS::RDS::DBInstance:CACertificateIdentifier").argParser(parseAllowUnsupportedPropertiesToken);
710
+ function parsePreferSdkRouteToken(value, previous) {
711
+ return parseRouteEntries(value, previous, "--prefer-sdk-route");
712
+ }
713
+ /**
714
+ * Whether the deprecated alias has already warned this process.
715
+ *
716
+ * Commander calls the parser ONCE PER OCCURRENCE, and this flag is repeatable —
717
+ * so without this a user passing it three times gets the deprecation notice
718
+ * three times, which reads as three separate problems.
719
+ */
720
+ let deprecatedAliasWarned = false;
721
+ function parseAllowUnsupportedPropertiesToken(value, previous) {
722
+ if (!deprecatedAliasWarned) {
723
+ deprecatedAliasWarned = true;
724
+ process.stderr.write("--allow-unsupported-properties is deprecated and will be removed in the first minor release after 2026-12-01; use --prefer-sdk-route, which takes the same <ResourceType>:<PropertyName> tokens and does the same thing. The old name reads as the opposite of its behaviour: it does not make the property reach AWS, it keeps the resource on the SDK provider and accepts that the value is NOT written.\n");
725
+ }
726
+ return parseRouteEntries(value, previous, "--allow-unsupported-properties");
727
+ }
728
+ /**
729
+ * `--prefer-sdk-route` — keep a resource on its SDK provider instead of letting
730
+ * an unwired property auto-route it to Cloud Control (issue
731
+ * [#3000](https://github.com/go-to-k/cdkd/issues/3000)).
732
+ *
733
+ * RENAMED from `--allow-unsupported-properties`, which read as the opposite of
734
+ * what it does and rhymed with `--allow-unsupported-types`, a flag that runs the
735
+ * OTHER way: that one ENABLES a Cloud Control attempt, this one DISABLES the
736
+ * Cloud Control auto-route. Two independent readers took the old name backwards
737
+ * in one session, one of them while writing 44 issue bodies describing it.
738
+ *
739
+ * `prefer`, not a negative or a guarantee, and the reason is mechanical rather
740
+ * than stylistic. The allow set is per `<Type>:<Prop>` while ROUTING is per
741
+ * RESOURCE, so one un-allowed sibling drop on the same resource sends the whole
742
+ * resource to Cloud Control anyway — and Cloud Control then forwards the full
743
+ * map, so the named property reaches AWS after all. A name promising "do not
744
+ * route" would be a promise this flag cannot keep; `prefer` is the one modality
745
+ * that is true in both cases.
746
+ *
747
+ * The help text therefore carries what the name deliberately does not: that the
748
+ * values go UNWRITTEN when the preference is honoured, and that it is ignored
749
+ * when the resource routes to Cloud Control for another reason.
750
+ */
751
+ const preferSdkRouteOption = new Option("--prefer-sdk-route <entries>", "Comma-separated <ResourceType>:<PropertyName> tokens to keep on the SDK provider instead of auto-routing the resource to Cloud Control. The named values are then NOT written to AWS — the deployed resource is missing the field. Ignored for a resource that routes to Cloud Control for another reason (a sibling unwired property, or a sticky cc-api state record), where the values are written after all. Example: --prefer-sdk-route AWS::Lambda::Function:RuntimeManagementConfig").argParser(parsePreferSdkRouteToken);
752
+ /**
753
+ * The deprecated spelling. HIDDEN from `--help` so new users meet only the
754
+ * successor, but still parsed so existing scripts keep working; it warns once
755
+ * per process and feeds the SAME option name, so nothing downstream can tell
756
+ * which spelling was used.
757
+ */
758
+ const allowUnsupportedPropertiesOption = new Option("--allow-unsupported-properties <entries>", "Deprecated alias for --prefer-sdk-route.").argParser(parseAllowUnsupportedPropertiesToken).hideHelp();
704
759
  /**
705
760
  * Issue [#615] — `--recreate-via-cc-api <LogicalId>` (repeatable). Each
706
761
  * named resource is destroyed + recreated this deploy via Cloud Control
@@ -724,7 +779,7 @@ function parseRecreateViaCcApiToken(value, previous) {
724
779
  if (!LOGICAL_ID_FORMAT.test(token)) throw new Error(`Invalid --recreate-via-cc-api value "${value}": expected a CloudFormation logical id (alphanumeric, starts with a letter, max 255 chars). One --recreate-via-cc-api flag per resource — repeat the flag for additional targets.`);
725
780
  return [...previous ?? [], token];
726
781
  }
727
- const recreateViaCcApiOption = new Option("--recreate-via-cc-api <logicalId>", "Destroy + recreate the named resource (by CloudFormation logical id) via Cloud Control API in this deploy, so a top-level CFn property cdkd would otherwise silently drop reaches AWS via CC. Repeatable — pass the flag once per resource. Per-resource opt-in (no bulk / no per-stack shortcut) so the destroy-and-recreate cost is acknowledged for each target. Stateful resource types (RDS, DynamoDB, S3, EFS, ...) refuse unless --force-stateful-recreation is ALSO passed (two-flag protection). Cannot be combined with --allow-unsupported-properties on the same resource type and property.").argParser(parseRecreateViaCcApiToken);
782
+ const recreateViaCcApiOption = new Option("--recreate-via-cc-api <logicalId>", "Destroy + recreate the named resource (by CloudFormation logical id) via Cloud Control API in this deploy, so a top-level CFn property cdkd would otherwise silently drop reaches AWS via CC. Repeatable — pass the flag once per resource. Per-resource opt-in (no bulk / no per-stack shortcut) so the destroy-and-recreate cost is acknowledged for each target. Stateful resource types (RDS, DynamoDB, S3, EFS, ...) refuse unless --force-stateful-recreation is ALSO passed (two-flag protection). Cannot be combined with --prefer-sdk-route on the same resource type and property.").argParser(parseRecreateViaCcApiToken);
728
783
  /**
729
784
  * Issue [#651] — `--recreate-via-sdk-provider <LogicalId>` is the reverse
730
785
  * direction of `--recreate-via-cc-api`. Once a resource is sticky on
@@ -897,6 +952,7 @@ const deployOptions = [
897
952
  aggressiveVpcParallelOption,
898
953
  new Option("-e, --exclusively", "Only deploy requested stacks, do not include dependencies").default(false),
899
954
  allowUnsupportedTypesOption,
955
+ preferSdkRouteOption,
900
956
  allowUnsupportedPropertiesOption,
901
957
  recreateViaCcApiOption,
902
958
  recreateViaSdkProviderOption,
@@ -2758,8 +2814,8 @@ function renderRecreateTargetsErrors(validation) {
2758
2814
  }
2759
2815
  if (validation.ambiguousIntent.length > 0) {
2760
2816
  if (lines.length > 0) lines.push("");
2761
- lines.push(`Ambiguous intent — ${validation.ambiguousIntent.length} resource(s) are named in BOTH --recreate-via-cc-api and --allow-unsupported-properties with the same Type:Prop on a silent-drop property the template uses:`);
2762
- for (const overlap of validation.ambiguousIntent) lines.push(` - ${overlap.logicalId} (${overlap.resourceType}) — both --recreate-via-cc-api ${overlap.logicalId} (would migrate to CC, honoring ${overlap.property}) AND --allow-unsupported-properties ${overlap.resourceType}:${overlap.property} (would keep on SDK, accepting silent drop)`);
2817
+ lines.push(`Ambiguous intent — ${validation.ambiguousIntent.length} resource(s) are named in BOTH --recreate-via-cc-api and --prefer-sdk-route with the same Type:Prop on a silent-drop property the template uses:`);
2818
+ for (const overlap of validation.ambiguousIntent) lines.push(` - ${overlap.logicalId} (${overlap.resourceType}) — both --recreate-via-cc-api ${overlap.logicalId} (would migrate to CC, honoring ${overlap.property}) AND --prefer-sdk-route ${overlap.resourceType}:${overlap.property} (would keep on SDK, accepting silent drop)`);
2763
2819
  lines.push(` Fix: pick ONE strategy per resource.`);
2764
2820
  }
2765
2821
  if (validation.blockedStatefulTargets.length > 0) {
@@ -2809,9 +2865,9 @@ function renderRecreateTargetsErrors(validation) {
2809
2865
  }
2810
2866
  if (validation.ambiguousIntentSdk.length > 0) {
2811
2867
  if (lines.length > 0) lines.push("");
2812
- lines.push(`Inverse ambiguous intent — ${validation.ambiguousIntentSdk.length} --recreate-via-sdk-provider target(s) would IMMEDIATELY be re-routed back to Cloud Control after the recreate because their template uses silent-drop properties NOT in --allow-unsupported-properties:`);
2868
+ lines.push(`Inverse ambiguous intent — ${validation.ambiguousIntentSdk.length} --recreate-via-sdk-provider target(s) would IMMEDIATELY be re-routed back to Cloud Control after the recreate because their template uses silent-drop properties NOT in --prefer-sdk-route:`);
2813
2869
  for (const overlap of validation.ambiguousIntentSdk) lines.push(` - ${overlap.logicalId} (${overlap.resourceType}) — template uses ${overlap.property}; the default-on CC auto-route would re-route the recreated resource back to CC immediately`);
2814
- lines.push(" Fix: pass --allow-unsupported-properties <Type>:<Prop> for each silent-drop property so the recreated resource stays on SDK with the property explicitly dropped. Or drop --recreate-via-sdk-provider — the resource already routes via CC and honors the property.");
2870
+ lines.push(" Fix: pass --prefer-sdk-route <Type>:<Prop> for each silent-drop property so the recreated resource stays on SDK with the property explicitly dropped. Or drop --recreate-via-sdk-provider — the resource already routes via CC and honors the property.");
2815
2871
  }
2816
2872
  return lines.length > 0 ? lines.join("\n") : null;
2817
2873
  }
@@ -61046,8 +61102,9 @@ async function runDestroyForStack(stackName, state, ctx) {
61046
61102
  };
61047
61103
  const guardIndeterminateTargets = /* @__PURE__ */ new Set();
61048
61104
  const resourceCount = Object.keys(state.resources).length;
61105
+ const orphanCount = (state.orphans ?? []).length;
61049
61106
  const regionForState = state.region ?? ctx.baseRegion;
61050
- if (resourceCount === 0) {
61107
+ if (resourceCount === 0 && orphanCount === 0) {
61051
61108
  logger.info(`Stack ${stackName} has no resources, cleaning up state...`);
61052
61109
  let emptyLockHeld = false;
61053
61110
  let emptyInterrupted = false;
@@ -61093,7 +61150,9 @@ async function runDestroyForStack(stackName, state, ctx) {
61093
61150
  emptyLockHeld = true;
61094
61151
  try {
61095
61152
  const recheck = await ctx.stateBackend.getState(stackName, regionForState);
61096
- if (!(!recheck || Object.keys(recheck.state.resources).length === 0)) throw new Error(`Stack '${stackName}' (${regionForState}) was empty when this run started but now has ${Object.keys(recheck.state.resources).length} resource(s) — another cdkd process deployed into it. Re-run the destroy to act on the current state.`);
61153
+ const recheckResources = recheck ? Object.keys(recheck.state.resources).length : 0;
61154
+ const recheckOrphans = recheck ? (recheck.state.orphans ?? []).length : 0;
61155
+ if (!(!recheck || recheckResources === 0 && recheckOrphans === 0)) throw new Error(`Stack '${stackName}' (${regionForState}) was empty when this run started but now has ${recheckResources} resource(s) and ${recheckOrphans} rollback-orphaned resource(s) — another cdkd process wrote to it. Re-run the destroy to act on the current state.`);
61097
61156
  await ctx.stateBackend.deleteState(stackName, regionForState);
61098
61157
  logger.info(`${green("✓")} State deleted`);
61099
61158
  } finally {
@@ -61116,6 +61175,12 @@ async function runDestroyForStack(stackName, state, ctx) {
61116
61175
  logger.info(`\nResources to be deleted (${resourceCount}):`);
61117
61176
  for (const [logicalId, resource] of Object.entries(state.resources)) logger.info(` - ${logicalId} (${resource.resourceType})`);
61118
61177
  const protectedCount = ctx.removeProtection ? countProtectedResources(state) : 0;
61178
+ const orphansAtDestroy = state.orphans ?? [];
61179
+ if (orphansAtDestroy.length > 0) {
61180
+ logger.info(`\n${orphansAtDestroy.length} resource(s) are still in AWS from an earlier failed deploy. cdkd left them there because their DeletionPolicy is Retain (CDK's RemovalPolicy.RETAIN), and has been tracking them so a later deploy of this stack could adopt them back instead of colliding with their names.`);
61181
+ logger.info("Destroying this stack deletes that record. The resources stay in AWS, may continue to incur charges, and cdkd will no longer know about them:");
61182
+ for (const entry of orphansAtDestroy) logger.info(` - ${entry.logicalId} (${entry.state.resourceType}) ${entry.state.physicalId}`);
61183
+ }
61119
61184
  if (!ctx.skipConfirmation) {
61120
61185
  if (process.stdin.isTTY !== true) throw new CdkdError(`The destroy confirmation prompt for stack "${stackName}" cannot run in a non-interactive environment. Pass --yes / -y to confirm the destroy (cdkd destroy also accepts -f / --force), or run the command from a real terminal.`, "NON_INTERACTIVE_CONFIRM");
61121
61186
  const rl = readline$1.createInterface({
@@ -63165,7 +63230,8 @@ async function deployCommand(stacks, options) {
63165
63230
  registerAllProviders(stackProviderRegistry);
63166
63231
  stackProviderRegistry.setCustomResourceResponseBucket(stateBucket);
63167
63232
  if (options.allowUnsupportedTypes?.length) stackProviderRegistry.allowUnsupportedTypes(options.allowUnsupportedTypes);
63168
- if (options.allowUnsupportedProperties?.length) stackProviderRegistry.allowUnsupportedProperties(options.allowUnsupportedProperties);
63233
+ const preferSdkRoute = [...options.preferSdkRoute ?? [], ...options.allowUnsupportedProperties ?? []];
63234
+ if (preferSdkRoute.length) stackProviderRegistry.allowUnsupportedProperties(preferSdkRoute);
63169
63235
  const eventRecorder = startRunRecorder({
63170
63236
  backend: stackStateBackend,
63171
63237
  stackName: stackInfo.stackName,
@@ -63186,7 +63252,7 @@ async function deployCommand(stacks, options) {
63186
63252
  const syncValidation = validateRecreateTargets({
63187
63253
  template: stackInfo.template,
63188
63254
  state: stateForRecreateCheck?.state ?? {
63189
- version: 9,
63255
+ version: 10,
63190
63256
  stackName: stackInfo.stackName,
63191
63257
  region: stackRegion,
63192
63258
  resources: {},
@@ -63195,7 +63261,7 @@ async function deployCommand(stacks, options) {
63195
63261
  },
63196
63262
  recreateViaCcApi: options.recreateViaCcApi ?? [],
63197
63263
  recreateViaSdkProvider: options.recreateViaSdkProvider ?? [],
63198
- allowUnsupportedProperties: new Set(options.allowUnsupportedProperties ?? []),
63264
+ allowUnsupportedProperties: /* @__PURE__ */ new Set([...options.preferSdkRoute ?? [], ...options.allowUnsupportedProperties ?? []]),
63199
63265
  forceStatefulRecreation: options.forceStatefulRecreation ?? false,
63200
63266
  hasSdkProvider: (rt) => stackProviderRegistry.getProviderType(rt) === "sdk"
63201
63267
  });
@@ -64849,7 +64915,7 @@ async function loadStateOrEmpty(stackName, region, stateBackend) {
64849
64915
  region,
64850
64916
  resources: {},
64851
64917
  outputs: {},
64852
- version: 9,
64918
+ version: 10,
64853
64919
  lastModified: Date.now()
64854
64920
  };
64855
64921
  }
@@ -64996,7 +65062,24 @@ async function computeStackDiff(currentState, template, region, stackName, state
64996
65062
  ...conditions && { conditions },
64997
65063
  skipDynamicReferences: true
64998
65064
  });
64999
- const changes = await diffCalculator.calculateDiff(currentState, effectiveTemplate, resolveFn, canonicalizeProperties);
65065
+ let adoptedOrphans = [];
65066
+ let adoptedRecords = {};
65067
+ let blocking = [];
65068
+ let stateForDiff = currentState;
65069
+ if (currentState.orphans?.length && options.previewOrphanAdoption) {
65070
+ const plan = await options.previewOrphanAdoption(currentState, effectiveTemplate, stackName, region);
65071
+ adoptedOrphans = Object.keys(plan.adopted);
65072
+ adoptedRecords = plan.adopted;
65073
+ blocking = plan.refusals;
65074
+ if (adoptedOrphans.length > 0) stateForDiff = {
65075
+ ...currentState,
65076
+ resources: {
65077
+ ...currentState.resources,
65078
+ ...plan.adopted
65079
+ }
65080
+ };
65081
+ }
65082
+ const changes = await diffCalculator.calculateDiff(stateForDiff, effectiveTemplate, resolveFn, canonicalizeProperties);
65000
65083
  const changedLogicalIds = new Set([...changes.values()].filter((c) => c.changeType !== "NO_CHANGE").map((c) => c.logicalId));
65001
65084
  const bindingSkipped = bindingSkippedOutputs(outputsDigestSource, currentState.skippedOutputs, changedLogicalIds);
65002
65085
  const resolved = await resolveTemplateOutputs(effectiveTemplate, resolveFn, conditions, currentState.outputs, bindingSkipped);
@@ -65012,7 +65095,10 @@ async function computeStackDiff(currentState, template, region, stackName, state
65012
65095
  });
65013
65096
  return {
65014
65097
  changes,
65015
- outputChanges
65098
+ outputChanges,
65099
+ adoptedOrphans,
65100
+ adoptedRecords,
65101
+ blocking
65016
65102
  };
65017
65103
  }
65018
65104
  /**
@@ -65075,22 +65161,32 @@ async function resolveChildStackParameters(parentStackRow, parentTemplate, paren
65075
65161
  * fail at deploy time.
65076
65162
  */
65077
65163
  async function buildDiffTree(args) {
65078
- const { stackName, displayName, region, template, nestedTemplates, recursive, stateBackend, diffCalculator, parentHasSecretReference, parameters, canonicalizeProperties, assetRedirect, cfnFallback } = args;
65164
+ const { stackName, displayName, region, template, nestedTemplates, recursive, stateBackend, diffCalculator, parentHasSecretReference, parameters, canonicalizeProperties, assetRedirect, cfnFallback, previewOrphanAdoption } = args;
65079
65165
  const state = await loadStateOrEmpty(stackName, region, stateBackend);
65080
65166
  const secretBearingAbove = parentHasSecretReference === true || templateHasSecretDynamicReference(template);
65081
- const { changes, outputChanges } = await computeStackDiff(state, template, region, stackName, stateBackend, diffCalculator, {
65167
+ const { changes, outputChanges, adoptedOrphans, adoptedRecords, blocking } = await computeStackDiff(state, template, region, stackName, stateBackend, diffCalculator, {
65082
65168
  ...parameters && { parameters },
65083
65169
  ...canonicalizeProperties && { canonicalizeProperties },
65084
65170
  ...cfnFallback !== void 0 && { cfnFallback },
65171
+ ...previewOrphanAdoption && { previewOrphanAdoption },
65085
65172
  inheritSecretBearingTemplate: false
65086
65173
  });
65174
+ const stateAfterAdoption = Object.keys(adoptedRecords).length > 0 ? {
65175
+ ...state,
65176
+ resources: {
65177
+ ...state.resources,
65178
+ ...adoptedRecords
65179
+ }
65180
+ } : state;
65087
65181
  const node = {
65088
65182
  stackName,
65089
65183
  displayName,
65090
65184
  region,
65091
65185
  changes,
65092
- ccApiRoutes: collectCcApiRoutes(template, state),
65186
+ ccApiRoutes: collectCcApiRoutes(template, stateAfterAdoption),
65093
65187
  outputChanges,
65188
+ adoptedOrphans,
65189
+ blocking,
65094
65190
  children: []
65095
65191
  };
65096
65192
  if (!recursive) return node;
@@ -65104,7 +65200,7 @@ async function buildDiffTree(args) {
65104
65200
  const childTemplate = readNestedTemplate(childTemplatePath);
65105
65201
  if (assetRedirect) rewriteTemplateAssetReferences(childTemplate, assetRedirect);
65106
65202
  const grandchildTemplates = indexNestedChildTemplates(childTemplate, childTemplatePath);
65107
- const childParameters = await resolveChildStackParameters(resource, template, state, region, stackName, stateBackend, parameters, cfnFallback);
65203
+ const childParameters = await resolveChildStackParameters(resource, template, stateAfterAdoption, region, stackName, stateBackend, parameters, cfnFallback);
65108
65204
  node.children.push(await buildDiffTree({
65109
65205
  stackName: childStackName,
65110
65206
  displayName: childStackName,
@@ -65118,6 +65214,7 @@ async function buildDiffTree(args) {
65118
65214
  ...canonicalizeProperties && { canonicalizeProperties },
65119
65215
  ...assetRedirect && { assetRedirect },
65120
65216
  ...cfnFallback !== void 0 && { cfnFallback },
65217
+ ...previewOrphanAdoption && { previewOrphanAdoption },
65121
65218
  parentHasSecretReference: secretBearingAbove
65122
65219
  }));
65123
65220
  }
@@ -65143,6 +65240,8 @@ async function buildDeletedSubtree(stackName, region, stateBackend, diffCalculat
65143
65240
  region,
65144
65241
  changes,
65145
65242
  ccApiRoutes: /* @__PURE__ */ new Map(),
65243
+ adoptedOrphans: [],
65244
+ blocking: [],
65146
65245
  outputChanges,
65147
65246
  children: []
65148
65247
  };
@@ -65209,9 +65308,41 @@ function collectCcApiRoutes(template, state) {
65209
65308
  /** True when this node has at least one real (non-`NO_CHANGE`) change. */
65210
65309
  function nodeHasChanges(node) {
65211
65310
  for (const change of node.changes.values()) if (change.changeType !== "NO_CHANGE") return true;
65212
- return node.outputChanges.length > 0;
65311
+ return node.outputChanges.length > 0 || node.adoptedOrphans.length > 0;
65213
65312
  }
65214
65313
  /** True when this node OR any descendant has a real change (tree-wide drift detector for `--fail`). */
65314
+ /**
65315
+ * How many deploy refusals the whole tree carries (issue go-to-k/cdkd#2943).
65316
+ *
65317
+ * A COUNT rather than a boolean because the caller's message quotes it.
65318
+ * {@link treeIsWorthRendering} below IS a boolean built on this, added when
65319
+ * review found the render gate gated on changes alone — it derives from this
65320
+ * count rather than walking the tree again, so there is still one recursion to
65321
+ * keep in step.
65322
+ *
65323
+ * Deliberately SEPARATE from {@link treeHasChanges}: `--fail` answers "is
65324
+ * there a delta", and a refusal is not a delta — a user who runs
65325
+ * `cdkd diff --fail` in CI to detect drift must not have that signal merged
65326
+ * with "the deploy cannot start", which is true whether or not anything
65327
+ * changed.
65328
+ */
65329
+ function countBlocking(node) {
65330
+ return node.blocking.length + node.children.reduce((n, c) => n + countBlocking(c), 0);
65331
+ }
65332
+ /**
65333
+ * Whether `cdkd diff` should render this tree's block at all (issue
65334
+ * go-to-k/cdkd#2943).
65335
+ *
65336
+ * A named predicate rather than an inline conjunction at the call site,
65337
+ * because the two halves are easy to get wrong INDEPENDENTLY and were:
65338
+ * {@link renderDiffTree} was written to print a `Blocking` section for a node
65339
+ * with no changes, and the caller then skipped the render on
65340
+ * `!treeHasChanges` alone — so a changeless refusal would print "No changes
65341
+ * detected" and exit non-zero citing reasons that were never printed.
65342
+ */
65343
+ function treeIsWorthRendering(node) {
65344
+ return treeHasChanges(node) || countBlocking(node) > 0;
65345
+ }
65215
65346
  function treeHasChanges(node) {
65216
65347
  if (nodeHasChanges(node)) return true;
65217
65348
  return node.children.some(treeHasChanges);
@@ -65248,6 +65379,8 @@ function diffTreeToJson(node) {
65248
65379
  ...change.changeType !== "REMOVE" ? { newValue: change.newValue } : {},
65249
65380
  export: change.isExport
65250
65381
  })),
65382
+ adoptedOrphans: node.adoptedOrphans,
65383
+ blocking: node.blocking,
65251
65384
  children: node.children.map(diffTreeToJson)
65252
65385
  };
65253
65386
  }
@@ -65340,11 +65473,22 @@ function renderDiffValue(own, filtered, indent, isNewSide) {
65340
65473
  * #614's auto-fallback decision at plan time. DELETE lines are not annotated
65341
65474
  * — the delete routing is recorded on each resource's `provisionedBy` state
65342
65475
  * field rather than re-derived from the template.
65476
+ *
65477
+ * `adoptedOrphans` annotates the rows whose resource is in this diff only
65478
+ * because a rollback-orphan record was verified and spliced in (issue
65479
+ * go-to-k/cdkd#2943). Without it the row is indistinguishable from an ordinary
65480
+ * update, and the user last saw that resource FAIL and leave state — so an
65481
+ * unannotated `[~]` reads as cdkd having quietly kept managing it. Both
65482
+ * annotations can apply to one row; adoption is printed first because it is
65483
+ * why the row exists at all, where the routing describes how it will be
65484
+ * carried out.
65343
65485
  */
65344
- function renderChangeLines(changes, logFn, ccApiRoutes) {
65486
+ function renderChangeLines(changes, logFn, ccApiRoutes, adoptedOrphans) {
65345
65487
  let createCount = 0;
65346
65488
  let updateCount = 0;
65347
65489
  let deleteCount = 0;
65490
+ const adopted = new Set(adoptedOrphans ?? []);
65491
+ const annotateAdoption = (logicalId) => adopted.has(logicalId) ? " [adopted from a rollback orphan]" : "";
65348
65492
  const annotateRouting = (logicalId) => {
65349
65493
  const props = ccApiRoutes?.get(logicalId);
65350
65494
  if (!props || props.length === 0) return "";
@@ -65354,11 +65498,11 @@ function renderChangeLines(changes, logFn, ccApiRoutes) {
65354
65498
  for (const [logicalId, change] of changes.entries()) switch (change.changeType) {
65355
65499
  case "CREATE":
65356
65500
  createCount++;
65357
- logFn(` [+] ${logicalId} (${change.resourceType})${annotateRouting(logicalId)}`);
65501
+ logFn(` [+] ${logicalId} (${change.resourceType})${annotateAdoption(logicalId)}${annotateRouting(logicalId)}`);
65358
65502
  break;
65359
65503
  case "UPDATE":
65360
65504
  updateCount++;
65361
- logFn(` [~] ${logicalId} (${change.resourceType})${annotateRouting(logicalId)}`);
65505
+ logFn(` [~] ${logicalId} (${change.resourceType})${annotateAdoption(logicalId)}${annotateRouting(logicalId)}`);
65362
65506
  if (change.propertyChanges && change.propertyChanges.length > 0) for (const propChange of change.propertyChanges) {
65363
65507
  const requiresReplace = propChange.requiresReplacement ? " [requires replacement]" : "";
65364
65508
  const propagated = propChange.replacementPropagated ? " [replacement propagated]" : "";
@@ -65466,12 +65610,18 @@ function renderOutputChangeLines(outputChanges, logFn) {
65466
65610
  * (the A4 `state show --show-nested` convention, full `~`-joined name).
65467
65611
  */
65468
65612
  function renderDiffTree(node, isRoot, logFn) {
65469
- if (nodeHasChanges(node)) {
65470
- logFn(isRoot ? `\nStack ${node.stackName}:` : `\nNested stack: ${node.displayName}`);
65471
- const { create, update, delete: del } = renderChangeLines(node.changes, logFn, node.ccApiRoutes);
65613
+ const hasChanges = nodeHasChanges(node);
65614
+ if (hasChanges || node.blocking.length > 0) logFn(isRoot ? `\nStack ${node.stackName}:` : `\nNested stack: ${node.displayName}`);
65615
+ if (hasChanges) {
65616
+ const { create, update, delete: del } = renderChangeLines(node.changes, logFn, node.ccApiRoutes, node.adoptedOrphans);
65472
65617
  const outputs = renderOutputChangeLines(node.outputChanges, logFn);
65473
65618
  logFn(`\n${create} to create, ${update} to update, ${del} to delete`);
65474
65619
  if (outputs.add + outputs.change + outputs.remove > 0) logFn(`${outputs.add} output(s) to add, ${outputs.change} to change, ${outputs.remove} to remove`);
65620
+ if (node.adoptedOrphans.length > 0) logFn(`${node.adoptedOrphans.length} resource(s) to adopt from a previous rollback: ${node.adoptedOrphans.map(stripControlChars).join(", ")}`);
65621
+ }
65622
+ if (node.blocking.length > 0) {
65623
+ logFn("\n Blocking (cdkd deploy will refuse):");
65624
+ for (const reason of node.blocking) logFn(` ! ${stripControlChars(reason)}`);
65475
65625
  }
65476
65626
  for (const child of node.children) renderDiffTree(child, false, logFn);
65477
65627
  }
@@ -65493,6 +65643,35 @@ var DiffDetectedError = class DiffDetectedError extends CdkdError {
65493
65643
  }
65494
65644
  };
65495
65645
  /**
65646
+ * Signals that the preview completed and `cdkd deploy` would REFUSE to start
65647
+ * (issue go-to-k/cdkd#2943) — today, a rollback-orphan record whose physical
65648
+ * id another cdkd stack already manages.
65649
+ *
65650
+ * Exit **3**, and neither 1 nor 2, for reasons that are about what a caller
65651
+ * can conclude:
65652
+ *
65653
+ * - **1** is `--fail`'s "a change was detected". A refusal is not a change,
65654
+ * and merging them would make a CI job that gates on drift report the same
65655
+ * code for "there is work to do" and "the work cannot begin".
65656
+ * - **2** is this CLI's partial-failure family, documented as "work
65657
+ * completed, re-running typically resolves it" (`docs/cli-reference.md`).
65658
+ * A refusal is the opposite: re-running changes nothing until a human
65659
+ * resolves the ownership conflict.
65660
+ *
65661
+ * It is NOT silent: unlike `--fail`, this is not a flag the user opted into,
65662
+ * so the reason has to be visible even when the diff report scrolled away.
65663
+ * The `Blocking` section already printed the per-record detail; this adds the
65664
+ * one line that says the run cannot proceed.
65665
+ */
65666
+ var DeployRefusalPreviewError = class DeployRefusalPreviewError extends CdkdError {
65667
+ exitCode = 3;
65668
+ constructor(count) {
65669
+ super(`cdkd deploy would refuse to start: ${count} blocking condition(s) reported above. Resolve them, or run \`cdkd state\` to inspect the conflicting stack.`, "DEPLOY_REFUSAL_PREVIEW");
65670
+ this.name = "DeployRefusalPreviewError";
65671
+ Object.setPrototypeOf(this, DeployRefusalPreviewError.prototype);
65672
+ }
65673
+ };
65674
+ /**
65496
65675
  * Diff command implementation
65497
65676
  */
65498
65677
  async function diffCommand(stacks, options) {
@@ -65553,6 +65732,34 @@ async function diffCommand(stacks, options) {
65553
65732
  const diffProviderRegistry = new ProviderRegistry();
65554
65733
  registerAllProviders(diffProviderRegistry);
65555
65734
  const canonicalizeProperties = makeCanonicalizePropertiesFn(diffProviderRegistry);
65735
+ const previewOrphanAdoption = async (state, effectiveTemplate, orphanStackName, orphanRegion) => {
65736
+ const outcome = await planOrphanAdoption({
65737
+ records: state.orphans ?? [],
65738
+ managedLogicalIds: new Set(Object.keys(state.resources)),
65739
+ template: effectiveTemplate,
65740
+ stackName: orphanStackName,
65741
+ region: orphanRegion,
65742
+ getProvider: (resourceType, provisionedBy) => diffProviderRegistry.getProviderFor({
65743
+ resourceType,
65744
+ provisionedBy
65745
+ }).provider,
65746
+ nameProperties: (resourceType) => {
65747
+ const property = explicitNamePropertyFor(resourceType);
65748
+ return property ? [property] : [];
65749
+ },
65750
+ readSiblingClaims: makeSiblingClaimReader({
65751
+ stateBackend,
65752
+ selfStackName: orphanStackName,
65753
+ selfRegion: orphanRegion,
65754
+ logger
65755
+ }),
65756
+ logger
65757
+ });
65758
+ return {
65759
+ adopted: outcome.adopted,
65760
+ refusals: outcome.refusals
65761
+ };
65762
+ };
65556
65763
  const recursive = options.recursive ?? false;
65557
65764
  const resolveAssetRedirect = createAssetRedirectResolver({
65558
65765
  stateBackend,
@@ -65581,17 +65788,20 @@ async function diffCommand(stacks, options) {
65581
65788
  diffCalculator,
65582
65789
  canonicalizeProperties,
65583
65790
  ...assetRedirect && { assetRedirect },
65584
- ...options.cfnFallback === false && { cfnFallback: false }
65791
+ ...options.cfnFallback === false && { cfnFallback: false },
65792
+ previewOrphanAdoption
65585
65793
  }));
65586
65794
  }
65587
65795
  if (options.json) process.stdout.write(`${JSON.stringify(trees.map(diffTreeToJson), null, 2)}\n`);
65588
65796
  else for (const tree of trees) {
65589
- if (!treeHasChanges(tree)) {
65797
+ if (!treeIsWorthRendering(tree)) {
65590
65798
  logger.info(`\n✓ No changes detected for stack ${tree.stackName}`);
65591
65799
  continue;
65592
65800
  }
65593
65801
  renderDiffTree(tree, true, (msg) => logger.info(msg));
65594
65802
  }
65803
+ const blockingCount = trees.reduce((n, tree) => n + countBlocking(tree), 0);
65804
+ if (blockingCount > 0) throw new DeployRefusalPreviewError(blockingCount);
65595
65805
  if (options.fail && trees.some(treeHasChanges)) throw new DiffDetectedError();
65596
65806
  } finally {
65597
65807
  awsClients.destroy();
@@ -66109,10 +66319,9 @@ async function scrubCommand(stacks, options) {
66109
66319
  * (issue #2005) — the outputs' own map plus every resource's, filtered to the
66110
66320
  * values long enough to be a safe needle.
66111
66321
  *
66112
- * Used ONLY by {@link redactUnaccountedOutputs}, which is why the union is built
66113
- * here rather than kept as the pass's input everywhere: `outputSecrets` and
66114
- * `perResourceSecrets` are deliberately SEPARATE bags so one resource's secret
66115
- * value cannot rewrite another resource's coinciding literal (the collision the
66322
+ * Built here rather than kept as each pass's input everywhere `outputSecrets`
66323
+ * and `perResourceSecrets` are deliberately SEPARATE bags so one resource's
66324
+ * secret value cannot rewrite another's coinciding literal (the collision the
66116
66325
  * deploy engine's `perResourceSecrets` doc describes), and widening the bag the
66117
66326
  * RESOURCE walk uses would re-open exactly that.
66118
66327
  *
@@ -66198,9 +66407,10 @@ async function scrubCommand(stacks, options) {
66198
66407
  * command can. Refusing the substring arm here would trade the irreversible
66199
66408
  * failure for the reversible one in the wrong direction.
66200
66409
  */
66201
- function allRecordedSecrets(outputSecrets, perResourceSecrets) {
66410
+ function allRecordedSecrets(outputSecrets, perResourceSecrets, orphanSecrets) {
66202
66411
  const union = /* @__PURE__ */ new Map();
66203
66412
  for (const recorded of perResourceSecrets.values()) for (const [value, expression] of recorded) union.set(value, expression);
66413
+ for (const recorded of orphanSecrets.values()) for (const [value, expression] of recorded) union.set(value, expression);
66204
66414
  for (const [value, expression] of outputSecrets) union.set(value, expression);
66205
66415
  for (const value of union.keys()) if (value.length < 4) union.delete(value);
66206
66416
  return union;
@@ -67721,6 +67931,13 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
67721
67931
  if (acquired) await lockManager.acquireLockWithRetry(stack.stackName, region, void 0, "scrub");
67722
67932
  const perResourceSecrets = /* @__PURE__ */ new Map();
67723
67933
  const outputSecrets = /* @__PURE__ */ new Map();
67934
+ /**
67935
+ * Needles derived from each rollback-orphan record's OWN recorded bag
67936
+ * (issue go-to-k/cdkd#2943), keyed by logical id — per-record for the same
67937
+ * reason `perResourceSecrets` is per-resource: one record's secret must not
67938
+ * rewrite another's coinciding literal.
67939
+ */
67940
+ const orphanSecrets = /* @__PURE__ */ new Map();
67724
67941
  const prePassFindings = { unverifiable: [] };
67725
67942
  try {
67726
67943
  const loaded = await stateBackend.getState(stack.stackName, region);
@@ -67801,6 +68018,27 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
67801
68018
  }
67802
68019
  perResourceTemplateProps.set(logicalId, templateResource.Properties);
67803
68020
  }
68021
+ for (const record of state.orphans ?? []) {
68022
+ const recordedSecretValues = /* @__PURE__ */ new Map();
68023
+ orphanSecrets.set(record.logicalId, recordedSecretValues);
68024
+ const resolveInput = await pinCrossRegionSecrets({
68025
+ properties: record.state.properties,
68026
+ attributes: record.state.attributes ?? {},
68027
+ observedProperties: record.state.observedProperties ?? {}
68028
+ }, stack.stackName, {
68029
+ stackRegion: region,
68030
+ producerRegions,
68031
+ resolvers,
68032
+ recordedSecretValues,
68033
+ origin: `orphan record '${record.logicalId}'`
68034
+ });
68035
+ try {
68036
+ await resolver.resolve(resolveInput, resolverContext(recordedSecretValues));
68037
+ } catch (err) {
68038
+ if (isRegionAmbiguousRefusal(err)) throw err;
68039
+ logger.debug(`Resolution of orphan record ${record.logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
68040
+ }
68041
+ }
67804
68042
  for (const [name, output] of Object.entries(templateOutputs)) {
67805
68043
  const declaredExportName = output.Export?.Name;
67806
68044
  let exportName = declaredExportName;
@@ -67871,7 +68109,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
67871
68109
  secretBearingKeys.push(key);
67872
68110
  logger.warn(secretBearingStateKeyWarning(stack.stackName, display));
67873
68111
  }
67874
- const totalSecrets = outputSecrets.size + [...perResourceSecrets.values()].reduce((n, m) => n + m.size, 0);
68112
+ const totalSecrets = outputSecrets.size + [...perResourceSecrets.values()].reduce((n, m) => n + m.size, 0) + [...orphanSecrets.values()].reduce((n, m) => n + m.size, 0);
67875
68113
  if (totalSecrets === 0) return {
67876
68114
  recordsChanged: 0,
67877
68115
  secretsFound: 0,
@@ -67894,13 +68132,27 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
67894
68132
  if (JSON.stringify(scrubbed) !== JSON.stringify(record)) recordsChanged++;
67895
68133
  newResources[logicalId] = scrubbed;
67896
68134
  }
67897
- const newOutputs = redactUnaccountedOutputs(outputSecrets.size > 0 ? redactSecretsForState(state.outputs, outputSecrets, outputsSourceUntrusted ? void 0 : outputsTemplateSource, TEMPLATE_SOURCED_RULES) : state.outputs, state.outputs, accountedOutputKeys, allRecordedSecrets(outputSecrets, perResourceSecrets));
68135
+ const newOrphans = (state.orphans ?? []).map((record) => {
68136
+ const own = orphanSecrets.get(record.logicalId);
68137
+ const needles = new Map(allRecordedSecrets(outputSecrets, perResourceSecrets, orphanSecrets));
68138
+ for (const [plaintext, expression] of own ?? []) needles.set(plaintext, expression);
68139
+ if (needles.size === 0) return record;
68140
+ const scrubbed = scrubResourceRecord(record.state, needles, void 0, STATE_SOURCED_CROSS_GENERATION_RULES);
68141
+ if (JSON.stringify(scrubbed) === JSON.stringify(record.state)) return record;
68142
+ recordsChanged++;
68143
+ return {
68144
+ ...record,
68145
+ state: scrubbed
68146
+ };
68147
+ });
68148
+ const newOutputs = redactUnaccountedOutputs(outputSecrets.size > 0 ? redactSecretsForState(state.outputs, outputSecrets, outputsSourceUntrusted ? void 0 : outputsTemplateSource, TEMPLATE_SOURCED_RULES) : state.outputs, state.outputs, accountedOutputKeys, allRecordedSecrets(outputSecrets, perResourceSecrets, orphanSecrets));
67898
68149
  if (JSON.stringify(newOutputs) !== JSON.stringify(state.outputs)) recordsChanged++;
67899
68150
  if (recordsChanged > 0 && !opts.dryRun) {
67900
68151
  const { skippedOutputs: _droppedByScrub, ...carriedState } = state;
67901
68152
  const nextState = {
67902
68153
  ...carriedState,
67903
68154
  resources: newResources,
68155
+ ...state.orphans !== void 0 && { orphans: newOrphans },
67904
68156
  outputs: newOutputs,
67905
68157
  lastModified: Date.now()
67906
68158
  };
@@ -67915,7 +68167,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
67915
68167
  exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
67916
68168
  };
67917
68169
  } catch (err) {
67918
- throw maskSecretsInError(err, allRecordedSecrets(outputSecrets, perResourceSecrets));
68170
+ throw maskSecretsInError(err, allRecordedSecrets(outputSecrets, perResourceSecrets, orphanSecrets));
67919
68171
  } finally {
67920
68172
  if (acquired) await lockManager.releaseLock(stack.stackName, region).catch((err) => {
67921
68173
  logger.warn(`Failed to release lock for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
@@ -68826,6 +69078,17 @@ function stripWalk(value) {
68826
69078
  //#endregion
68827
69079
  //#region src/cli/commands/drift.ts
68828
69080
  /**
69081
+ * The schema version at which `ResourceState.observedBaselineRefused` arrived.
69082
+ * A record BELOW it could not carry a refusal, which is what
69083
+ * {@link warnIfPreV10BaselineGap} keys on (issue
69084
+ * [#3011](https://github.com/go-to-k/cdkd/issues/3011)).
69085
+ *
69086
+ * A literal rather than `STATE_SCHEMA_VERSION_CURRENT`: this is a FIXED
69087
+ * historical boundary, and binding it to "current" would silently stop warning
69088
+ * about v10 records on the next bump — the population it names does not move.
69089
+ */
69090
+ const PRE_V10_BASELINE_WARNING_FLOOR = 10;
69091
+ /**
68829
69092
  * Route one outcome to the handler for its variant.
68830
69093
  *
68831
69094
  * The mechanism issue [#2135](https://github.com/go-to-k/cdkd/issues/2135) asks
@@ -69047,6 +69310,10 @@ const UNCOMPARED_REASONS = {
69047
69310
  kind: "unknown",
69048
69311
  phrase: "not compared AT ALL: the read or comparison failed"
69049
69312
  },
69313
+ baselineRefused: {
69314
+ kind: "unknown",
69315
+ phrase: "not compared AT ALL: a `cdkd import` run refused to capture their observed baseline"
69316
+ },
69050
69317
  refused: {
69051
69318
  kind: "unknown",
69052
69319
  phrase: "only PARTIALLY compared: cdkd refused to resolve a dynamic reference their state records"
@@ -69592,7 +69859,8 @@ function notComparedReason(cause) {
69592
69859
  return {
69593
69860
  refused: "cdkd refused to resolve a dynamic reference its state records (spell the reference as a full ARN, which names its region)",
69594
69861
  unresolvedToken: "its state records a `{{resolve:...}}` spelling cdkd resolves for nobody (permanent; a re-run cannot clear it)",
69595
- readFailed: "the read or comparison threw, so NONE of its properties were compared"
69862
+ readFailed: "the read or comparison threw, so NONE of its properties were compared",
69863
+ baselineRefused: "a `cdkd import` run refused to capture its observed baseline, so the only baseline available is the recorded properties that refusal already found untrustworthy (deploy a change to this resource to restore one)"
69596
69864
  }[cause];
69597
69865
  }
69598
69866
  /**
@@ -69980,6 +70248,15 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
69980
70248
  });
69981
70249
  continue;
69982
70250
  }
70251
+ if (resource.observedBaselineRefused === true) {
70252
+ outcomes.push({
70253
+ kind: "notCompared",
70254
+ logicalId,
70255
+ resourceType: resource.resourceType,
70256
+ notComparedCause: "baselineRefused"
70257
+ });
70258
+ continue;
70259
+ }
69983
70260
  try {
69984
70261
  let aws;
69985
70262
  if (provider.readCurrentState) aws = await provider.readCurrentState(resource.physicalId, logicalId, resource.resourceType, resource.properties ?? {}, buildReadCurrentStateContext(state, logicalId));
@@ -70106,17 +70383,56 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
70106
70383
  continue;
70107
70384
  }
70108
70385
  }
70386
+ const warnings = warnIfPreV10BaselineGap(state, outcomes, logger);
70109
70387
  return {
70110
70388
  stackName,
70111
70389
  region,
70112
70390
  outcomes,
70113
70391
  state,
70114
70392
  etag: result.etag,
70115
- migrationPending: result.migrationPending ?? false
70393
+ migrationPending: result.migrationPending ?? false,
70394
+ warnings
70116
70395
  };
70117
70396
  });
70118
70397
  }
70119
70398
  /**
70399
+ * Warn once for a stack whose state PREDATES the refused-baseline marker
70400
+ * (`ResourceState.observedBaselineRefused`, state schema v10) and still has a
70401
+ * resource without a drift baseline (issue
70402
+ * [#3011](https://github.com/go-to-k/cdkd/issues/3011)).
70403
+ *
70404
+ * Both conjuncts are load-bearing, and neither is a heuristic:
70405
+ *
70406
+ * - `version < 10` is not "probably imported" — it is the exact statement that
70407
+ * this record could not carry a refusal even if one was made. A v10 record
70408
+ * that omits the field is a record nothing refused, and warning there would
70409
+ * be false.
70410
+ * - the missing baseline is what makes the warning ACTIONABLE. A pre-v10 stack
70411
+ * where every resource has a baseline has nothing for drift to fall back to
70412
+ * `properties` for, so it cannot hit the disclosure.
70413
+ *
70414
+ * Exported for unit testing -- internal to the drift flow otherwise.
70415
+ */
70416
+ function warnIfPreV10BaselineGap(state, outcomes, logger) {
70417
+ if ((state.version ?? 1) >= PRE_V10_BASELINE_WARNING_FLOOR) return [];
70418
+ const compared = new Set(outcomes.flatMap((o) => matchOutcome(o, {
70419
+ drifted: (d) => [d.logicalId],
70420
+ clean: (c) => [c.logicalId],
70421
+ notCompared: (n) => [n.logicalId],
70422
+ unsupported: () => [],
70423
+ skipped: () => []
70424
+ })));
70425
+ const affected = Object.entries(state.resources ?? {}).filter(([id, r]) => r.observedProperties === void 0 && compared.has(id)).map(([id]) => id);
70426
+ if (affected.length === 0) return [];
70427
+ const shown = affected.slice(0, 10).join(", ");
70428
+ const rest = affected.length > 10 ? `, and ${affected.length - 10} more` : "";
70429
+ const finding = `${state.stackName} (${state.region ?? "unknown region"}): this stack's state predates cdkd's refused-baseline marker (schema v${state.version ?? 1}; the marker arrived in v${PRE_V10_BASELINE_WARNING_FLOOR}), and ${affected.length} of its compared resource(s) have no drift baseline: ${shown}${rest}. If any were adopted by 'cdkd import' and that import REFUSED to capture a baseline, cdkd cannot tell — so it compares them against their recorded properties, which can hold a placeholder the deployed stack never used and can surface a live secret in this report. Re-run 'cdkd import' for this stack to put the record right.`;
70430
+ const note = " Note: ANY write to this stack's state — including this run's --accept / --revert, or a deploy that changes nothing about the resources above — re-stamps it at the current schema version and SILENCES this warning without fixing those records. Only a re-import, or a deploy that actually CHANGES a listed resource, repairs one.";
70431
+ logger.warn(finding);
70432
+ logger.warn(note);
70433
+ return [finding, note];
70434
+ }
70435
+ /**
70120
70436
  * Set a value at a dotted path inside a plain object, creating intermediate
70121
70437
  * objects as needed. Mirrors `lodash.set` for the subset of paths the drift
70122
70438
  * comparator actually emits — dotted nested keys, no array indices.
@@ -70226,6 +70542,10 @@ async function runAccept(reports, stateBackend, stateConfig, awsClients, options
70226
70542
  for (const outcome of driftedOutcomes) {
70227
70543
  const existing = resources[outcome.logicalId];
70228
70544
  if (!existing) continue;
70545
+ if (existing.observedBaselineRefused === true) {
70546
+ logger.warn(` ! ${report.stackName}/${outcome.logicalId} (${outcome.resourceType}): NOT accepted — a 'cdkd import' run refused to capture this resource's observed-properties baseline, because its recorded properties can no longer position the secret redaction. Accepting would write the AWS readback into those properties, which can persist a resolved secret into state.json in plaintext. Deploy a change to this resource to restore a baseline first.`);
70547
+ continue;
70548
+ }
70229
70549
  const hasObserved = existing.observedProperties !== void 0;
70230
70550
  const baselineSource = hasObserved ? existing.observedProperties : existing.properties ?? {};
70231
70551
  const newBaseline = JSON.parse(JSON.stringify(baselineSource));
@@ -71389,6 +71709,11 @@ async function runRevert(reports, providerRegistry, stateBackend, stateConfig, a
71389
71709
  logger.error(` ✗ ${report.stackName}/${outcome.logicalId} (${outcome.resourceType}): resource missing from state; skipped.`);
71390
71710
  return;
71391
71711
  }
71712
+ if (stateResource.observedBaselineRefused === true) {
71713
+ totalUnresolvable++;
71714
+ logger.warn(` ! ${report.stackName}/${outcome.logicalId} (${outcome.resourceType}): NOT reverted — a 'cdkd import' run refused to capture this resource's observed-properties baseline, so the only baseline available is its recorded properties, which the refusal already found untrustworthy. Reverting from them could overwrite a live value (a resolved secret among them) with a placeholder the deployed stack never used. Deploy a change to this resource to restore a baseline first.`);
71715
+ return;
71716
+ }
71392
71717
  const provider = providerRegistry.getProviderFor({
71393
71718
  resourceType: outcome.resourceType,
71394
71719
  provisionedBy: stateResource.provisionedBy
@@ -71571,6 +71896,10 @@ function printAcceptPlan(reports, out) {
71571
71896
  const lines = [];
71572
71897
  let plannedWrites = 0;
71573
71898
  for (const o of drifted) {
71899
+ if (report.state.resources[o.logicalId]?.observedBaselineRefused === true) {
71900
+ lines.push(` ~ ${o.logicalId} (${o.resourceType})\n SKIPPED — a 'cdkd import' run refused this resource's observed-properties baseline; accepting would write the AWS readback into properties it already found untrustworthy. Deploy a change to this resource first.\n`);
71901
+ continue;
71902
+ }
71574
71903
  lines.push(` ~ ${o.logicalId} (${o.resourceType})\n`);
71575
71904
  for (const change of o.changes) {
71576
71905
  const refusal = acceptRefusalReason(change, o.maskedPaths);
@@ -71603,6 +71932,10 @@ function printRevertPlan(reports, out) {
71603
71932
  if (drifted.length === 0) continue;
71604
71933
  out.write(`\nPlan (--revert): push cdkd state values back into AWS for ${report.stackName} (${report.region}):\n`);
71605
71934
  for (const o of drifted) {
71935
+ if (report.state.resources[o.logicalId]?.observedBaselineRefused === true) {
71936
+ out.write(` ! ${o.logicalId} (${o.resourceType}): NOT reverted — a 'cdkd import' run refused this resource's observed-properties baseline, so the only baseline available is the one that refusal already found untrustworthy. Deploy a change to this resource first.\n`);
71937
+ continue;
71938
+ }
71606
71939
  const word = o.changes.length === 1 ? "property path" : "property paths";
71607
71940
  out.write(` → provider.update on ${o.logicalId} (${o.resourceType}): revert ${o.changes.length} ${word}\n`);
71608
71941
  for (const change of o.changes) out.write(` ${change.path}: ${formatScalar(change.awsValue)} -> ${formatScalar(change.stateValue)}\n`);
@@ -71709,12 +72042,13 @@ function writeJsonReport(reports) {
71709
72042
  const notCompared = notComparedOutcomes(r).map(({ outcome, cause }) => ({
71710
72043
  logicalId: outcome.logicalId,
71711
72044
  type: outcome.resourceType,
71712
- referencesUnresolved: cause !== "readFailed",
72045
+ referencesUnresolved: cause !== "readFailed" && cause !== "baselineRefused",
71713
72046
  cause
71714
72047
  }));
71715
72048
  return {
71716
72049
  stack: r.stackName,
71717
72050
  region: r.region,
72051
+ ...r.warnings.length > 0 && { warnings: r.warnings },
71718
72052
  drifted,
71719
72053
  clean,
71720
72054
  notSupported,
@@ -71753,7 +72087,7 @@ function writeHumanReport(reports) {
71753
72087
  const checked = inspected - notCompared.length;
71754
72088
  if (drifted.length === 0) {
71755
72089
  if (notCompared.length === 0 && checked === 0 && report.outcomes.length > 0) process.stdout.write(`⚠ ${report.stackName} (${report.region}): no drift detected, but NOTHING was compared — 0 of ${report.outcomes.length} resource${report.outcomes.length === 1 ? "" : "s"} checked (${unsupported.length} unsupported, ${skippedCount} skipped)\n`);
71756
- else if (notCompared.length > 0) process.stdout.write(`⚠ ${report.stackName} (${report.region}): no drift detected, but ${checked} of ${inspected} resource${inspected === 1 ? "" : "s"} fully checked ` + (notCompared.some((n) => n.cause === "readFailed") ? `(${notCompared.length} not fully compared), ` : `(${notCompared.length} only partially compared), `) + `${unsupported.length} unsupported\n`);
72090
+ else if (notCompared.length > 0) process.stdout.write(`⚠ ${report.stackName} (${report.region}): no drift detected, but ${checked} of ${inspected} resource${inspected === 1 ? "" : "s"} fully checked ` + (notCompared.some((n) => n.cause === "readFailed" || n.cause === "baselineRefused") ? `(${notCompared.length} not fully compared), ` : `(${notCompared.length} only partially compared), `) + `${unsupported.length} unsupported\n`);
71757
72091
  else process.stdout.write(`✓ ${report.stackName} (${report.region}): no drift detected (${checked} resource${checked === 1 ? "" : "s"} checked, ${unsupported.length} unsupported)\n`);
71758
72092
  } else {
71759
72093
  const word = drifted.length === 1 ? "resource" : "resources";
@@ -71768,9 +72102,12 @@ function writeHumanReport(reports) {
71768
72102
  }
71769
72103
  }
71770
72104
  if (notCompared.length > 0) {
71771
- const readFailed = notCompared.filter((n) => n.cause === "readFailed").length;
71772
- const referenceCaused = notCompared.length - readFailed;
71773
- process.stdout.write(readFailed === 0 ? `\n ${notCompared.length} resource(s) only PARTIALLY compared — cdkd could not, or refused to, resolve a dynamic reference their state records, so their secret-bearing properties were NOT compared:\n` : `\n ${notCompared.length} resource(s) NOT fully compared — ${readFailed} not compared AT ALL (the read or comparison failed)` + (referenceCaused > 0 ? `, ${referenceCaused} only PARTIALLY compared (a dynamic reference cdkd could not, or refused to, resolve)` : "") + `:\n`);
72105
+ const notComparedAtAll = notCompared.filter((n) => n.cause === "readFailed" || n.cause === "baselineRefused").length;
72106
+ const readFailedCount = notCompared.filter((n) => n.cause === "readFailed").length;
72107
+ const baselineRefusedCount = notCompared.filter((n) => n.cause === "baselineRefused").length;
72108
+ const referenceCaused = notCompared.length - notComparedAtAll;
72109
+ const atAllCause = [...readFailedCount > 0 ? ["the read or comparison failed"] : [], ...baselineRefusedCount > 0 ? ["an import refused their observed baseline"] : []].join("; ");
72110
+ process.stdout.write(notComparedAtAll === 0 ? `\n ${notCompared.length} resource(s) only PARTIALLY compared — cdkd could not, or refused to, resolve a dynamic reference their state records, so their secret-bearing properties were NOT compared:\n` : `\n ${notCompared.length} resource(s) NOT fully compared — ${notComparedAtAll} not compared AT ALL (${atAllCause})` + (referenceCaused > 0 ? `, ${referenceCaused} only PARTIALLY compared (a dynamic reference cdkd could not, or refused to, resolve)` : "") + `:\n`);
71774
72111
  for (const { outcome, cause } of notCompared) process.stdout.write(` ! ${outcome.logicalId} (${outcome.resourceType}) — ${notComparedReason(cause)}\n`);
71775
72112
  }
71776
72113
  if (unsupported.length > 0) {
@@ -75015,9 +75352,18 @@ function reportDriftBaselineGaps(state, logger) {
75015
75352
  logger.warn(`cdkd state schema is v${state.version} (pre-observedProperties). cdkd drift cannot reliably compare against AWS for this stack; the next \`cdk deploy\` after migration may surface spurious changes if AWS has drifted from the template. Run \`cdkd state refresh-observed ${state.stackName}\` (or any redeploy) before export to capture an AWS-current baseline.`);
75016
75353
  return;
75017
75354
  }
75018
- logger.warn(`${missing.length} of ${entries.length} resource(s) in cdkd state lack an AWS-current baseline (observedProperties). cdkd drift may produce false positives for them; the next \`cdk deploy\` after migration may surface unexpected changes. Run \`cdkd state refresh-observed ${state.stackName}\` to capture a baseline before export, then \`cdkd drift\` to verify the stack matches AWS.`);
75019
- for (const [logicalId] of missing.slice(0, 10)) logger.warn(` ${logicalId}`);
75020
- if (missing.length > 10) logger.warn(` ... and ${missing.length - 10} more`);
75355
+ const refused = missing.filter(([, r]) => r.observedBaselineRefused === true);
75356
+ const refreshable = missing.filter(([, r]) => r.observedBaselineRefused !== true);
75357
+ if (refreshable.length > 0) {
75358
+ logger.warn(`${refreshable.length} of ${entries.length} resource(s) in cdkd state lack an AWS-current baseline (observedProperties). cdkd drift may produce false positives for them; the next \`cdk deploy\` after migration may surface unexpected changes. Run \`cdkd state refresh-observed ${state.stackName}\` to capture a baseline before export, then \`cdkd drift\` to verify the stack matches AWS.`);
75359
+ for (const [logicalId] of refreshable.slice(0, 10)) logger.warn(` ${logicalId}`);
75360
+ if (refreshable.length > 10) logger.warn(` ... and ${refreshable.length - 10} more`);
75361
+ }
75362
+ if (refused.length > 0) {
75363
+ logger.warn(`${refused.length} of ${entries.length} resource(s) had their baseline REFUSED by a \`cdkd import\` run, because their recorded properties can no longer position the secret redaction. \`cdkd state refresh-observed\` will decline them too — capturing a readback against those properties could persist a resolved secret into state.json in plaintext. Deploy a change to each one to restore its baseline.`);
75364
+ for (const [logicalId] of refused.slice(0, 10)) logger.warn(` ${logicalId}`);
75365
+ if (refused.length > 10) logger.warn(` ... and ${refused.length - 10} more`);
75366
+ }
75021
75367
  }
75022
75368
  /**
75023
75369
  * Walk every stack other than `exportingStackName` looking for
@@ -76374,10 +76720,10 @@ async function stateResourcesCommand(stackName, options) {
76374
76720
  if (options.long) {
76375
76721
  const lines = [];
76376
76722
  for (const detail of details) {
76377
- lines.push(detail.logicalId);
76378
- lines.push(` Type: ${detail.resourceType}`);
76379
- lines.push(` PhysicalID: ${stripControlChars(detail.physicalId)}`);
76380
- lines.push(` Dependencies: ${detail.dependencies.length > 0 ? detail.dependencies.join(", ") : "(none)"}`);
76723
+ lines.push(stripControlChars(detail.logicalId));
76724
+ lines.push(` Type: ${formatAttributeValue(detail.resourceType)}`);
76725
+ lines.push(` PhysicalID: ${formatAttributeValue(detail.physicalId)}`);
76726
+ lines.push(` Dependencies: ${formatDependencyList(detail.dependencies)}`);
76381
76727
  const attrEntries = Object.entries(detail.attributes);
76382
76728
  if (attrEntries.length === 0) lines.push(" Attributes: (none)");
76383
76729
  else {
@@ -76390,15 +76736,27 @@ async function stateResourcesCommand(stackName, options) {
76390
76736
  process.stdout.write(`${lines.join("\n")}\n`);
76391
76737
  return;
76392
76738
  }
76393
- const idWidth = Math.max(...details.map((d) => d.logicalId.length));
76394
- const typeWidth = Math.max(...details.map((d) => d.resourceType.length));
76395
- for (const detail of details) process.stdout.write(`${detail.logicalId.padEnd(idWidth)} ${detail.resourceType.padEnd(typeWidth)} ${detail.physicalId}\n`);
76739
+ const rows = details.map((d) => ({
76740
+ logicalId: stripControlChars(d.logicalId),
76741
+ resourceType: formatAttributeValue(d.resourceType),
76742
+ physicalId: formatAttributeValue(d.physicalId)
76743
+ }));
76744
+ const idWidth = Math.max(...rows.map((r) => r.logicalId.length));
76745
+ const typeWidth = Math.max(...rows.map((r) => r.resourceType.length));
76746
+ for (const row of rows) process.stdout.write(`${row.logicalId.padEnd(idWidth)} ${row.resourceType.padEnd(typeWidth)} ${row.physicalId}\n`);
76396
76747
  } finally {
76397
76748
  setup.dispose();
76398
76749
  }
76399
76750
  }
76400
76751
  /**
76401
- * Render a single attribute value for the `--long` human-readable form.
76752
+ * Render one record-derived value into a human row.
76753
+ *
76754
+ * Named for its first caller and used well beyond an attribute now: a state or
76755
+ * lock record is read as an unchecked cast, so a field declared `string` can
76756
+ * hold anything, and the bare strip throws on it and empties the whole render.
76757
+ * This survives that, and for an actual string it is byte-identical to the
76758
+ * strip — which is why it is the default choice for a record FIELD, and the bare
76759
+ * strip is kept only where the value is a string by construction.
76402
76760
  *
76403
76761
  * Scalar values render as-is; objects/arrays are JSON-encoded inline so a
76404
76762
  * resource block stays compact even when an attribute is structured.
@@ -76417,16 +76775,24 @@ async function stateResourcesCommand(stackName, options) {
76417
76775
  * pretty-prints — `JSON.stringify` is called without an indent, so a newline
76418
76776
  * here is content injected by the value, not layout.
76419
76777
  *
76420
- * Only the human path routes through this; `--json` serializes the state
76421
- * record directly and stays byte-faithful, which is the same split
76422
- * `renderOutputChangeLines` makes.
76778
+ * Only the human path routes through this, which is the same split
76779
+ * `renderOutputChangeLines` makes. `--json` is the OTHER side of it rather than
76780
+ * a byte-faithful echo: `state show --json` reserializes the parsed record and
76781
+ * the lock it prints was sanitised upstream, and `state resources --json` emits
76782
+ * a constructed summary with defaults filled in. What `--json` does not do is
76783
+ * pass a value through THIS function.
76423
76784
  */
76424
76785
  function formatAttributeValue(value) {
76425
76786
  if (value === null) return "null";
76426
76787
  if (value === void 0) return "undefined";
76427
76788
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return stripControlChars(String(value));
76428
- const json = JSON.stringify(value);
76429
- return json === void 0 ? "(unserializable)" : stripControlChars(json);
76789
+ let json;
76790
+ try {
76791
+ json = JSON.stringify(value);
76792
+ } catch {
76793
+ json = void 0;
76794
+ }
76795
+ return json === void 0 ? UNSERIALIZABLE : stripControlChars(json);
76430
76796
  }
76431
76797
  /**
76432
76798
  * Render a duration in milliseconds as `1m23s` / `45s`.
@@ -76437,7 +76803,65 @@ function formatDuration(ms) {
76437
76803
  return `${Math.floor(seconds / 60)}m${seconds % 60}s`;
76438
76804
  }
76439
76805
  /**
76806
+ * Render a whole `Dependencies` row from a list that is an unchecked cast.
76807
+ *
76808
+ * It owns the `(none)` case as well as the values, so that no caller touches
76809
+ * `.length` on a value whose type it cannot trust — reading `.length` is itself
76810
+ * a throw site, and `"A".length > 0` would pass an emptiness test and then
76811
+ * throw inside `join`, which is a method of an ARRAY. So the test is
76812
+ * array-ness. Elements are rendered ONE AT A TIME through the field guard
76813
+ * rather than joined by it: an element that throws on STRING COERCION
76814
+ * (`{"toString": null}` is valid JSON, and `JSON.stringify` handles it fine)
76815
+ * throws INSIDE `join`, where no guard wrapping the result can catch it.
76816
+ *
76817
+ * ABSENT and `null` part company here, deliberately. An absent field is a
76818
+ * record saying nothing, which `(none)` states correctly; a `null` is a record
76819
+ * saying `null`, and printing `(none)` over it would claim something the record
76820
+ * does not. JSON can express `null` here and a hand-edited record can carry it,
76821
+ * so the difference is worth the surprise of seeing `Dependencies: null`.
76822
+ */
76823
+ function formatDependencyList(deps) {
76824
+ if (Array.isArray(deps)) return deps.length > 0 ? deps.map((dep) => formatAttributeValue(dep)).join(", ") : "(none)";
76825
+ return deps === void 0 ? "(none)" : formatAttributeValue(deps);
76826
+ }
76827
+ /**
76828
+ * Render `Last Modified` from a record whose `lastModified` is an unchecked cast.
76829
+ *
76830
+ * `new Date(x).toISOString()` throws a RangeError on a value it cannot date —
76831
+ * an out-of-range number, most non-numeric strings — and that empties the whole
76832
+ * render, the failure `formatAttributeValue` exists to stop everywhere else in
76833
+ * this block. `new Date` ITSELF throws a TypeError on an object whose
76834
+ * `toString` is unusable (`{"toString": null}` is valid JSON), which is why the
76835
+ * construction is inside the `try` and not only the formatting.
76836
+ *
76837
+ * Only the throw is guarded: the value still reaches `new Date` as-is, so every
76838
+ * input the previous line already dated renders the same ISO string — an ISO or
76839
+ * `YYYY-MM-DD` string, `null` and a boolean included. Anything else falls
76840
+ * through to the guard the other fields take.
76841
+ */
76842
+ function formatLastModified(value) {
76843
+ try {
76844
+ return new Date(value).toISOString();
76845
+ } catch {
76846
+ return formatAttributeValue(value);
76847
+ }
76848
+ }
76849
+ /**
76440
76850
  * Render lock metadata for the `state show` block.
76851
+ *
76852
+ * This row takes NO display guard — `Version` is the other, for its own reason —
76853
+ * and here the reason is upstream: `LockManager.getLockRecord` already passes
76854
+ * `owner` and `operation` through `displaySafe`, which coerces and replaces the
76855
+ * whole control class with spaces. They arrive here as control-free strings, so
76856
+ * a guard would be redundant — and a test for one could only be written by
76857
+ * mocking the read that sanitises them, which would pin nothing about the real
76858
+ * path. `displaySafe`'s own `String(value)` is unguarded, so a value that throws
76859
+ * on coercion fails THERE, before this function; that is go-to-k/cdkd#2947 and
76860
+ * not something a guard here could catch either.
76861
+ *
76862
+ * `expiresAt` is declared a number and is not guaranteed to be one, but it
76863
+ * reaches the row only through subtraction and `formatDuration`, so no character
76864
+ * the record carries can survive into the output either.
76441
76865
  */
76442
76866
  function formatLockSummary(lockInfo) {
76443
76867
  if (!lockInfo) return "unlocked";
@@ -76459,8 +76883,8 @@ function createStateResourcesCommand() {
76459
76883
  * `cdkd state show <stack>` command implementation
76460
76884
  *
76461
76885
  * Renders the full state record for one stack: stack-level metadata, lock
76462
- * status, outputs, and every resource (including properties). The deepest /
76463
- * most verbose `state` subcommand — use `state list` / `state resources` for
76886
+ * status, outputs, skipped outputs, and every resource (including properties).
76887
+ * The deepest / most verbose `state` subcommand — use `state list` / `state resources` for
76464
76888
  * lighter inspection.
76465
76889
  *
76466
76890
  * When the same stack name has state in multiple regions, requires
@@ -76473,8 +76897,9 @@ function createStateResourcesCommand() {
76473
76897
  * - `--show-nested` (issue #555 A4): when the stack contains
76474
76898
  * `AWS::CloudFormation::Stack` rows, recursively load every child state
76475
76899
  * record (`cdkd/<parent>~<childLogicalId>/<region>/state.json`) and append
76476
- * its block after the parent's. No-op when the stack has no nested
76477
- * children.
76900
+ * its block after the parent's. With no nested children the rendered
76901
+ * blocks are the same, though a `Skipped outputs:` explanation moves to
76902
+ * the end of the tree.
76478
76903
  */
76479
76904
  async function stateShowCommand(stackName, options) {
76480
76905
  const logger = getLogger();
@@ -76503,27 +76928,82 @@ async function stateShowCommand(stackName, options) {
76503
76928
  }, null, 2)}\n`);
76504
76929
  return;
76505
76930
  }
76506
- process.stdout.write(`${renderStateBlock(stateResult.state, lockInfo).join("\n")}\n`);
76931
+ process.stdout.write(`${renderStateBlock(stateResult.state, lockInfo, true).join("\n")}\n`);
76507
76932
  } finally {
76508
76933
  setup.dispose();
76509
76934
  }
76510
76935
  }
76511
76936
  /**
76937
+ * How much of a `skippedOutputs` digest the text view shows. A prefix
76938
+ * DIFFERENCE proves two records differ, which is what a reader wants at a
76939
+ * glance; a prefix MATCH proves nothing, so an exact comparison belongs to
76940
+ * `--json`, which carries the digest whole.
76941
+ */
76942
+ const SKIPPED_DIGEST_PREVIEW_LEN = 12;
76943
+ /** What `formatAttributeValue` prints for a value `JSON.stringify` refuses. */
76944
+ const UNSERIALIZABLE = "(unserializable)";
76945
+ /**
76946
+ * The `skippedOutputs` rows a state record owes, sorted by key.
76947
+ */
76948
+ function sortedSkippedOutputs(state) {
76949
+ return Object.entries(state.skippedOutputs ?? {}).sort(([a], [b]) => a.localeCompare(b));
76950
+ }
76951
+ /** Whether one record renders the block. The ONE copy of that condition. */
76952
+ function rendersSkippedBlock(state) {
76953
+ return Object.keys(state.skippedOutputs ?? {}).length > 0;
76954
+ }
76955
+ /**
76956
+ * The explanation printed under a `Skipped outputs:` block.
76957
+ *
76958
+ * At column ZERO, unlike the rows it follows. At the rows' two-space indent a
76959
+ * reader — or a `grep '^ '` — takes these lines for another key. It also
76960
+ * quotes `Outputs:` mid-sentence, so a test matching that as a SUBSTRING has
76961
+ * to anchor on the section's own line to mean anything.
76962
+ *
76963
+ * Emitted ONCE per render rather than per block: under `--show-nested` the
76964
+ * same explanation would otherwise repeat for every child that skipped
76965
+ * anything.
76966
+ *
76967
+ * That placement is why the opening sentence NAMES its referent rather than
76968
+ * saying "these". At the end of a tree render it follows every rendered stack
76969
+ * block, so it is not adjacent to the block it explains and a bare
76970
+ * demonstrative reads as belonging to whichever stack printed last. The
76971
+ * single-stack path still has the adjacency; the wording holds in both.
76972
+ *
76973
+ * It does not claim `Outputs:` is on the screen. That block is omitted when
76974
+ * the bag is empty, which includes the first-deploy case this record exists
76975
+ * for — every output failed, so `outputs` is `{}` while `skippedOutputs` is
76976
+ * populated.
76977
+ */
76978
+ function skippedOutputsLegend() {
76979
+ return [
76980
+ "",
76981
+ "The last deploy could not resolve the keys listed under `Skipped outputs:`",
76982
+ "above, and recorded a digest of their template inputs. While the record",
76983
+ "binds and a key is still absent from the stored outputs, `cdkd diff`",
76984
+ "previews it as ABSENT — no row, no warning. A key whose earlier value",
76985
+ "was retained is also stored under `Outputs:` here, where the record does",
76986
+ "NOT suppress it: the ordinary rules apply, up to `cdkd diff` suppressing",
76987
+ "its whole Outputs section if the key still cannot resolve.",
76988
+ "Binding rule: `bindingSkippedOutputs` in src/analyzer/skipped-outputs.ts."
76989
+ ];
76990
+ }
76991
+ /**
76512
76992
  * Render one stack's state record as a human-readable multi-line block —
76513
76993
  * the shared body used by both the single-stack default output and each
76514
76994
  * nested child rendered under `--show-nested`.
76515
76995
  */
76516
- function renderStateBlock(state, lockInfo) {
76996
+ function renderStateBlock(state, lockInfo, withLegend) {
76517
76997
  const lines = [];
76518
- lines.push(`Stack: ${state.stackName}`);
76519
- if (state.region) lines.push(` Region: ${state.region}`);
76998
+ lines.push(`Stack: ${formatAttributeValue(state.stackName)}`);
76999
+ if (state.region) lines.push(` Region: ${formatAttributeValue(state.region)}`);
76520
77000
  lines.push(` Version: ${state.version}`);
76521
- lines.push(` Last Modified: ${new Date(state.lastModified).toISOString()}`);
77001
+ lines.push(` Last Modified: ${formatLastModified(state.lastModified)}`);
76522
77002
  lines.push(` Lock: ${formatLockSummary(lockInfo)}`);
76523
77003
  if (state.parentStack !== void 0) {
76524
- const parentRegionStr = state.parentRegion ? ` (${state.parentRegion})` : "";
76525
- const logicalIdStr = state.parentLogicalId !== void 0 ? `, logical id: ${state.parentLogicalId}` : "";
76526
- lines.push(` Parent: ${state.parentStack}${parentRegionStr}${logicalIdStr}`);
77004
+ const parentRegionStr = state.parentRegion ? ` (${formatAttributeValue(state.parentRegion)})` : "";
77005
+ const logicalIdStr = state.parentLogicalId !== void 0 ? `, logical id: ${formatAttributeValue(state.parentLogicalId)}` : "";
77006
+ lines.push(` Parent: ${formatAttributeValue(state.parentStack)}${parentRegionStr}${logicalIdStr}`);
76527
77007
  }
76528
77008
  const outputEntries = Object.entries(state.outputs ?? {});
76529
77009
  if (outputEntries.length > 0) {
@@ -76531,18 +77011,28 @@ function renderStateBlock(state, lockInfo) {
76531
77011
  lines.push("Outputs:");
76532
77012
  for (const [k, v] of outputEntries) lines.push(` ${stripControlChars(k)}: ${formatAttributeValue(v)}`);
76533
77013
  }
77014
+ if (rendersSkippedBlock(state)) {
77015
+ lines.push("");
77016
+ lines.push("Skipped outputs:");
77017
+ for (const [k, digest] of sortedSkippedOutputs(state)) {
77018
+ const fullDigest = formatAttributeValue(digest);
77019
+ const shownDigest = fullDigest.length > SKIPPED_DIGEST_PREVIEW_LEN && fullDigest !== UNSERIALIZABLE ? `${fullDigest.slice(0, SKIPPED_DIGEST_PREVIEW_LEN)}…` : fullDigest;
77020
+ lines.push(` ${stripControlChars(k)}: ${shownDigest}`);
77021
+ }
77022
+ if (withLegend) lines.push(...skippedOutputsLegend());
77023
+ }
76534
77024
  const resourceEntries = Object.entries(state.resources ?? {}).sort(([a], [b]) => a.localeCompare(b));
76535
77025
  lines.push("");
76536
77026
  lines.push(`Resources (${resourceEntries.length}):`);
76537
77027
  for (const [logicalId, resource] of resourceEntries) {
76538
77028
  lines.push("");
76539
- lines.push(logicalId);
76540
- lines.push(` Type: ${resource.resourceType}`);
76541
- lines.push(` PhysicalID: ${stripControlChars(resource.physicalId)}`);
77029
+ lines.push(stripControlChars(logicalId));
77030
+ lines.push(` Type: ${formatAttributeValue(resource.resourceType)}`);
77031
+ lines.push(` PhysicalID: ${formatAttributeValue(resource.physicalId)}`);
76542
77032
  const provisionedBy = resource.provisionedBy ?? "(sdk, legacy default)";
76543
- lines.push(` ProvisionedBy: ${provisionedBy}`);
76544
- const deps = resource.dependencies ?? [];
76545
- lines.push(` Dependencies: ${deps.length > 0 ? deps.join(", ") : "(none)"}`);
77033
+ lines.push(` ProvisionedBy: ${formatAttributeValue(provisionedBy)}`);
77034
+ if (resource.observedBaselineRefused === true) lines.push(" ObservedBaseline: REFUSED by 'cdkd import' — no baseline will be captured (deploy a change to this resource to restore one)");
77035
+ lines.push(` Dependencies: ${formatDependencyList(resource.dependencies)}`);
76546
77036
  const attrEntries = Object.entries(resource.attributes ?? {});
76547
77037
  if (attrEntries.length === 0) lines.push(" Attributes: (none)");
76548
77038
  else {
@@ -76598,6 +77088,14 @@ async function loadLocksForTree(tree, lockManager, rootLock) {
76598
77088
  return decorate(tree);
76599
77089
  }
76600
77090
  /**
77091
+ * Whether any node in this tree owes a `Skipped outputs:` block, and so
77092
+ * whether the tree render owes the legend once at its end. Asked through
77093
+ * {@link rendersSkippedBlock}, the same predicate the block's own guard uses.
77094
+ */
77095
+ function treeOwesSkippedLegend(node) {
77096
+ return rendersSkippedBlock(node.state) || node.children.some(treeOwesSkippedLegend);
77097
+ }
77098
+ /**
76601
77099
  * Render the parent's block followed by every descendant in DFS order,
76602
77100
  * each separated by a blank line and prefixed with a `Nested stack: ...`
76603
77101
  * header so the user can scan the tree top-down in one screen.
@@ -76608,15 +77106,16 @@ async function loadLocksForTree(tree, lockManager, rootLock) {
76608
77106
  */
76609
77107
  function renderTreeWithChildren(root) {
76610
77108
  const lines = [];
76611
- lines.push(...renderStateBlock(root.state, root.lock));
77109
+ lines.push(...renderStateBlock(root.state, root.lock, false));
76612
77110
  appendDescendants(root.children, lines);
77111
+ if (treeOwesSkippedLegend(root)) lines.push(...skippedOutputsLegend());
76613
77112
  return lines;
76614
77113
  }
76615
77114
  function appendDescendants(children, out) {
76616
77115
  for (const child of children) {
76617
77116
  out.push("");
76618
- out.push(`Nested stack: ${child.stackName}`);
76619
- out.push(...renderStateBlock(child.state, child.lock));
77117
+ out.push(`Nested stack: ${stripControlChars(child.stackName)}`);
77118
+ out.push(...renderStateBlock(child.state, child.lock, false));
76620
77119
  appendDescendants(child.children, out);
76621
77120
  }
76622
77121
  }
@@ -77268,6 +77767,7 @@ async function stateRefreshObservedCommand(stackArgs, options) {
77268
77767
  let totalRefreshed = 0;
77269
77768
  let totalUnsupported = 0;
77270
77769
  let totalFailed = 0;
77770
+ let totalRefusedBaseline = 0;
77271
77771
  for (const target of targets) {
77272
77772
  if (!target.region) throw new Error(`Stack '${target.stackName}' has only a legacy state record without a region. Run 'cdkd deploy ${target.stackName}' (or any cdkd write) first to migrate it to the region-scoped layout, then re-run refresh-observed.`);
77273
77773
  const counts = await refreshObservedForStack(target.stackName, target.region, setup.stateBackend, setup.lockManager, providerRegistry, {
@@ -77282,9 +77782,11 @@ async function stateRefreshObservedCommand(stackArgs, options) {
77282
77782
  totalRefreshed += counts.refreshed;
77283
77783
  totalUnsupported += counts.unsupported;
77284
77784
  totalFailed += counts.failed;
77785
+ totalRefusedBaseline += counts.refusedBaseline;
77285
77786
  }
77286
77787
  const summary = options.dryRun ? `Plan: ${totalRefreshed} resource(s) would be refreshed, ${totalUnsupported} unsupported, ${totalFailed} would fail (--dry-run, no state was written)` : `Done: ${totalRefreshed} resource(s) refreshed, ${totalUnsupported} unsupported, ${totalFailed} failed`;
77287
77788
  logger.info(`\n${summary}`);
77789
+ if (totalRefusedBaseline > 0) logger.warn(`${totalRefusedBaseline} resource(s) ${options.dryRun ? "would NOT be refreshed" : "were NOT refreshed"}: a 'cdkd import' run refused to capture their observed-properties baseline, because their recorded properties can no longer position the secret redaction — refreshing against those properties could persist a resolved secret into state.json in plaintext. A deploy that actually CHANGES one of them restores its baseline — a NO_CHANGE deploy does not, and re-running this command will refuse them again. Drift compares against their recorded properties until then.`);
77288
77790
  if (totalFailed > 0) throw new PartialFailureError(`Refresh completed with ${totalFailed} per-resource readCurrentState failure(s). Affected resources keep their previous observedProperties (or no observedProperties at all). Re-run 'cdkd state refresh-observed' to retry.`);
77289
77791
  } finally {
77290
77792
  setup.dispose();
@@ -77351,13 +77853,19 @@ async function refreshObservedForStack(stackName, region, stateBackend, lockMana
77351
77853
  return {
77352
77854
  refreshed: 0,
77353
77855
  unsupported: 0,
77354
- failed: 0
77856
+ failed: 0,
77857
+ refusedBaseline: 0
77355
77858
  };
77356
77859
  }
77357
77860
  if (opts.dryRun) {
77358
77861
  let wouldRefresh = 0;
77359
77862
  let wouldUnsupported = 0;
77863
+ let wouldRefuse = 0;
77360
77864
  for (const [, resource] of entries) {
77865
+ if (resource.observedBaselineRefused === true) {
77866
+ wouldRefuse++;
77867
+ continue;
77868
+ }
77361
77869
  let provider;
77362
77870
  try {
77363
77871
  provider = providerRegistry.getProviderFor({
@@ -77371,11 +77879,12 @@ async function refreshObservedForStack(stackName, region, stateBackend, lockMana
77371
77879
  if (provider.readCurrentState) wouldRefresh++;
77372
77880
  else wouldUnsupported++;
77373
77881
  }
77374
- logger.info(`Plan ${stackName} (${region}): ${wouldRefresh} resource(s) would be refreshed, ${wouldUnsupported} unsupported`);
77882
+ logger.info(`Plan ${stackName} (${region}): ${wouldRefresh} resource(s) would be refreshed, ${wouldUnsupported} unsupported` + (wouldRefuse > 0 ? `, ${wouldRefuse} refused (import baseline refusal)` : ""));
77375
77883
  return {
77376
77884
  refreshed: wouldRefresh,
77377
77885
  unsupported: wouldUnsupported,
77378
- failed: 0
77886
+ failed: 0,
77887
+ refusedBaseline: wouldRefuse
77379
77888
  };
77380
77889
  }
77381
77890
  const owner = `${process.env["USER"] || "unknown"}@${process.env["HOSTNAME"] || "host"}:${process.pid}`;
@@ -77389,8 +77898,13 @@ async function refreshObservedForStack(stackName, region, stateBackend, lockMana
77389
77898
  let refreshed = 0;
77390
77899
  let unsupported = 0;
77391
77900
  let failed = 0;
77901
+ let refusedBaseline = 0;
77392
77902
  await withStackName(stackName, async () => {
77393
77903
  const tasks = entries.map(async ([logicalId, resource]) => {
77904
+ if (resource.observedBaselineRefused === true) {
77905
+ refusedBaseline++;
77906
+ return;
77907
+ }
77394
77908
  if (providerRegistry.shouldSkipResource(resource.resourceType)) {
77395
77909
  unsupported++;
77396
77910
  return;
@@ -77429,11 +77943,12 @@ async function refreshObservedForStack(stackName, region, stateBackend, lockMana
77429
77943
  const saveOptions = { expectedEtag: etag };
77430
77944
  if (migrationPending) saveOptions.migrateLegacy = true;
77431
77945
  await stateBackend.saveState(stackName, region, state, saveOptions);
77432
- logger.info(`✓ ${stackName} (${region}): ${refreshed} refreshed, ${unsupported} unsupported, ${failed} failed`);
77946
+ logger.info(`✓ ${stackName} (${region}): ${refreshed} refreshed, ${unsupported} unsupported, ${failed} failed` + (refusedBaseline > 0 ? `, ${refusedBaseline} refused (import baseline refusal)` : ""));
77433
77947
  return {
77434
77948
  refreshed,
77435
77949
  unsupported,
77436
- failed
77950
+ failed,
77951
+ refusedBaseline
77437
77952
  };
77438
77953
  } finally {
77439
77954
  await lockManager.releaseLock(stackName, region).catch((err) => {
@@ -77674,6 +78189,7 @@ async function rollbackCommand(stackArg, options) {
77674
78189
  if (!stateData) throw new Error(`Rollback journal exists for '${stackName}' (${region}) but its state.json is missing (keys: ${setup.prefix}/${stackName}/${region}/state.json and .../rollback-journal.json). State appears corrupted — inspect the bucket manually.`);
77675
78190
  const baseState = stateData.state;
77676
78191
  const stateResources = { ...baseState.resources };
78192
+ const mintedOrphans = [];
77677
78193
  const orphanLogicalIds = new Set(options.orphan ?? []);
77678
78194
  const newestSegment = journal.segments[journal.segments.length - 1];
77679
78195
  if (newestSegment.roleArn && !options.roleArn) logger.info(`Note: the failed deploy ran with --role-arn ${newestSegment.roleArn}; this rollback is running with ambient credentials (pass --role-arn to match).`);
@@ -77720,9 +78236,10 @@ async function rollbackCommand(stackArg, options) {
77720
78236
  const { skippedOutputs: _droppedByRollback, ...carriedState } = baseState;
77721
78237
  const next = () => ({
77722
78238
  ...carriedState,
77723
- version: 9,
78239
+ version: 10,
77724
78240
  region,
77725
78241
  resources: { ...stateResources },
78242
+ ...orphansAfterRollback(baseState, mintedOrphans),
77726
78243
  lastModified: Date.now()
77727
78244
  });
77728
78245
  try {
@@ -77764,7 +78281,8 @@ async function rollbackCommand(stackArg, options) {
77764
78281
  const failedResult = await replayFailedOperations(segment.failedOperations, stateResources, stackName, ctx, {
77765
78282
  afterOp: saveState,
77766
78283
  isInterrupted: () => interrupted,
77767
- emitEnvelope: segment.operations.length === 0
78284
+ emitEnvelope: segment.operations.length === 0,
78285
+ onOrphan: (record) => mintedOrphans.push(record)
77768
78286
  });
77769
78287
  failedOpFailures = failedResult.failures;
77770
78288
  failedOpWarnings = failedResult.warnings;
@@ -77785,7 +78303,8 @@ async function rollbackCommand(stackArg, options) {
77785
78303
  const replayResult = await replayRollback(segment.operations, stateResources, stackName, ctx, {
77786
78304
  orphanLogicalIds,
77787
78305
  afterOp: saveState,
77788
- isInterrupted: () => interrupted
78306
+ isInterrupted: () => interrupted,
78307
+ onOrphan: (record) => mintedOrphans.push(record)
77789
78308
  });
77790
78309
  return {
77791
78310
  failures: replayResult.failures + failedOpFailures,
@@ -77806,7 +78325,8 @@ async function rollbackCommand(stackArg, options) {
77806
78325
  } finally {
77807
78326
  await eventRecorder.finalize(totalFailures > 0 || interrupted ? "FAILED" : "SUCCEEDED");
77808
78327
  }
77809
- if (journal.segments.length === 0 && oldestInitialDeploy && Object.keys(stateResources).length === 0) {
78328
+ const survivingOrphans = orphansAfterRollback(baseState, mintedOrphans).orphans ?? [];
78329
+ if (journal.segments.length === 0 && oldestInitialDeploy && Object.keys(stateResources).length === 0 && survivingOrphans.length === 0) {
77810
78330
  await setup.stateBackend.deleteState(stackName, region);
77811
78331
  logger.info(`State for '${stackName}' (${region}) removed (stack fully rolled back).`);
77812
78332
  }
@@ -80793,7 +81313,7 @@ async function importCommand(stackArg, options) {
80793
81313
  }
80794
81314
  }
80795
81315
  const stackState = buildStackState(stackInfo.stackName, targetRegion, rows, templateParser, stateTemplate, existingState, selectiveMode);
80796
- await captureObservedForImportedResources(stackState, providerRegistry, logger, await withSharedDrainBudget(() => resolveImportedProperties(stackState, stateTemplate, targetRegion, stateBackend, logger)));
81316
+ await captureObservedForImportedResources(stackState, providerRegistry, logger, await withSharedDrainBudget(() => resolveImportedProperties(stackState, stateTemplate, targetRegion, stateBackend, logger)), rebuiltLogicalIdsFrom(rows, stateTemplate));
80797
81317
  const saveOptions = {};
80798
81318
  if (existingEtag) saveOptions.expectedEtag = existingEtag;
80799
81319
  if (migrationPending) saveOptions.migrateLegacy = true;
@@ -81109,6 +81629,30 @@ function collectImportableResources(template) {
81109
81629
  return out;
81110
81630
  }
81111
81631
  /**
81632
+ * The logical ids whose `ResourceState` a run REBUILT from the template — the
81633
+ * discriminator `captureObservedForImportedResources` needs for schema v10's
81634
+ * `observedBaselineRefused` (issue
81635
+ * [#2944](https://github.com/go-to-k/cdkd/issues/2944)).
81636
+ *
81637
+ * It is a function, exported and tested, rather than an inline
81638
+ * `rows.filter(...)` at each call site, because the predicate is the whole
81639
+ * safety property: `buildStackState` overwrites exactly the `'imported'` rows
81640
+ * and PRESERVES every other record in a selective merge, so a predicate that
81641
+ * drifts wider (`rows.map(...)`, or admitting another outcome) silently
81642
+ * re-opens the fifth-writer leak — a preserved record's stale, distrusted
81643
+ * `properties` would be treated as this run's resolution, captured against,
81644
+ * and its refusal cleared. Two inline copies could also drift apart from each
81645
+ * other and from `buildStackState`'s own test.
81646
+ *
81647
+ * Every non-`'imported'` outcome is EXCLUDED, including the ones that sound
81648
+ * harmless: `skipped-out-of-scope` is precisely the selective-merge case, and
81649
+ * `failed` / `skipped-not-found` / `skipped-no-impl` leave whatever the record
81650
+ * already held.
81651
+ */
81652
+ function rebuiltLogicalIdsFrom(rows, template) {
81653
+ return new Set(rows.filter((r) => r.outcome === "imported" && r.physicalId !== void 0 && template.Resources[r.logicalId] !== void 0).map((r) => r.logicalId));
81654
+ }
81655
+ /**
81112
81656
  * Compose a `StackState` from the per-resource import outcomes plus
81113
81657
  * dependency info recovered from the template.
81114
81658
  *
@@ -81148,12 +81692,13 @@ function buildStackState(stackName, region, rows, templateParser, template, exis
81148
81692
  };
81149
81693
  }
81150
81694
  return {
81151
- version: 9,
81695
+ version: 10,
81152
81696
  stackName,
81153
81697
  region,
81154
81698
  resources,
81155
81699
  outputs: existingState?.outputs ?? {},
81156
81700
  ...existingState ? exportNamesCarriedFrom(existingState) : { exportNames: [] },
81701
+ ...existingState ? orphansCarriedFrom(existingState) : {},
81157
81702
  lastModified: Date.now()
81158
81703
  };
81159
81704
  }
@@ -81236,7 +81781,7 @@ function defaultOnlyParameterTemplate(template) {
81236
81781
  * the clear.
81237
81782
  *
81238
81783
  * The predicate is deliberately CONSERVATIVE — it over-refuses — and that is a
81239
- * decision a review round paid for. Two arms:
81784
+ * decision a review round paid for. Three arms:
81240
81785
  *
81241
81786
  * - **Resolution THREW** — refuse, unconditionally. A throw means the
81242
81787
  * resolver could not finish, so nothing about the persisted bag is
@@ -81264,6 +81809,19 @@ function defaultOnlyParameterTemplate(template) {
81264
81809
  * remedy -- because a populated map makes absence from it real evidence of
81265
81810
  * a public parameter, exactly as the deploy path already relies on.
81266
81811
  *
81812
+ * - **Resolution SUCCEEDED but DISCARDED a subtree that is not provably
81813
+ * inert** (issue [#2850](https://github.com/go-to-k/cdkd/issues/2850)).
81814
+ * The count in arm 2 sees only what the raw bag SPELLS, and that issue
81815
+ * measured two ways past it: a downgraded `Fn::If` dropping one reference
81816
+ * while a parameter-sourced reference is ADDED elsewhere (the totals
81817
+ * balance), and a reference sourced ENTIRELY from a parameter `Default` or
81818
+ * a `Mappings` entry then dropped (both counts read zero, and nothing
81819
+ * warns). {@link resolveDiscardsNonInertSubtree} carries the discard-event
81820
+ * enumeration and the fail-closed rules; "inert" is
81821
+ * {@link isInertDiscardedSubtree}'s definition — pure literals with no
81822
+ * opener text and no intrinsic anywhere, because every way a discarded
81823
+ * subtree could have SOURCED a reference is an intrinsic.
81824
+ *
81267
81825
  * The cost of a false refusal is one resource's drift baseline until its next
81268
81826
  * deploy; the cost of a false pass is a plaintext secret in `state.json`. So an
81269
81827
  * unmeasurable bag refuses too.
@@ -81277,9 +81835,6 @@ function defaultOnlyParameterTemplate(template) {
81277
81835
  * PLAINTEXT CAN STILL BE PERSISTED by this capture. Known ways, each with its
81278
81836
  * issue and none of them proven to be all of them:
81279
81837
  *
81280
- * - the resolve drops a reference, or trades one away, in a manner the opener
81281
- * comparison below cannot see --
81282
- * [#2850](https://github.com/go-to-k/cdkd/issues/2850);
81283
81838
  * - a parameter binds to a placeholder `Default` while the DEPLOYED value was
81284
81839
  * the reference, so no opener exists in any template this walk is handed --
81285
81840
  * [#2854](https://github.com/go-to-k/cdkd/issues/2854);
@@ -81371,7 +81926,14 @@ async function resolveImportedProperties(stackState, template, region, stateBack
81371
81926
  }
81372
81927
  const rawOpeners = countDynamicReferenceOpeners(unresolvedProperties);
81373
81928
  const persistedOpeners = countDynamicReferenceOpeners(resource.properties);
81374
- if (rawOpeners === void 0 || persistedOpeners === void 0 || persistedOpeners < rawOpeners) unsafeObservedBaselineLogicalIds.add(logicalId);
81929
+ let discardsNonInertSubtree;
81930
+ try {
81931
+ discardsNonInertSubtree = resolveDiscardsNonInertSubtree(unresolvedProperties, conditions, template.Mappings);
81932
+ } catch (err) {
81933
+ logger.debug(`observed-baseline discard walk failed for imported ${logicalId} (${resource.resourceType}): ${err instanceof Error ? err.name : typeof err} — refusing the baseline fail-closed.`);
81934
+ discardsNonInertSubtree = true;
81935
+ }
81936
+ if (rawOpeners === void 0 || persistedOpeners === void 0 || persistedOpeners < rawOpeners || discardsNonInertSubtree) unsafeObservedBaselineLogicalIds.add(logicalId);
81375
81937
  }
81376
81938
  return unsafeObservedBaselineLogicalIds;
81377
81939
  }
@@ -81405,6 +81967,199 @@ function countDynamicReferenceOpeners(bag) {
81405
81967
  from = at + 10;
81406
81968
  }
81407
81969
  }
81970
+ /**
81971
+ * Whether an object key can make the resolver treat its object as an
81972
+ * INTRINSIC. Deliberately BROADER than the resolver's own dispatch list
81973
+ * (`resolveValue` names specific keys; this matches any `Fn::`-prefixed one):
81974
+ * the callers below use it to decide what a DISCARDED subtree could have
81975
+ * been, and over-matching there only widens a refusal, never a pass.
81976
+ */
81977
+ function isIntrinsicShapedKey(key) {
81978
+ return key === "Ref" || key.startsWith("Fn::");
81979
+ }
81980
+ /**
81981
+ * Whether a subtree the resolve DISCARDED is provably INERT: pure JSON
81982
+ * literals, carrying no `{{resolve:` opener text in any string leaf and no
81983
+ * intrinsic-shaped object key anywhere.
81984
+ *
81985
+ * This is the capability question of issue
81986
+ * [#2850](https://github.com/go-to-k/cdkd/issues/2850) asked in the
81987
+ * fail-closed direction. Asking "could this subtree have carried or SOURCED a
81988
+ * dynamic reference" precisely would need enumerating every source a reference
81989
+ * can arrive from — a parameter `Default` via `Ref` or `Fn::Sub`, a `Mappings`
81990
+ * entry via `Fn::FindInMap`, another stack's outputs via `Fn::ImportValue` /
81991
+ * `Fn::GetStackOutput` — and the enumeration missing one form is exactly how
81992
+ * the opener COUNT missed the first two. Every one of those source forms is an
81993
+ * intrinsic, so "no intrinsic at all" covers them all without naming any, and
81994
+ * a plain-literal subtree is one whose value at deploy time was the same
81995
+ * public template text this walk can read.
81996
+ */
81997
+ function isInertDiscardedSubtree(node) {
81998
+ if (typeof node === "string") return !node.includes(DYNAMIC_REFERENCE_OPENER);
81999
+ if (node === null || typeof node !== "object") return true;
82000
+ if (Array.isArray(node)) return node.every((element) => isInertDiscardedSubtree(element));
82001
+ const record = node;
82002
+ const keys = Object.keys(record);
82003
+ if (keys.length === 1 && keys[0] === "Ref" && record["Ref"] === "AWS::NoValue") return true;
82004
+ return Object.entries(record).every(([key, value]) => !isIntrinsicShapedKey(key) && isInertDiscardedSubtree(value));
82005
+ }
82006
+ /**
82007
+ * Whether a scalar the resolver consumes RAW (an `Fn::Select` index) is a
82008
+ * template-constant literal that `resolveSelect`'s `resolvedList[index]` read
82009
+ * actually honors: a number, or the CANONICAL digit-string of one. The
82010
+ * canonical form is load-bearing, not pedantry — a delta review round
82011
+ * measured `['a','b']['01']` as `undefined` (a non-canonical string is a
82012
+ * property name, not an index) while CloudFormation integer-parses it to 1,
82013
+ * so `'01'` is exactly as selection-divergent as an intrinsic index and must
82014
+ * not count as static. Anything non-static makes the selection depend on a
82015
+ * value this import cannot reproduce.
82016
+ */
82017
+ function isStaticSelectIndex(value) {
82018
+ if (typeof value === "number") return Number.isInteger(value) && value >= 0;
82019
+ return typeof value === "string" && /^(0|[1-9]\d*)$/.test(value);
82020
+ }
82021
+ /**
82022
+ * A `Fn::FindInMap` lookup-key argument the resolver can only have resolved
82023
+ * to the same key at deploy time: a plain scalar literal CARRYING NO
82024
+ * `{{resolve:` opener. The opener clause is the same delta-review finding one
82025
+ * helper up: `resolveFindInMap` resolves each key through `resolveValue`, so
82026
+ * a dynamic-reference STRING key is decrypted before the lookup and the
82027
+ * selected entry depends on the secret's deploy-time value — not on the
82028
+ * template text this walk reads.
82029
+ */
82030
+ function isStaticLookupKey(value) {
82031
+ if (typeof value === "string") return !value.includes(DYNAMIC_REFERENCE_OPENER);
82032
+ return typeof value === "number" || typeof value === "boolean";
82033
+ }
82034
+ /**
82035
+ * ARM 3 of the observed-baseline refusal (issue
82036
+ * [#2850](https://github.com/go-to-k/cdkd/issues/2850)): did the resolve
82037
+ * DISCARD a subtree it cannot vouch is inert?
82038
+ *
82039
+ * The opener-count comparison (ARM 2) sees only what the raw bag SPELLS, and
82040
+ * the issue measured two ways past it on the success arm: a downgraded
82041
+ * `Fn::If` that drops one reference while a parameter-sourced reference is
82042
+ * added elsewhere (the totals balance), and a reference sourced ENTIRELY from
82043
+ * outside the bag then dropped (both counts read zero, nothing throws, no
82044
+ * warn fires). Both are DISCARD events: the resolver threw a subtree away
82045
+ * unresolved, and whatever that subtree would have produced — which is what
82046
+ * AWS actually holds when the deployed condition took the other branch — is
82047
+ * exactly what the persisted bag cannot position a redaction for.
82048
+ *
82049
+ * So this walk detects the discard EVENTS on the RAW bag instead of comparing
82050
+ * positions across the resolve (which would need a path identity across the
82051
+ * `Fn::If` collapse that does not exist). The discarders, enumerated from
82052
+ * `IntrinsicFunctionResolver` rather than assumed — and re-enumerated by this
82053
+ * PR's review round, which found the first cut had missed TWO (`Fn::FindInMap`
82054
+ * and `Fn::Select` below; an earlier revision called `resolveIf` "the only
82055
+ * LAZY selector" and the resolver's own source refutes it):
82056
+ *
82057
+ * - `resolveIf` — a LAZY selector: it resolves the selected branch
82058
+ * only, so the unselected branch is discarded UNRESOLVED. A missing
82059
+ * condition warns and selects the FALSE branch (no throw), so the success
82060
+ * arm reaches it. Dispatch is `'Fn::If' in obj`, not single-key.
82061
+ * - `resolveFindInMap` — the OTHER lazy site: the 4-argument form's
82062
+ * `DefaultValue` is resolved only on a lookup MISS, so a HIT discards it
82063
+ * unresolved; and a non-literal lookup KEY makes the un-taken mapping
82064
+ * entries discarded template content. The walk's own arm comment carries
82065
+ * both modes.
82066
+ * - `resolveSelect` — eager on the LIST but the INDEX is consumed RAW, never
82067
+ * resolved: an intrinsic index selects `undefined` here while
82068
+ * CloudFormation resolved it at deploy, so the whole list is potentially
82069
+ * discarded content. (With a NUMERIC-LITERAL index the selection is
82070
+ * template-constant and the un-selected elements were discarded at deploy
82071
+ * too — that case discards nothing AWS could hold.)
82072
+ * - Dispatch order itself — `resolveValue` dispatches on the first matching
82073
+ * intrinsic key, so an object holding an intrinsic key AMONG OTHER KEYS
82074
+ * resolves as that intrinsic and silently DROPS the sibling keys'
82075
+ * subtrees.
82076
+ * - NOT `Fn::Sub`: its variable map is resolved eagerly, every entry.
82077
+ * - NOT `Fn::Join`'s delimiter: it is consumed raw too, but CloudFormation
82078
+ * itself admits no intrinsic there, and an intrinsic delimiter joins as
82079
+ * `[object Object]` — a mangled value, not a discarded reference.
82080
+ *
82081
+ * Fail-closed rules, in the order tested (an earlier revision of this list
82082
+ * omitted the two arms the review round added while still claiming to BE the
82083
+ * tested order — a list that claims an order and is not the order is worse
82084
+ * than no list, so keep it complete or delete the claim):
82085
+ *
82086
+ * - an ARRAY recurses element-wise, so a discarder inside a list property
82087
+ * (`SecurityGroupIds: [{Fn::If: ...}]`) is found;
82088
+ * - single-key `Fn::If` with well-formed args and a condition the evaluated
82089
+ * map carries (`Object.hasOwn`, the same test `resolveIf` uses since issue
82090
+ * #2767): the UNSELECTED branch must be inert, and the SELECTED branch is
82091
+ * recursed into;
82092
+ * - single-key `Fn::If` whose condition the map does NOT carry: BOTH
82093
+ * branches must be inert. The resolver warns and takes the false branch,
82094
+ * so this is a SUPERSET of mirroring it — mirroring the lookup exactly is
82095
+ * the two-spellings-of-one-question drift this repo keeps paying for, and
82096
+ * the superset can only over-refuse, never leak;
82097
+ * - single-key `Fn::If` with MALFORMED args: refuse;
82098
+ * - single-key `Fn::FindInMap`: MALFORMED (< 3 args) refuses; a 4th
82099
+ * argument must be inert AS A WHOLE (its `DefaultValue` is resolved only
82100
+ * on a lookup miss, and any sibling key there — or a whole 4th arg with
82101
+ * no `DefaultValue` key — is discarded unconditionally); a non-literal
82102
+ * lookup key refuses unless the map in scope is inert (the NAMED map for
82103
+ * a literal map name, the whole `Mappings` section otherwise); then the
82104
+ * args recurse;
82105
+ * - single-key `Fn::Select`: MALFORMED (not exactly 2 args) refuses; a
82106
+ * non-static index refuses unless the list argument is inert; then the
82107
+ * args recurse;
82108
+ * - a MULTI-KEY object carrying intrinsic-shaped keys: TWO or more such
82109
+ * keys refuse outright (dispatch drops the losers as whole INTRINSICS,
82110
+ * whose values' text is not evidence of what they pull); with exactly
82111
+ * one, every value must be inert — which sibling the dispatch order
82112
+ * drops is deliberately not mirrored here;
82113
+ * - everything else recurses, so a nested `Fn::If` inside an `Fn::Join` /
82114
+ * `Fn::Sub` argument is still found.
82115
+ *
82116
+ * Additive beside ARM 1 (throw) and ARM 2 (count loss): refusal strictly
82117
+ * widens, so no previously-refused shape can start leaking, and the risk
82118
+ * direction is over-refusal alone. The negative controls live in the matrix:
82119
+ * an `Fn::If` discarding pure literals stays ADMITTED, and so does a
82120
+ * multi-key intrinsic object whose sibling values are literals.
82121
+ *
82122
+ * THE CALLER WRAPS EVERY CALL IN A FAIL-CLOSED try (see the ARM 3 note at
82123
+ * the call site): this walk recurses synchronously and a deep-enough bag
82124
+ * overflows the call stack where the JSON.stringify arms still compute.
82125
+ */
82126
+ function resolveDiscardsNonInertSubtree(node, conditions, mappings) {
82127
+ if (node === null || typeof node !== "object") return false;
82128
+ if (Array.isArray(node)) return node.some((element) => resolveDiscardsNonInertSubtree(element, conditions, mappings));
82129
+ const record = node;
82130
+ const keys = Object.keys(record);
82131
+ if (keys.length === 1 && keys[0] === "Fn::If") {
82132
+ const args = record["Fn::If"];
82133
+ if (!Array.isArray(args) || args.length !== 3 || typeof args[0] !== "string") return true;
82134
+ const [conditionName, valueIfTrue, valueIfFalse] = args;
82135
+ if (!Object.hasOwn(conditions, conditionName)) return !isInertDiscardedSubtree(valueIfTrue) || !isInertDiscardedSubtree(valueIfFalse);
82136
+ const selected = conditions[conditionName] ? valueIfTrue : valueIfFalse;
82137
+ if (!isInertDiscardedSubtree(conditions[conditionName] ? valueIfFalse : valueIfTrue)) return true;
82138
+ return resolveDiscardsNonInertSubtree(selected, conditions, mappings);
82139
+ }
82140
+ if (keys.length === 1 && keys[0] === "Fn::FindInMap") {
82141
+ const args = record["Fn::FindInMap"];
82142
+ if (!Array.isArray(args) || args.length < 3 || args.length > 4) return true;
82143
+ if (args.length > 3) {
82144
+ if (!isInertDiscardedSubtree(args[3])) return true;
82145
+ }
82146
+ if (!args.slice(0, 3).every((key) => isStaticLookupKey(key))) {
82147
+ if (!isInertDiscardedSubtree(typeof args[0] === "string" && mappings !== null && typeof mappings === "object" && Object.hasOwn(mappings, args[0]) ? mappings[args[0]] : mappings)) return true;
82148
+ }
82149
+ return args.some((arg) => resolveDiscardsNonInertSubtree(arg, conditions, mappings));
82150
+ }
82151
+ if (keys.length === 1 && keys[0] === "Fn::Select") {
82152
+ const args = record["Fn::Select"];
82153
+ if (!Array.isArray(args) || args.length !== 2) return true;
82154
+ if (!(isStaticSelectIndex(args[0]) && (!Array.isArray(args[1]) || Number(args[0]) < args[1].length)) && !isInertDiscardedSubtree(args[1])) return true;
82155
+ return args.some((arg) => resolveDiscardsNonInertSubtree(arg, conditions, mappings));
82156
+ }
82157
+ if (keys.length > 1 && keys.some((key) => isIntrinsicShapedKey(key))) {
82158
+ if (keys.filter((key) => isIntrinsicShapedKey(key)).length > 1) return true;
82159
+ return !keys.every((key) => isInertDiscardedSubtree(record[key]));
82160
+ }
82161
+ return keys.some((key) => resolveDiscardsNonInertSubtree(record[key], conditions, mappings));
82162
+ }
81408
82163
  function printSummary(rows) {
81409
82164
  const logger = getLogger();
81410
82165
  const counts = {
@@ -81531,14 +82286,20 @@ const NO_RECORDED_SECRETS = /* @__PURE__ */ new Map();
81531
82286
  * call site's own note (issue
81532
82287
  * [#2828](https://github.com/go-to-k/cdkd/issues/2828)).
81533
82288
  */
81534
- async function captureObservedForImportedResources(stackState, providerRegistry, logger, unsafeObservedBaselineLogicalIds) {
82289
+ async function captureObservedForImportedResources(stackState, providerRegistry, logger, unsafeObservedBaselineLogicalIds, rebuiltLogicalIds) {
81535
82290
  const entries = Object.entries(stackState.resources);
81536
82291
  if (entries.length === 0) return;
81537
82292
  await Promise.all(entries.map(async ([logicalId, resource]) => {
82293
+ if (resource.observedBaselineRefused === true && !rebuiltLogicalIds.has(logicalId)) {
82294
+ logger.debug(`observedProperties capture SKIPPED for preserved ${logicalId} (${resource.resourceType}): a previous 'cdkd import' run refused this record's baseline and this run did not re-import it, so its recorded properties are still the ones that refusal distrusted. Deploy a change to this resource to restore a baseline.`);
82295
+ return;
82296
+ }
81538
82297
  if (unsafeObservedBaselineLogicalIds.has(logicalId)) {
82298
+ resource.observedBaselineRefused = true;
81539
82299
  logger.debug(`observedProperties capture SKIPPED for imported ${logicalId} (${resource.resourceType}): the recorded properties no longer spell the template's dynamic reference, so they cannot position a redaction — capturing an AWS readback against them could persist a resolved secret in plaintext. Drift will compare against the recorded properties for this resource until the next successful deploy.`);
81540
82300
  return;
81541
82301
  }
82302
+ delete resource.observedBaselineRefused;
81542
82303
  try {
81543
82304
  const provider = providerRegistry.getProviderFor({
81544
82305
  resourceType: resource.resourceType,
@@ -81718,7 +82479,7 @@ async function importNestedStackChildrenRecursive(args) {
81718
82479
  childStackState.parentStack = parentStackName;
81719
82480
  childStackState.parentLogicalId = childLogicalId;
81720
82481
  childStackState.parentRegion = parentRegion;
81721
- await captureObservedForImportedResources(childStackState, providerRegistry, logger, await withSharedDrainBudget(() => resolveImportedProperties(childStackState, childStateTemplate, childRegion, stateBackend, logger)));
82482
+ await captureObservedForImportedResources(childStackState, providerRegistry, logger, await withSharedDrainBudget(() => resolveImportedProperties(childStackState, childStateTemplate, childRegion, stateBackend, logger)), rebuiltLogicalIdsFrom(rows, childTemplate));
81722
82483
  await stateBackend.saveState(childStackName, childRegion, childStackState);
81723
82484
  logger.info(`✓ Nested stack state written: ${childStackName} (${childRegion}) — ${rows.filter((r) => r.outcome === "imported").length} resource(s) imported.`);
81724
82485
  if (childTreeNode.nested.size > 0) await importNestedStackChildrenRecursive({
@@ -90949,4 +91710,4 @@ function buildProgram() {
90949
91710
 
90950
91711
  //#endregion
90951
91712
  export { buildProgram };
90952
- //# sourceMappingURL=program-CxMjCbiG.js.map
91713
+ //# sourceMappingURL=program-Bg60hZhr.js.map