@go-to-k/cdkd 0.290.16 → 0.290.18

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 secretBearing, $n as BOOTSTRAP_MARKER_PREFIX, $r as getAwsClients, $t as s3BucketRegionalDomainName, A as extractDeploymentEventError, Ai as markNonRetryable, An as buildForceUnlockCommand, Ar as resolveAutoAssetStorage, At as parameterTypeMayLoseSecretIdentity, B as toCfnInteger, Bn as importableOutputs, Br as MIGRATE_TMP_PREFIX, Bt as configIntegerRefusal, C as buildFinalSnapshotIdentifier, Cn as recordMaskOnlyValue, Cr as AssetManifestLoader, Ct as IntrinsicFunctionResolver, D as refusesFinalSnapshot, Di as isRetryableTransientError, Dn as LockManager, Dr as getDefaultStateBucketName, Dt as getAccountInfo, E as isFinalSnapshotError, Ei as isMarkedNonRetryable, En as scrubResourceRecord, Er as synthesisStatusMessage, Et as coerceParameterTypedValue, F as isStatefulRecreateTargetSync, Fn as CUSTOM_RESOURCE_RESPONSE_PREFIX, Fr as resolveUseCdkBootstrapAssets, Ft as resolveExplicitPhysicalId, G as gray, Gn as AssetPublisher, Gr as PARTITION_TABLE, Gt as requireConfigObject, H as formatResourceLine, Hn as orphansAfterRollback, Hr as uploadCfnTemplate, Ht as readConfigString, I as renderStatefulReason, In as DEFAULT_STATE_PREFIX, Ir as stateBucketExistenceConfirmed, It as assertRegionMatch, J as yellow, Jn as buildAssetRedirectMap, Jr as AssemblyReader, Jt as producerRegionsFromState, K as green, Kn as stringifyValue, Kr as canonicalizeRegion, Kt as requireConfigString, L as WARM_THROUGHPUT_MEMBERS, Ln as exportNamesCarriedFrom, Lr as warnDeprecatedNoPrefixCliFlag, Lt as coerceCfnBoolean, M as planOrphanAdoption, Mi as retryClassificationText, Mn as forceQuitRecoveryClause, Mr as resolveSkipPrefix, Mt as isSettledInstanceState, N as MULTI_REGION_RECREATE_BLOCKED_TYPES, Nn as formatLockExpiry, Nr as resolveStateBucketWithDefault, Nt as WAFv2WebACLProvider, O as unsupportedFinalSnapshotError, Oi as isThrottlingError, On as S3StateBackend, Or as getLegacyStateBucketName, Ot as isImpossibleEmptyStoredAttribute, P as isStatefulRecreateTargetForReplace, Pn as shellQuote, Pr as resolveStateBucketWithDefaultAndSource, Pt as normalizeAwsTagsToCfn, Q as isExportAliasCollision, Qn as AssetModeResolver, Qr as AwsClients, Qt as s3BucketDualStackDomainName, R as coerceWarmThroughput$1, Rn as hasReadableExportSet, Rr as CFN_TEMPLATE_BODY_LIMIT, Rt as coerceCfnInteger, S as PRE_DELETE_SNAPSHOT_TYPES, Sn as maskSecretsInText, Sr as stripControlChars, St as isTerminationProtectionPropagationError, T as createPreDeleteFinalSnapshot, Ti as withErrorHandling, Tn as redactSecretsForState, Tr as Synthesizer, Tt as cfnRefValueFromPhysicalId, U as bold, Un as orphansCarriedFrom, Ur as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, Ut as replayWarn, V as toFiniteNumber, Vn as isReadableBag, Vr as findLargeInlineResources, Vt as configStringRefusal, W as cyan, Wn as shouldRetainResource, Wr as expectedOwnerParam, Wt as requireConfigArray, X as collectPublishedOutputNames, Xn as loadPublishableAssetManifest, Xt as s3BucketArn, Y as collectDeclaredOutputNames, Yn as createAssetRedirectResolver, Yr as processStackMessages, Yt as withSharedDrainBudget, Z as exportAliasCollisionScrubWarning, Zn as rewriteTemplateAssetReferences, Zr as resolveBucketRegion, Zt as s3BucketDomainName, _ as isSecretBearingReferenceString, _i as ResourceUpdateNotSupportedError, _n as errorCauseChain, _r as partitionSensitiveEnv, _t as slowCcOperationTimeoutMs, a as DeploymentEventsStore, ai as CrossAccountSecretRefusalError, an as findSilentDropProperties, ar as readBootstrapMarkerBody, at as clearOnUpdateRemoval, b as bindingSkippedOutputs, bn as markSameGenerationBag, br as runDockerStreaming, bt as deleteSkipReason, c as replayFailedOperations, ci as DynamicReferenceRegionAmbiguousError, cn as DagBuilder, cr as buildDenyExternalAccessPolicy, ct as createMaskedRetryLogger, d as updatePartialReason, di as LocalStartServiceError, dn as STATE_SOURCED_CROSS_GENERATION_RULES, dr as describeDockerCapturedOutput, dt as beginCommandInterruptScope, ei as resetAwsClients, en as s3BucketWebsiteUrl, er as assertAssetBucketRegion, et as secretBearingStateKeyWarning, f as withResourceDeadline, fn as STATE_SOURCED_READBACK_RULES, fr as describeDockerExecFailure, ft as endCommandInterruptScope, g as bagHoldsSecretExpression, gi as ResourceTimeoutError, gn as dynamicReferenceTokens, gr as getDockerCmd, gt as CloudControlProvider, h as computeImplicitDeleteEdges, hi as ProvisioningError, hn as createSecretMasker, hr as formatDockerLoginError, ht as startInterruptWatch, i as DeploymentEventsReader, in as findActionableSilentDrops, ir as parseBootstrapMarker, it as collectInlinePolicyNamesManagedBySiblings, j as makeSiblingClaimReader, ji as markRedactedCause, jn as buildLockContentionMessage, jr as resolveCaptureObservedState, jt as refStateLookupFromResource, k as makeCanonicalizePropertiesFn, ki as isTransientServerError, kn as rebuildClientForBucketRegion, kr as resolveApp, kt as isUnboundTemplateParameter, l as replayRollback, li as IntrinsicResolutionRefusalError, ln as TemplateParser, lr as describeAwsFailure, lt as maskDeep, m as IMPLICIT_DELETE_DEPENDENCIES, mi as PartialFailureError, mn as carriesSecretMask, mr as dockerSpawnEnvWithSensitive, mt as isInterruptedWaitError, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as DiffCalculator, nr as getBootstrapMarkerKey, nt as getCurrentResourceSecrets, o as planFailedOps, on as describeTypeWithThrottleRetry, or as validateAssetBucketName, ot as ProviderRegistry, p as maskingRetryLogger, pi as NestedStackChildDirectDestroyError, pn as TEMPLATE_SOURCED_RULES, pr as describeDockerFailure, pt as interruptWatchListenerCount, q as red, qn as WorkGraph, qr as derivePartitionAndUrlSuffix, qt as classifyReplaySecretRegion, r as DeployEngine, ri as CdkdError, rn as INTRINSIC_KEYS, rr as isCrossRegionRedirect, rt as IAMRoleProvider, s as planRollback, si as DeployCancelledError, sn as withRetry, sr as validateContainerRepoName, st as wouldReturnToSdkProvider, t as DEFAULT_RESOURCE_TIMEOUT_MS, ti as setAwsClients, tn as applyRoleArnIfSet, tr as ensureAssetStorage, tt as secretSafeKeyDisplay, u as updatePartialMessage, ui as LocalInvokeBuildError$1, un as STATE_SOURCED_BASELINE_RULES, ur as buildDockerImage, ut as maskerOrIdentity, v as keptWholeReasonText, vi as StackHasActiveImportsError, vn as identityKeyFor, vr as redactDockerArgvValues, vt as UNSPECIFIED_SKIP_REASON, w as ccRoutedFinalSnapshotError, wi as normalizeAwsError, wn as recoverMaskedOutput, wr as getDockerImageBySourceHash, wt as carriesDynamicReference, x as ATOMIC_FINAL_SNAPSHOT_TYPES, xn as maskSecretsInError, xr as escapeRegExp, xt as disableInstanceApiTermination, y as mergeNoChangeOutputs, yi as StackTerminationProtectionError, yn as isSingleDynamicReferenceToken, yr as runDockerForeground, yt as deleteIndeterminateGuards, z as isWarmThroughputDecrease, zn as importableOutputKeys, zr as CFN_TEMPLATE_URL_LIMIT, zt as configBooleanRefusal } from "./deploy-engine-B91NMqm1.js";
1
+ import { $ as secretBearing, $i as markNonRetryable, $n as buildLockContentionMessage, $r as resolveCaptureObservedState, $t as malformedExportNamesWarning, A as extractDeploymentEventError, Ai as DynamicReferenceRegionAmbiguousError, An as DagBuilder, Ar as buildDenyExternalAccessPolicy, At as isImpossibleEmptyStoredAttribute, B as toCfnInteger, Bi as ResourceUpdateNotSupportedError, Bn as identityKeyFor, Br as redactDockerArgvValues, Bt as coerceCfnBoolean, C as buildFinalSnapshotIdentifier, Ci as setAwsClients, Cn as callerEnvCredentials, Cr as ensureAssetStorage, Ct as isTerminationProtectionPropagationError, D as refusesFinalSnapshot, Di as CrossAccountSecretRefusalError, Dn as findSilentDropProperties, Dr as readBootstrapMarkerBody, Dt as cfnRefValueFromPhysicalId, E as isFinalSnapshotError, En as findActionableSilentDrops, Er as parseBootstrapMarker, Et as carriesFetchableDynamicReference, F as isStatefulRecreateTargetSync, Fi as MalformedProducerRecordRefusalError, Fn as TEMPLATE_SOURCED_RULES, Fr as describeDockerFailure, Ft as isSettledInstanceState, G as gray, Gn as recordMaskOnlyValue, Gr as AssetManifestLoader, Gt as readConfigString, H as formatResourceLine, Hi as StackTerminationProtectionError, Hn as markSameGenerationBag, Hr as runDockerStreaming, Ht as configBooleanRefusal, I as renderStatefulReason, Ii as NestedStackChildDirectDestroyError, In as carriesSecretMask, Ir as dockerSpawnEnvWithSensitive, It as WAFv2WebACLProvider, J as yellow, Ji as withErrorHandling, Jn as scrubResourceRecord, Jr as synthesisStatusMessage, Jt as requireConfigObject, K as green, Kn as recoverMaskedOutput, Kr as getDockerImageBySourceHash, Kt as replayWarn, L as WARM_THROUGHPUT_MEMBERS, Li as PartialFailureError, Ln as createSecretMasker, Lr as formatDockerLoginError, Lt as normalizeAwsTagsToCfn, M as planOrphanAdoption, Mi as LocalInvokeBuildError$1, Mn as STATE_SOURCED_BASELINE_RULES, Mr as buildDockerImage, Mt as isUnboundTemplateParameter, N as MULTI_REGION_RECREATE_BLOCKED_TYPES, Ni as LocalStartServiceError, Nn as STATE_SOURCED_CROSS_GENERATION_RULES, Nr as describeDockerCapturedOutput, Nt as parameterTypeMayLoseSecretIdentity, O as unsupportedFinalSnapshotError, On as describeTypeWithThrottleRetry, Or as validateAssetBucketName, Ot as coerceParameterTypedValue, P as isStatefulRecreateTargetForReplace, Pn as STATE_SOURCED_READBACK_RULES, Pr as describeDockerExecFailure, Pt as refStateLookupFromResource, Q as isExportAliasCollision, Qi as isTransientServerError, Qn as buildForceUnlockCommand, Qr as resolveAutoAssetStorage, Qt as hasReadableResources, R as coerceWarmThroughput$1, Ri as ProvisioningError, Rn as dynamicReferenceTokens, Rr as getDockerCmd, Rt as resolveExplicitPhysicalId, S as PRE_DELETE_SNAPSHOT_TYPES, Si as resetAwsClients, Sn as applyCallerIdentityCredentials, Sr as assertAssetBucketRegion, St as disableInstanceApiTermination, T as createPreDeleteFinalSnapshot, Ti as CdkdError, Tn as INTRINSIC_KEYS, Tr as isCrossRegionRedirect, Tt as carriesDynamicReference, U as bold, Un as maskSecretsInError, Ur as escapeRegExp, Ut as configIntegerRefusal, V as toFiniteNumber, Vi as StackHasActiveImportsError, Vn as isSingleDynamicReferenceToken, Vr as runDockerForeground, Vt as coerceCfnInteger, W as cyan, Wn as maskSecretsInText, Wr as stripControlChars, Wt as configStringRefusal, X as collectPublishedOutputNames, Xi as isRetryableTransientError, Xn as S3StateBackend, Xr as getLegacyStateBucketName, Xt as STATE_RESOURCES_MALFORMED, Y as collectDeclaredOutputNames, Yi as isMarkedNonRetryable, Yn as LockManager, Yr as getDefaultStateBucketName, Yt as requireConfigString, Z as exportAliasCollisionScrubWarning, Zi as isThrottlingError, Zn as rebuildClientForBucketRegion, Zr as resolveApp, Zt as hasReadableOutputs, _ as isSecretBearingReferenceString, _i as processStackMessages, _n as s3BucketDomainName, _r as createAssetRedirectResolver, _t as isWaitAbandonedError, a as DeploymentEventsStore, ai as warnDeprecatedNoPrefixCliFlag, an as malformedResourcesWarning, ar as exportNamesCarriedFrom, at as clearOnUpdateRemoval, b as bindingSkippedOutputs, bi as AwsClients, bn as s3BucketWebsiteUrl, br as AssetModeResolver, bt as deleteIndeterminateGuards, c as replayFailedOperations, ci as MIGRATE_TMP_PREFIX, cn as refuseMalformedOutputs, cr as importableOutputs, ct as createMaskedRetryLogger, d as updatePartialReason, di as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, dn as repairMalformedOutputsForReadOnly, dr as orphansCarriedFrom, dt as beginCommandInterruptScope, ea as markRedactedCause, ei as resolveSkipPrefix, en as malformedExportSourceWarning, er as forceQuitRecoveryClause, et as secretBearingStateKeyWarning, f as withResourceDeadline, fi as expectedOwnerParam, fn as repairMalformedResourcesForReadOnly, fr as shouldRetainResource, ft as endCommandInterruptScope, g as bagHoldsSecretExpression, gi as AssemblyReader, gn as s3BucketArn, gr as buildAssetRedirectMap, gt as CloudControlProvider, h as computeImplicitDeleteEdges, hi as derivePartitionAndUrlSuffix, hn as withSharedDrainBudget, hr as WorkGraph, ht as startInterruptWatch, i as DeploymentEventsReader, ii as stateBucketExistenceConfirmed, in as malformedRenderedContainersWarning, ir as DEFAULT_STATE_PREFIX, it as collectInlinePolicyNamesManagedBySiblings, j as makeSiblingClaimReader, ji as IntrinsicResolutionRefusalError, jn as TemplateParser, jr as describeAwsFailure, jt as isNamelessDynamicReferenceError, k as makeCanonicalizePropertiesFn, ki as DeployCancelledError, kn as withRetry, kr as validateContainerRepoName, kt as getAccountInfo, l as replayRollback, li as findLargeInlineResources, ln as refuseMalformedOutputsForDestroy, lr as isReadableBag, lt as maskDeep, m as IMPLICIT_DELETE_DEPENDENCIES, mi as canonicalizeRegion, mn as producerRegionsFromState, mr as stringifyValue, mt as isInterruptedWaitError, n as DEFAULT_RESOURCE_WARN_AFTER_MS, ni as resolveStateBucketWithDefaultAndSource, nn as malformedOutputsRefusalMessage, nr as shellQuote, nt as getCurrentResourceSecrets, o as planFailedOps, oi as CFN_TEMPLATE_BODY_LIMIT, on as malformedStateRefusalMessage, or as hasReadableExportSet, ot as ProviderRegistry, p as maskingRetryLogger, pi as PARTITION_TABLE, pn as classifyReplaySecretRegion, pr as AssetPublisher, pt as interruptWatchListenerCount, q as red, qi as normalizeAwsError, qn as redactSecretsForState, qr as Synthesizer, qt as requireConfigArray, r as DeployEngine, ri as resolveUseCdkBootstrapAssets, rn as malformedOutputsWarning, rr as CUSTOM_RESOURCE_RESPONSE_PREFIX, rt as IAMRoleProvider, s as planRollback, si as CFN_TEMPLATE_URL_LIMIT, sn as refuseMalformedNestedChildOutputs, sr as importableOutputKeys, st as wouldReturnToSdkProvider, t as DEFAULT_RESOURCE_TIMEOUT_MS, ta as retryClassificationText, ti as resolveStateBucketWithDefault, tn as malformedLocalOutputsWarning, tr as formatLockExpiry, tt as secretSafeKeyDisplay, u as updatePartialMessage, ui as uploadCfnTemplate, un as refuseMalformedState, ur as orphansAfterRollback, ut as maskerOrIdentity, v as keptWholeReasonText, vn as s3BucketDualStackDomainName, vr as loadPublishableAssetManifest, vt as slowCcOperationTimeoutMs, w as ccRoutedFinalSnapshotError, wn as DiffCalculator, wr as getBootstrapMarkerKey, wt as IntrinsicFunctionResolver, x as ATOMIC_FINAL_SNAPSHOT_TYPES, xi as getAwsClients, xn as applyRoleArnIfSet, xr as BOOTSTRAP_MARKER_PREFIX, xt as deleteSkipReason, y as mergeNoChangeOutputs, yi as resolveBucketRegion, yn as s3BucketRegionalDomainName, yr as rewriteTemplateAssetReferences, yt as UNSPECIFIED_SKIP_REASON, z as isWarmThroughputDecrease, zi as ResourceTimeoutError, zn as errorCauseChain, zr as partitionSensitiveEnv, zt as assertRegionMatch } from "./deploy-engine-DuiVEV9B.js";
2
2
  import { C as displaySafe, S as displayIdent, _ as withSkipPrefix, b as STACK_REF_MAX_CODE_POINTS, 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, w as truncateCodePoints, x as UNRENDERABLE } from "./logger-C17H06QA.js";
3
- import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
4
- import { t as getCdkdVersion } from "./version-1FTpsWNc.js";
5
- import { a as renderDisableCommand, i as protectedReplacementAdvice, r as UNNAMEABLE_ID_CLAUSE, t as ASGProvider } from "./asg-provider-CvkcE8rH.js";
3
+ import { n as awsClientDefaults } from "./aws-client-defaults-BoIxyCir.js";
4
+ import { t as getCdkdVersion } from "./version-Da2VV388.js";
5
+ import { a as renderDisableCommand, i as protectedReplacementAdvice, r as UNNAMEABLE_ID_CLAUSE, t as ASGProvider } from "./asg-provider-Di5-tD3l.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";
@@ -100,8 +100,10 @@ const STATE_FILE_SUFFIX = "/state.json";
100
100
  * The twin of `S3StateBackend`'s `LEGACY_KEY_DEPTH`, which counts the
101
101
  * `state.json` segment as well; this one counts what remains after the prefix
102
102
  * and the suffix are stripped, so it is that value minus one. Fenced against it
103
- * by a unit test rather than imported, because a value import from
104
- * `s3-state-backend.js` breaks the suites that mock that module.
103
+ * by a unit test rather than imported: a value import from `s3-state-backend.js`
104
+ * breaks every suite that mocks that module. That is the reason, and it is
105
+ * specific to THAT module — this file does import values from `display-safe.js`,
106
+ * which is a leaf with no imports of its own.
105
107
  */
106
108
  const LEGACY_SEGMENTS_UNDER_PREFIX = 1;
107
109
  /**
@@ -172,9 +174,19 @@ async function listAllLockKeys(stateBackend) {
172
174
  return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(LOCK_FILE_SUFFIX));
173
175
  }
174
176
  /**
175
- * `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
176
- * `{prefix}/{stack}/state.json` → `stack`. Pass {@link LOCK_FILE_SUFFIX} to
177
- * describe lock keys.
177
+ * Split `{prefix}/{stack}/{region}/state.json` into its parts; legacy
178
+ * `{prefix}/{stack}/state.json` yields a `region`-less result. Pass
179
+ * {@link LOCK_FILE_SUFFIX} to parse lock keys.
180
+ *
181
+ * STRUCTURAL, so a caller that needs the halves never has to re-parse a
182
+ * RENDERED string. `gc.ts` did exactly that — it ran `/^(\S+) \((\S+)\)$/` over
183
+ * `describeStateKey`'s own output to build a `cdkd state show ...` command —
184
+ * and that re-parse was already fragile before this change (`\S` excludes
185
+ * `\r`, so a planted carriage return silently took the no-match branch and put
186
+ * the WHOLE rendered string in the command's stack-name position). Sanitising
187
+ * the rendered form would have broken the re-parse outright; returning the
188
+ * parts removes the question. Issue
189
+ * [#3179](https://github.com/go-to-k/cdkd/issues/3179) section C.
178
190
  *
179
191
  * The shape heuristic below cannot be made exact, and it errs in BOTH
180
192
  * directions -- a region it fails to recognise is reported AS the stack name,
@@ -205,18 +217,124 @@ async function listAllLockKeys(stateBackend) {
205
217
  * `MyStack`. Depth cannot tell that apart from a real `{stack}/{region}`; only
206
218
  * the shape of the tail can, so the heuristic keeps that case.
207
219
  */
208
- function describeStateKey(key, suffix = STATE_FILE_SUFFIX, knownPrefix) {
220
+ function parseStateKey(key, suffix = STATE_FILE_SUFFIX, knownPrefix) {
209
221
  const body = key.slice(0, -suffix.length);
210
222
  const prefix = knownPrefix?.replace(/\/+$/, "");
211
223
  if (prefix !== void 0 && prefix !== "" && key.startsWith(`${prefix}/`)) {
212
224
  const rest = body.slice(prefix.length + 1).split("/");
213
- if (rest.length === LEGACY_SEGMENTS_UNDER_PREFIX && rest[0]) return rest[0];
225
+ if (rest.length === LEGACY_SEGMENTS_UNDER_PREFIX && rest[0]) return { stack: rest[0] };
214
226
  }
215
227
  const segments = body.split("/");
216
228
  const last = segments[segments.length - 1] ?? key;
217
229
  const secondLast = segments[segments.length - 2];
218
- if (secondLast !== void 0 && secondLast !== "" && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
219
- return last;
230
+ if (secondLast !== void 0 && secondLast !== "" && REGION_SEGMENT.test(last)) return {
231
+ stack: secondLast,
232
+ region: last
233
+ };
234
+ return { stack: last };
235
+ }
236
+ /**
237
+ * `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
238
+ * `{prefix}/{stack}/state.json` → `stack`. Pass {@link LOCK_FILE_SUFFIX} to
239
+ * describe lock keys.
240
+ *
241
+ * The split is {@link parseStateKey}'s; this renders it for a HUMAN, through
242
+ * `displayIdent`, which is the whole of what changed for issue
243
+ * [#3179](https://github.com/go-to-k/cdkd/issues/3179) section C. Both halves
244
+ * come out of an S3 KEY, and a state-bucket key is not cdkd's to trust: anyone
245
+ * holding `s3:PutObject` on the bucket chooses it, the same premise
246
+ * go-to-k/cdkd#3207 rests on for the record BODY. Every reader of this string
247
+ * puts it in front of an operator who is deciding whether to destroy something,
248
+ * so an unbounded, unsanitised value is read as cdkd's own words.
249
+ *
250
+ * Measured on this tree before the change, with `cdkd/` as the known prefix:
251
+ * a key segment `Prod<ESC>[2K<CR>SafeStack` rendered as itself, and a terminal
252
+ * ERASES the line and shows `SafeStack (us-east-1)` — a different stack than
253
+ * the record names. `displayIdent` sanitises to ASCII, caps the length, and
254
+ * quotes anything outside the plain-identifier set, so that segment now renders
255
+ * visibly quoted and visibly altered.
256
+ *
257
+ * The stack half takes `STACK_REF_MAX_CODE_POINTS` rather than the default cap,
258
+ * matching `state.ts`'s `formatStackRefSafe`: a cdkd state-record stack name is
259
+ * NOT bounded by CloudFormation's 128, because a nested-stack child's name is
260
+ * minted recursively as `${parent}~${logicalId}`.
261
+ *
262
+ * What this does NOT make safe is a value INTERPOLATED INTO A COMMAND — see
263
+ * {@link isPasteableIdent}, which exists because `displayIdent` correctly
264
+ * renders `--state-bucket=attacker` bare (every character of it is a plain
265
+ * identifier character) while that value is a flag, not a name.
266
+ */
267
+ function describeStateKey(key, suffix = STATE_FILE_SUFFIX, knownPrefix) {
268
+ const { stack, region } = parseStateKey(key, suffix, knownPrefix);
269
+ const name = displayIdent(stack, { maxCodePoints: STACK_REF_MAX_CODE_POINTS });
270
+ return region === void 0 ? name : `${name} (${displayIdent(region)})`;
271
+ }
272
+ /**
273
+ * The shape a state-key segment must have before it may be interpolated into a
274
+ * command cdkd invites an operator to PASTE.
275
+ *
276
+ * An ALLOW-LIST, and deliberately so: the first cut of this predicate was a
277
+ * deny-list that refused a leading `-`, and review defeated it with a leading
278
+ * `~`. A deny-list has to enumerate every character a shell treats specially
279
+ * BEFORE cdkd sees the word, and the set of things nobody thought of is
280
+ * unbounded. The repo already settled this once — `rollback-executor.ts`'s
281
+ * `PASTEABLE_LOGICAL_ID` is `/^[A-Za-z0-9]{1,255}$/`, with a comment naming
282
+ * `~user` and `=x` by name.
283
+ *
284
+ * This one is wider than that because the values here are not logical ids: a
285
+ * cdkd state record's stack name carries `~` (a nested-stack child is minted
286
+ * `${parent}~${logicalId}`) and a region carries `-`. What it keeps from the
287
+ * precedent is the LEADING character, which is where the danger is. MEASURED
288
+ * in bash on a real host rather than reasoned:
289
+ *
290
+ * ~root -> /var/root ~/x -> $HOME/x ~- -> $OLDPWD
291
+ * a=~/x -> a=$HOME/x
292
+ * Parent~Child -> inert a:~/x -> inert
293
+ *
294
+ * So `~` is dangerous only in the leading position or straight after `=`,
295
+ * which is why a medial `~` stays in the set and `=` stays out of it entirely.
296
+ * A leading digit or letter also closes the option case: `--state-bucket=x`
297
+ * cannot match, and that one is NOT about shell quoting — it is still an
298
+ * OPTION after quoting, which is why the answer is refusal rather than quoting.
299
+ */
300
+ const PASTEABLE_STATE_IDENT = /^[A-Za-z0-9][A-Za-z0-9~_.-]*$/;
301
+ /**
302
+ * The cap for rendering a whole state or lock KEY, as opposed to one segment.
303
+ *
304
+ * A key CONTAINS a stack name, so capping it at `STACK_REF_MAX_CODE_POINTS`
305
+ * cuts a legitimate one: the fixed segments around the name — `{prefix}/`,
306
+ * `/{region}/` and `state.json` / `lock.json` — are not free. The extra 64
307
+ * covers them with roughly 37 characters left for the prefix — measured, not
308
+ * generous: at a full-budget stack name a longer `--state-prefix` still
309
+ * overflows, and the cost of that is cosmetic. It matters because a key renders
310
+ * `[cut: N more characters withheld]` is at its least useful inside the very
311
+ * sentence that tells an operator to go and inspect THAT key.
312
+ *
313
+ * Same status as the constant it builds on, and stated for the same reason: a
314
+ * BUDGET, not an enforced bound. Nothing in `src/` validates key length.
315
+ */
316
+ const STATE_KEY_MAX_CODE_POINTS = STACK_REF_MAX_CODE_POINTS + 64;
317
+ /**
318
+ * Is this value safe to interpolate into a command we tell an operator to RUN?
319
+ *
320
+ * Two independent tests, and BOTH are load-bearing:
321
+ *
322
+ * 1. {@link PASTEABLE_STATE_IDENT}, which decides the shell question — see its
323
+ * own note for why it is an allow-list and what was measured.
324
+ * 2. It renders byte-identically through `displayIdent`, which adds the LENGTH
325
+ * cap and the ASCII rule the regex does not carry, asked through the public
326
+ * API rather than by re-spelling `PLAIN_IDENT` (a second spelling of a
327
+ * security predicate is how the two drift).
328
+ *
329
+ * Deliberately NOT shell-quoting instead. Quoting answers test 2's population
330
+ * and none of the option case — `'--state-bucket=attacker'` is still parsed as
331
+ * a flag — and a command that LOOKS runnable is the thing being handed over, so
332
+ * the honest answer for a value failing either test is to print the S3 key and
333
+ * let the operator decide.
334
+ */
335
+ function isPasteableIdent(value) {
336
+ if (!PASTEABLE_STATE_IDENT.test(value)) return false;
337
+ return displayIdent(value, { maxCodePoints: STACK_REF_MAX_CODE_POINTS }) === value;
220
338
  }
221
339
 
222
340
  //#endregion
@@ -246,7 +364,7 @@ function parseContextOptions(contextArgs) {
246
364
  const commonOptions = [
247
365
  new Option("--verbose", "Enable verbose logging").default(false),
248
366
  new Option("--profile <profile>", "AWS profile"),
249
- new Option("--role-arn <arn>", "IAM role ARN to assume for AWS API calls (env: CDKD_ROLE_ARN); the role needs the actions for the resource types you deploy plus the bookkeeping actions cdkd itself issues, because cdkd makes raw service API calls and does not route through CloudFormation, so CDK CLI deploy-roles will NOT work. Do not combine with --profile: the SDK then ignores the assumed role"),
367
+ new Option("--role-arn <arn>", "IAM role ARN to assume for AWS API calls (env: CDKD_ROLE_ARN); the role needs the actions for the resource types you deploy plus the bookkeeping actions cdkd itself issues, because cdkd makes raw service API calls and does not route through CloudFormation, so CDK CLI deploy-roles will NOT work. Combines with --profile: the profile supplies the base credentials the role is assumed FROM, and the role is what every later AWS call runs as"),
250
368
  new Option("-y, --yes", "Automatically answer interactive prompts with the recommended response (e.g. confirm destroy)").default(false)
251
369
  ];
252
370
  /**
@@ -1689,10 +1807,10 @@ async function bootstrapDestroyCommand(options) {
1689
1807
  if (options.includeStateBucket) {
1690
1808
  const stateKeys = await listAllStateKeys(stateBackend);
1691
1809
  if (stateKeys.length > 0) {
1692
- const listing = stateKeys.map((k) => ` - ${describeStateKey(k, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${k}]`).join("\n");
1810
+ const listing = stateKeys.map((k) => ` - ${describeStateKey(k, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${displayIdent(k, { maxCodePoints: STATE_KEY_MAX_CODE_POINTS })}]`).join("\n");
1693
1811
  throw new CdkdError(`Refusing to delete state bucket '${bucketName}': ${stateKeys.length} stack(s) still have state in it:\n${listing}\nDestroy every stack ('cdkd destroy' / 'cdkd state destroy') before deleting the state bucket.`, "STATE_BUCKET_NOT_EMPTY");
1694
1812
  }
1695
- if (markerSiblings.otherRegions.length > 0) throw new CdkdError(`Refusing to delete state bucket '${bucketName}': region(s) ${markerSiblings.otherRegions.join(", ")} are still opted in to cdkd asset storage (their bootstrap markers live in this bucket). Run 'cdkd bootstrap --destroy --region <r>' for each first.`, "STATE_BUCKET_HOLDS_MARKERS");
1813
+ if (markerSiblings.otherRegions.length > 0) throw new CdkdError(`Refusing to delete state bucket '${bucketName}': region(s) ${markerSiblings.otherRegions.map((r) => displayIdent(r)).join(", ")} are still opted in to cdkd asset storage (their bootstrap markers live in this bucket). Run 'cdkd bootstrap --destroy --region <r>' for each first.`, "STATE_BUCKET_HOLDS_MARKERS");
1696
1814
  if (markerSiblings.sameRegionKeys.length > 0) {
1697
1815
  const canonicalStillPresent = markerBody !== null && resolvedMarkerKey === markerKey;
1698
1816
  const listing = markerSiblings.sameRegionKeys.map((k) => canonicalStillPresent ? ` - ${k}` : ` - ${k} (cdkd bootstrap --destroy --region ${markerRegionOfKey(k)})`).join("\n");
@@ -3463,420 +3581,6 @@ function recordRunFailed(recorder, stackName, error) {
3463
3581
  });
3464
3582
  }
3465
3583
 
3466
- //#endregion
3467
- //#region src/state/malformed-resources-bag.ts
3468
- /** The error code every malformed-record refusal carries, whatever its class. */
3469
- const STATE_RESOURCES_MALFORMED = "STATE_RESOURCES_MALFORMED";
3470
- /**
3471
- * Answer the ONE question both helpers below key on: can this record's
3472
- * resource map be read at all?
3473
- *
3474
- * Widened past `null` / `undefined` because the message says "no readable
3475
- * resources map" and a hand-edited record does not stop at those two: a
3476
- * `[]`, a `5` or a `"ab"` all survive `Object.entries` and yield a bag that is
3477
- * empty or, for the string, absurd — repaired silently and warned about
3478
- * nowhere. The predicate is the plain-object test the bag's own type implies.
3479
- */
3480
- function hasReadableResources(state) {
3481
- return isReadableBag(state.resources);
3482
- }
3483
- /**
3484
- * The same question for the `outputs` bag — and it is a SEPARATE predicate
3485
- * rather than a second argument to the one above because the ABSENCE rule
3486
- * differs, which is the split {@link isReadableBag}'s own note calls a
3487
- * per-container call.
3488
- *
3489
- * An absent `outputs` bag is an ordinary record, not a defect: `cdkd scrub`
3490
- * round-trips one deliberately rather than materializing `{}` over it, and the
3491
- * deploy's failure-path saves write `outputs: currentState.outputs`, which
3492
- * `JSON.stringify` drops when it is `undefined`. So `undefined` is EXEMPT here
3493
- * while it is a defect for `resources`. `null` is NOT exempt — it is a value a
3494
- * hand-edited record carries and every consumer either throws on it or
3495
- * launders it into `{}`.
3496
- *
3497
- * Both {@link repairMalformedOutputsForReadOnly} and
3498
- * {@link refuseMalformedOutputs} delegate to this, so the read-only and the
3499
- * write-capable halves cannot come to different verdicts about the same record.
3500
- */
3501
- function hasReadableOutputs(state) {
3502
- return state.outputs === void 0 || isReadableBag(state.outputs);
3503
- }
3504
- /**
3505
- * The shared explanation, in the terms the reader needs: what is wrong, what
3506
- * to look at, and what NOT to do next.
3507
- *
3508
- * Both identifiers are SANITIZED and THEN SHELL-QUOTED, and the command is
3509
- * emitted LAST and UNWRAPPED — the shape `lock-contention-message.ts` and
3510
- * `.claude/rules/layout-state-types.md` require of any suggestion a user is
3511
- * meant to paste, for two separate reasons.
3512
- *
3513
- * Sanitizing alone is not enough. `displaySafe(..., { asciiOnly: true })` is a
3514
- * printable-ASCII allowlist, so it removes the line- and escape-forgery class
3515
- * but KEEPS `'`, `;`, `|`, `` ` ``, `$` and spaces — and neither name is
3516
- * trusted here, since a stack name reaches the cross-stack read path from an
3517
- * `Fn::GetStackOutput` argument or an S3 key. A name spelled
3518
- * `a'; curl http://x|sh; echo '` would close the quoting and append its own
3519
- * command to the line this text tells the user to RUN.
3520
- *
3521
- * Wrapping the command in `'...'` is not enough either, and is what makes the
3522
- * two compose badly: `shellQuote` does its own quoting, so an outer wrapper
3523
- * produces something unpastable. Hence unwrapped and last.
3524
- *
3525
- * An identifier that sanitizes to EMPTY becomes `UNRENDERABLE` rather than
3526
- * nothing — an empty argument makes `--stack-region` swallow the next flag,
3527
- * turning a remedy into a differently-broken command.
3528
- *
3529
- * MODULE-PRIVATE, and it stayed that way after go-to-k/cdkd#3206's review
3530
- * considered exporting it. `cdkd scrub`'s audited-record refusal needed the
3531
- * same three properties (sanitize, cap, `UNRENDERABLE` on empty) plus a
3532
- * BOUNDARY, because its names go into a comma-joined list where a name
3533
- * containing the delimiter forges an entry. `displayIdent` has all four —
3534
- * JSON-quoting escapes the delimiter it adds — so that caller uses it and this
3535
- * stays private. The pair here is not duplicated: what differs is the
3536
- * `shellQuote` composition every message in THIS module needs and that one
3537
- * must not have.
3538
- */
3539
- function safeIdentifier(value) {
3540
- const safe = displaySafe(value, { asciiOnly: true });
3541
- if (!safe) return UNRENDERABLE;
3542
- const { text, truncated } = truncateCodePoints(safe, 128);
3543
- return truncated ? `${text}...` : text;
3544
- }
3545
- function malformedStateDetail(stackName, region) {
3546
- const stack = safeIdentifier(stackName);
3547
- const reg = safeIdentifier(region);
3548
- return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'resources' map — the record is malformed or truncated. Do NOT run 'cdkd deploy' or 'cdkd destroy' against it: both read the same map, and an unreadable one is indistinguishable from an empty stack, so deploy would re-CREATE every resource and destroy would delete none of them. Inspect it with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
3549
- }
3550
- /**
3551
- * For a READ-ONLY command: give the record an empty resource bag so the
3552
- * command can report on it, and say so (issue go-to-k/cdkd#3018).
3553
- *
3554
- * `parseStateBody` deliberately does not validate the inner shape, so a
3555
- * hand-edited or truncated record reaches a command with `resources` absent,
3556
- * `null`, or some other non-object. Every such command then died on a raw
3557
- * `TypeError` from the first `Object.keys` / `in` / index read — the exact
3558
- * wording differs per shape (`Cannot convert undefined or null to object`,
3559
- * `Cannot use 'in' operator`, `Cannot read properties of null`), and none of
3560
- * them names a stack, a key or a remedy, from exactly the commands a user
3561
- * reaches for WHEN the state is broken.
3562
- *
3563
- * WHY AT THE LOAD AND NOT AT EACH LOOP. The first cut of #3018 put `?? {}` on
3564
- * the twelve `Object.entries(state.resources)` loops, which was inert: every
3565
- * one of those flows dereferences the bag EARLIER — `!(id in state.resources)`,
3566
- * `hasOwnProperty.call(...)`, `state.resources[logicalId]` — so the abort still
3567
- * happened one line up and the guards only made it look handled. One call per
3568
- * load dominates the whole flow behind it, including the helpers that take the
3569
- * state as a parameter (`orphan-rewriter`, `diff-calculator`,
3570
- * `collectCcApiRoutes`).
3571
- *
3572
- * Mutates in place and returns whether it repaired anything; the caller warns
3573
- * on `true`. A silent repair is its own defect — an empty resource set is
3574
- * indistinguishable from a healthy empty stack in every later line of output,
3575
- * so `No changes detected` over an unreadable record is a clean verdict about
3576
- * nothing.
3577
- *
3578
- * **Only for a command that cannot WRITE state.** Anything that can persist
3579
- * uses {@link refuseMalformedState} instead; see its note.
3580
- */
3581
- function repairMalformedResourcesForReadOnly(state) {
3582
- if (hasReadableResources(state)) return false;
3583
- state.resources = {};
3584
- return true;
3585
- }
3586
- /**
3587
- * The refusal TEXT, for a command whose own exit-code contract means this
3588
- * cannot be a plain `CdkdError`.
3589
- *
3590
- * `cdkd scrub` is the case: its exit `1` is SPOKEN FOR ("--fail found
3591
- * plaintext") and every one of its refusals carries `exitCode = 2`, because a
3592
- * CI gate reading the code alone must be able to tell "scrub looked and found
3593
- * a leak" from "scrub refused to look" — the two call for opposite responses.
3594
- * So scrub tests {@link hasReadableResources} and raises its own class around
3595
- * this text rather than calling {@link refuseMalformedState}.
3596
- *
3597
- * The other three refusing commands do NOT share that need, and giving them a
3598
- * single shared code would be wrong in the other direction: `cdkd rollback`
3599
- * documents `2` as "PARTIAL — journal kept, idempotent re-run", so a `2` here
3600
- * would tell an operator to re-run a command that attempted nothing.
3601
- */
3602
- function malformedStateRefusalMessage(stackName, region) {
3603
- return `${malformedStateDetail(stackName, region)} This command can WRITE state, so it refuses rather than continuing: saving over a record whose resource map could not be read would replace the evidence with a well-formed empty one and lose it permanently. Repair or remove the record first.`;
3604
- }
3605
- /**
3606
- * The warning a view emits when it emptied one or more {@link
3607
- * RenderedStateContainer}s it could not walk (go-to-k/cdkd#3187).
3608
- *
3609
- * Deliberately NOT {@link malformedResourcesWarning}'s text, and the reason is
3610
- * narrower than it looks. That one tells the reader not to run `cdkd deploy` /
3611
- * `cdkd destroy` because an unreadable `resources` MAP is indistinguishable
3612
- * from an empty stack: deploy would re-CREATE everything and destroy would
3613
- * delete nothing. No container named here can produce that specific confusion —
3614
- * the resource SET is still readable — so borrowing that sentence would attach
3615
- * a re-create-the-world warning to a record whose resource list is intact.
3616
- *
3617
- * It is NOT that these containers are display-only. `properties` is read by the
3618
- * deploy change calculation (`src/analyzer/diff-calculator.ts`), which imports
3619
- * nothing from this module, and a non-object there compares unequal to any
3620
- * desired object and yields a spurious property-change set — filed as
3621
- * go-to-k/cdkd#3191. An earlier revision of this comment asserted the
3622
- * display-only premise, which would have read as licence to drop a guard on
3623
- * that path (review of go-to-k/cdkd#3190).
3624
- *
3625
- * What stays the same is the remedy: `--json` is the mode that shows the
3626
- * stored value.
3627
- *
3628
- * ONE warning per record however many containers it names — a stack whose 500
3629
- * resources all carry a hand-edited `properties` gets one line, not 500. The
3630
- * caller FILTERS a fixed order (`RENDERED_CONTAINER_ORDER`), so the text is
3631
- * stable across records. Not `.sort()` — that order is deliberately not
3632
- * alphabetical, and its own JSDoc says so.
3633
- *
3634
- * Both identifiers are sanitized and then shell-quoted, and the command is
3635
- * emitted LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s own note
3636
- * gives.
3637
- *
3638
- * The container NAMES take {@link safeIdentifier} too — the SAME helper, so
3639
- * they cannot drift from it — but NOT `shellQuote`, because they appear in the
3640
- * PROSE and never inside the command this text tells the reader to run; the
3641
- * command carries the two identifiers and nothing else.
3642
- *
3643
- * Sanitizing them is not dead code written for a case that cannot happen. The
3644
- * union above is closed at COMPILE time, so without this the guarantee would
3645
- * live in a comment, and the day a caller derives a name from a record instead
3646
- * of from a literal an unsanitized element could forge a line, render as empty
3647
- * quotes naming nothing, or run to kilobytes and push the remedy command off
3648
- * the reader's screen. `safeIdentifier` closes all three and is the identity on
3649
- * all four literals, so no user-visible text moves.
3650
- *
3651
- * What it does NOT close, stated rather than reassured away: a forged name made
3652
- * only of printable ASCII still renders verbatim inside its quotes and could
3653
- * read as prose. That residual is bounded to the PROSE — the command the reader
3654
- * pastes is built from the two shell-quoted identifiers alone — and closing it
3655
- * would mean JSON-quoting a name in the one place the text is meant to read as
3656
- * English (review of go-to-k/cdkd#3190).
3657
- */
3658
- function malformedRenderedContainersWarning(stackName, region, containers) {
3659
- const stack = safeIdentifier(stackName);
3660
- const reg = safeIdentifier(region);
3661
- const names = containers.map((name) => `'${safeIdentifier(name)}'`).join(", ");
3662
- return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has a non-object ${names} — the record is malformed or truncated. 'Object.entries' walks a string or a list as readily as a map, so rendering one INVENTS a row per character or element. Continuing with it EMPTY: this view shows no rows there, which is not the same as the record holding none. A per-resource container is named once however many resources hold one. See the stored values with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
3663
- }
3664
- /** The warning a caller of {@link repairMalformedResourcesForReadOnly} emits. */
3665
- function malformedResourcesWarning(stackName, region) {
3666
- return `${malformedStateDetail(stackName, region)} Continuing with an EMPTY resource set: this command's output describes zero resources, which is not the same as the stack having none.`;
3667
- }
3668
- /**
3669
- * The `outputs` half of {@link repairMalformedResourcesForReadOnly}, for a
3670
- * READ-ONLY command that DIFFS the stored bag rather than rendering it
3671
- * (go-to-k/cdkd#3189).
3672
- *
3673
- * `computeOutputsDiff` enumerates the stored bag twice, the second walk
3674
- * emitting one `REMOVE` row per stored key today's template no longer declares.
3675
- * It used to admit the bag on a bare `?? {}`, which covers `null` and
3676
- * `undefined` only — so a hand-edited or truncated record whose `outputs` holds
3677
- * a string or a list previewed the REMOVAL of outputs that never existed, one
3678
- * row per character or element, each carrying a character of the record as its
3679
- * `old:` side, with `cdkd diff --fail` exiting 1 on them.
3680
- *
3681
- * AT THE LOAD, for the reason
3682
- * {@link repairMalformedResourcesForReadOnly}'s own note records and one more
3683
- * that is specific to this container: `cdkd diff` dereferences the stored bag
3684
- * BEFORE the walk that fabricates. `resolveTemplateOutputs` asks
3685
- * `hasOwnProperty.call(storedOutputs, key)` for go-to-k/cdkd#2740's
3686
- * skipped-output record and for go-to-k/cdkd#1942's literal `Export.Name`
3687
- * verdict — which ANSWERS TRUE on a string for `'0'` or `'length'`, and THROWS
3688
- * outright on `null` (`Cannot convert undefined or null to object`). So a guard
3689
- * written at `computeOutputsDiff` alone leaves a wrong decision, or a raw
3690
- * `TypeError`, one call above it.
3691
- *
3692
- * ABSENT IS EXEMPT AND `null` IS NOT, which is the one place this diverges from
3693
- * {@link isReadableBag}'s verdict, and the split is measured rather than
3694
- * stylistic. Both stored-bag lookups named above gate on
3695
- * `storedOutputs !== undefined` before the `hasOwnProperty` call, so an ABSENT
3696
- * bag never reaches one; every other consumer on the diff path carries its own
3697
- * `?? {}` (`mergeNoChangeOutputs`'s `persisted`, `importableOutputKeys`, this
3698
- * function's own walk). An absent bag is therefore inert, exactly the condition
3699
- * under which `state.ts`'s `repairRenderedContainers` exempts it — and warning
3700
- * would be a false positive on a record cdkd itself supports: `cdkd scrub`
3701
- * round-trips a record with no `outputs` deliberately, refusing to materialize
3702
- * `{}` over it, and the deploy's failure-path saves write
3703
- * `outputs: currentState.outputs`, which `JSON.stringify` drops when it is
3704
- * undefined. A `null` bag is NOT inert — it passes the `!== undefined` gate and
3705
- * `hasOwnProperty.call(null, ...)` throws — so it is repaired and warned about
3706
- * like any other unreadable shape.
3707
- *
3708
- * That leaves one clause of go-to-k/cdkd#3189's stated floor overridden on
3709
- * purpose: it asked that a `null` bag "diff exactly as it does today and say
3710
- * nothing", and diffing exactly as it does today means THROWING. Recorded on
3711
- * the issue rather than silently traded.
3712
- *
3713
- * Mutates in place and returns whether it repaired anything; the caller warns
3714
- * on `true` with {@link malformedOutputsWarning}. Read-only commands ONLY, for
3715
- * the reason {@link refuseMalformedState} gives — a bag laundered into a
3716
- * well-formed empty one is permanent, and for `outputs` it would also take the
3717
- * exports index with it on the next write.
3718
- */
3719
- function repairMalformedOutputsForReadOnly(state) {
3720
- if (hasReadableOutputs(state)) return false;
3721
- state.outputs = {};
3722
- return true;
3723
- }
3724
- /**
3725
- * The warning a caller of {@link repairMalformedOutputsForReadOnly} emits.
3726
- *
3727
- * Deliberately neither {@link malformedResourcesWarning}'s text nor
3728
- * {@link malformedRenderedContainersWarning}'s, because the CONSEQUENCE of
3729
- * continuing empty differs from both. The resources text forbids
3730
- * `cdkd deploy` / `cdkd destroy` because an unreadable resource MAP is
3731
- * indistinguishable from an empty stack; the resource set is intact here. The
3732
- * rendered-containers text says the view "shows no rows there" — true of a
3733
- * renderer, false of a diff, which does not go quiet on an empty stored bag but
3734
- * reports every resolved output as an `ADD`. Saying so is the point: an
3735
- * operator who reads `ADD` rows for outputs the stack already has needs to know
3736
- * the comparison lost its left-hand side.
3737
- *
3738
- * Identifiers are sanitized and then shell-quoted and the command is emitted
3739
- * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
3740
- */
3741
- function malformedOutputsWarning(stackName, region) {
3742
- const stack = safeIdentifier(stackName);
3743
- const reg = safeIdentifier(region);
3744
- return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map — the record is malformed or truncated. Where the stored value is a string or a list, 'Object.entries' walks it as readily as a map, so diffing it INVENTS a REMOVE row per character or element carrying the record's own characters; where it is a number, a boolean or null, it yields no comparison at all. Continuing with it EMPTY: every output this diff resolves is reported as an ADD and no stored key is reported as a REMOVE, which is not the same as the record holding none. See the stored value with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
3745
- }
3746
- /**
3747
- * For a command that can WRITE state: refuse the record by name instead of
3748
- * repairing it.
3749
- *
3750
- * Repairing is actively DANGEROUS here, and the PR that introduced this module
3751
- * shipped that danger for one round before its security review caught it.
3752
- * `cdkd scrub` gates its `saveState` on `recordsChanged > 0`, which an OUTPUTS
3753
- * change alone satisfies — so a record holding `"resources": null` and a
3754
- * plaintext secret in `outputs` would be scrubbed, and then saved back with a
3755
- * repaired, WELL-FORMED `resources: {}`. The malformed record is the only
3756
- * signal that anything is wrong; laundering it into a legitimate-looking empty
3757
- * one is permanent and silent. The next `cdkd deploy` then reads zero resources
3758
- * and CREATES the whole stack a second time, and the next `cdkd destroy`
3759
- * deletes nothing and orphans every live resource — and the one-shot warning
3760
- * that would have said so is long gone, quite possibly for a different
3761
- * operator. `cdkd import --force` launders it the same way.
3762
- *
3763
- * Refusing still satisfies #3018, whose complaint was the BARE `TypeError`:
3764
- * this names the stack, the region, the defect and the remedy, and exits on a
3765
- * code rather than on a stack trace.
3766
- */
3767
- function refuseMalformedState(state, stackName, region) {
3768
- if (hasReadableResources(state)) return;
3769
- throw new CdkdError(malformedStateRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED);
3770
- }
3771
- /**
3772
- * The `outputs` half of {@link malformedStateRefusalMessage}, for the same
3773
- * reason that one is exported separately from the throw: `cdkd scrub`'s exit
3774
- * `1` is spoken for and it raises its own exit-2 class around this text.
3775
- *
3776
- * A DIFFERENT text from the `resources` refusal because the consequence of
3777
- * continuing differs, the same way the three warnings in this module differ. The
3778
- * `resources` message forbids `cdkd deploy` / `cdkd destroy` because an
3779
- * unreadable resource MAP is indistinguishable from an empty stack; the resource
3780
- * set is intact here. What is at stake instead is the SHARED exports index:
3781
- * `cdkd/_index/<region>/exports.json` is rebuilt from these bags, and a string
3782
- * bag published one fabricated export per character into the namespace every
3783
- * other stack's `Fn::ImportValue` binds against.
3784
- *
3785
- * Identifiers are sanitized and THEN shell-quoted and the command is emitted
3786
- * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
3787
- */
3788
- function malformedOutputsRefusalMessage(stackName, region) {
3789
- const stack = safeIdentifier(stackName);
3790
- const reg = safeIdentifier(region);
3791
- return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map — the record is malformed or truncated. This command can WRITE state, so it refuses rather than continuing: it REBUILDS the bag before saving, and 'Object.entries' walks a string or a list as readily as a map, so a six-character value would be saved back as a well-formed six-key map (a null one as an empty map). That replaces the only signal anything is wrong with a legitimate-looking record, permanently — and the next deploy republishes it into the shared exports index every other stack's Fn::ImportValue resolves against. Repair or remove the record first. Inspect it with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
3792
- }
3793
- /**
3794
- * The line `ExportIndexStore`'s rebuild emits for a producer record whose
3795
- * export set it could not read (issue go-to-k/cdkd#3192) — `outputs` not a
3796
- * plain object, or `exportNames` present and not an array.
3797
- *
3798
- * A THIRD outputs text rather than {@link malformedOutputsWarning} because the
3799
- * consequence is again different, which is the rule the two above already
3800
- * follow. That one describes a DIFF continuing with an empty left-hand side.
3801
- * This describes a record CONTRIBUTING NOTHING to a shared, region-wide index
3802
- * other stacks resolve against — so the symptom a reader will actually meet is
3803
- * a later `Fn::ImportValue` failing in a DIFFERENT stack, naming the consumer
3804
- * and not this record. Saying which producer dropped out is the whole value of
3805
- * the line.
3806
- *
3807
- * It says the rebuild CONTINUES, because it does: refusing would take every
3808
- * other producer in the region down with it, and the index is best-effort by
3809
- * design.
3810
- *
3811
- * Identifiers are sanitized and THEN shell-quoted and the command is emitted
3812
- * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
3813
- */
3814
- function malformedExportSourceWarning(stackName, region) {
3815
- const stack = safeIdentifier(stackName);
3816
- const reg = safeIdentifier(region);
3817
- return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map or 'exportNames' list — the record is malformed or truncated. It contributes NO exports to this region's index, which is not the same as the stack exporting none: an Fn::ImportValue of a name this stack really publishes will fail in the CONSUMER stack, naming that stack rather than this record. Continuing with the other producers — enumerating a string or a list here would instead publish one FABRICATED export per character or element. See the stored values with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
3818
- }
3819
- /**
3820
- * The line a READ-ONLY command emits when a record's `exportNames` FIELD could
3821
- * not be used (go-to-k/cdkd#3192 review) — present and not an array, or an
3822
- * array with no usable name in it.
3823
- *
3824
- * Why a message at all, when the predicate already fails closed: before this
3825
- * class was guarded, `cdkd diff` over such a record died with
3826
- * `TypeError: state.exportNames.filter is not a function`. Failing closed
3827
- * inside `importableOutputKeys` is right — it is a pure predicate reached from
3828
- * five commands and holds no stack identity — but silently replacing a LOUD
3829
- * wrong answer with a QUIET one is its own regression, and `cdkd diff` is
3830
- * exactly the caller that DOES hold the identity. Without this it warned about
3831
- * a damaged `outputs` bag on one line and said nothing about the damaged
3832
- * `exportNames` on the same record.
3833
- *
3834
- * Deliberately NOT {@link malformedOutputsWarning}'s text: that one is about
3835
- * the BAG, and the consequence differs. An unusable export SET does not lose
3836
- * the comparison's left-hand side — every stored key is still diffed — it
3837
- * makes the preview report no key as an export, so a row that would carry
3838
- * `[export]` renders without it.
3839
- *
3840
- * Identifiers are sanitized and THEN shell-quoted and the command is emitted
3841
- * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
3842
- */
3843
- function malformedExportNamesWarning(stackName, region) {
3844
- const stack = safeIdentifier(stackName);
3845
- const reg = safeIdentifier(region);
3846
- return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has an unusable 'exportNames' list — the record is malformed or truncated. It is read as an EMPTY export set, which is not the same as the record holding one: no stored key is reported as an export, so a row that should carry an '[export]' tag renders without it. Reading it as UNKNOWN instead would be worse — that falls back to the pre-v9 rule where every output name is importable. See the stored value with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
3847
- }
3848
- /**
3849
- * For a command that can WRITE state: refuse a record whose `outputs` bag
3850
- * cannot be read, instead of rebuilding it (issue go-to-k/cdkd#3192).
3851
- *
3852
- * The SIBLING of {@link refuseMalformedState}, and deliberately a second call
3853
- * rather than a widening of that one. A record can be malformed in either
3854
- * container alone, the two carry different consequences, and the refusal a user
3855
- * sees must name the container that is actually broken — a `resources` message
3856
- * printed over an intact resource map tells them not to run `cdkd deploy` for a
3857
- * reason that does not hold.
3858
- *
3859
- * Why REFUSE and not {@link repairMalformedOutputsForReadOnly}, which is the
3860
- * opposite answer for the same container one function up: repairing the bag and
3861
- * then saving it IS the laundering this is here to stop. Measured, not
3862
- * reasoned — `rewriteResourceReferences` turns `outputs: 'abcdef'` into
3863
- * `{"0":"a",…,"5":"f"}` and `cdkd orphan` saves that; `cdkd scrub`'s
3864
- * `redactUnaccountedOutputs` spreads the same string into a map and its
3865
- * `outputsChanged` compare then satisfies the `recordsChanged > 0` write gate;
3866
- * `cdkd import` carries a `null` bag through `?? {}`. Each one rewrites a
3867
- * damaged record into a well-formed one and the evidence is gone.
3868
- *
3869
- * CALL IT AT THE LOAD, beside {@link refuseMalformedState} and above the first
3870
- * expression that reads the bag — the placement rule
3871
- * {@link repairMalformedResourcesForReadOnly}'s note records, for the reason it
3872
- * records: a guard written at the rebuild leaves every earlier dereference in
3873
- * front of it.
3874
- */
3875
- function refuseMalformedOutputs(state, stackName, region) {
3876
- if (hasReadableOutputs(state)) return;
3877
- throw new CdkdError(malformedOutputsRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED);
3878
- }
3879
-
3880
3584
  //#endregion
3881
3585
  //#region src/state/export-index-store.ts
3882
3586
  /**
@@ -62452,6 +62156,7 @@ async function runDestroyForStack(stackName, state, ctx) {
62452
62156
  const resourceCount = Object.keys(state.resources).length;
62453
62157
  const orphanCount = (state.orphans ?? []).length;
62454
62158
  const regionForState = state.region ?? ctx.baseRegion;
62159
+ refuseMalformedOutputsForDestroy(state, stackName, regionForState);
62455
62160
  if (resourceCount === 0 && orphanCount === 0) {
62456
62161
  logger.info(`Stack ${stackName} has no resources, cleaning up state...`);
62457
62162
  let emptyLockHeld = false;
@@ -62872,7 +62577,7 @@ async function runDestroyForStack(stackName, state, ctx) {
62872
62577
  } catch (error) {
62873
62578
  renderer.removeTask(logicalId);
62874
62579
  const msg = error instanceof Error ? error.message : String(error);
62875
- if (!isInterruptedWaitError(error) && !isFinalSnapshotError(error) && !isMarkedNonRetryable(error) && (msg.includes("does not exist") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException"))) {
62580
+ if (!isInterruptedWaitError(error) && !isFinalSnapshotError(error) && !isMarkedNonRetryable(error) && !isWaitAbandonedError(error) && (msg.includes("does not exist") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException"))) {
62876
62581
  logger.debug(` ${logicalId} already deleted, removing from state`);
62877
62582
  result.deletedCount++;
62878
62583
  ctx.eventRecorder?.record({
@@ -63324,6 +63029,7 @@ var NestedStackProvider = class {
63324
63029
  async readChildOutputsAsAttributes(ctx, childStackName, childRegion) {
63325
63030
  const childStateData = await ctx.stateBackend.getState(childStackName, childRegion);
63326
63031
  if (!childStateData) throw new Error(`Child stack state '${childStackName}' not found after deploy — NestedStackProvider invariant violated.`);
63032
+ refuseMalformedNestedChildOutputs(childStateData.state, childStackName, childRegion);
63327
63033
  const attributes = this.buildOutputsAttributes(childStateData.state.outputs ?? {});
63328
63034
  const noEchoAttributeNames = [];
63329
63035
  for (const [outputKey, persisted] of Object.entries(childStateData.state.outputs ?? {})) {
@@ -69045,26 +68751,6 @@ function isRegionAmbiguousRefusal(err) {
69045
68751
  return err instanceof Error && errorCauseChain(err).some((link) => link instanceof DynamicReferenceRegionAmbiguousError);
69046
68752
  }
69047
68753
  /**
69048
- * The marker the resolver's NAMELESS-dynamic-reference throw carries
69049
- * (`Dynamic reference: <service> PARAMETER_NAME is required`).
69050
- *
69051
- * Pinned against the resolver's own literal by
69052
- * `tests/unit/cli/scrub-nameless-dynamic-ref.test.ts`, because this is a
69053
- * consumer of a string the resolver owns: a reword there would otherwise make
69054
- * the predicate below silently stop matching and restore the very silence it
69055
- * exists to remove (`.claude/rules/testing.md` -> "A fixture that greps
69056
- * cdkd's OWN output must fail loudly when the format drifts").
69057
- */
69058
- const NAMELESS_DYNAMIC_REFERENCE_MARKERS = ["PARAMETER_NAME is required", "SECRET_ID is required"];
69059
- /**
69060
- * The prefix a matching message must ALSO carry. Without it the bare tails
69061
- * above are reachable from an unrelated throw: `resolveParameters` raises
69062
- * `Parameter ${name} is required but no value was provided`, so a template
69063
- * parameter literally NAMED `PARAMETER_NAME` would flip an ordinary
69064
- * best-effort miss into a whole-stack refusal.
69065
- */
69066
- const DYNAMIC_REFERENCE_PREFIX = "Dynamic reference: ";
69067
- /**
69068
68754
  * A NAMELESS dynamic reference is NOT a best-effort miss, however unresolvable
69069
68755
  * the rest of the resource is (issue go-to-k/cdkd#2692) — `{{resolve:ssm-secure}}`
69070
68756
  * with no parameter name, or `{{resolve:secretsmanager}}` /
@@ -69072,12 +68758,18 @@ const DYNAMIC_REFERENCE_PREFIX = "Dynamic reference: ";
69072
68758
  *
69073
68759
  * The resolver raises a bare `Error` for both, so they fall through the
69074
68760
  * typed-refusal test above into the `debug` below and `cdkd scrub` reports the
69075
- * run CLEAN. What makes that wrong is WHERE the throw happens: the token loop
69076
- * in `resolveDynamicReferences` has NO per-token `try`, so either one abandons
69077
- * every remaining `{{resolve:...}}` token in the leaf. A real
69078
- * `{{resolve:secretsmanager:prod/db}}` after it is never fetched and records NO
69079
- * needle — and a legacy plaintext already in `state.json` then survives under
69080
- * `No plaintext secrets found`, exit 0.
68761
+ * run CLEAN.
68762
+ *
68763
+ * WHY THAT STILL MATTERS AFTER go-to-k/cdkd#3181, which changed the reason. It
68764
+ * used to be about POSITION: the token loop had no per-token `try`, so a
68765
+ * nameless throw abandoned every remaining `{{resolve:...}}` token in the leaf
68766
+ * and a real reference behind it recorded no needle. That is no longer true —
68767
+ * the loop recovers per token for every opted-in caller. What survives is the
68768
+ * NAMELESS reference itself: it is a structurally broken template that no
68769
+ * substitution produces, the resolver re-raises it through
68770
+ * `isDeliberateResolutionRefusal` rather than recording it, and scrub must not
68771
+ * report a run clean over one. The predicate below is what turns that re-raise
68772
+ * into a refusal instead of a `debug` line.
69081
68773
  *
69082
68774
  * BOTH spellings are matched, and the second is the DOMINANT one. The first
69083
68775
  * cut matched `PARAMETER_NAME is required` alone, leaving the more common way
@@ -69086,8 +68778,7 @@ const DYNAMIC_REFERENCE_PREFIX = "Dynamic reference: ";
69086
68778
  * precisely BECAUSE the other throw spells it differently.
69087
68779
  *
69088
68780
  * WHY THE SET STOPS AT "is required", having once been widened to the whole
69089
- * `Dynamic reference:` family and reverted. The loop's missing per-token `try`
69090
- * means four SIBLING failures — a deleted SSM parameter, a secret with no
68781
+ * `Dynamic reference:` family and reverted. Four SIBLING failures — a deleted SSM parameter, a secret with no
69091
68782
  * `SecretString`, a missing `JSON_KEY`, a non-JSON secret — abandon the leaf
69092
68783
  * exactly the same way, so on the surface they belong here. They do not,
69093
68784
  * because scrub resolves with template DEFAULTS and takes no `--parameters`:
@@ -69116,7 +68807,7 @@ const DYNAMIC_REFERENCE_PREFIX = "Dynamic reference: ";
69116
68807
  * The old, wrong-in-substance refusal was backstopping this one.
69117
68808
  */
69118
68809
  function isNamelessDynamicReferenceFailure(err) {
69119
- return err instanceof Error && errorCauseChain(err).some((link) => link instanceof Error && link.message.includes(DYNAMIC_REFERENCE_PREFIX) && NAMELESS_DYNAMIC_REFERENCE_MARKERS.some((marker) => link.message.includes(marker)));
68810
+ return isNamelessDynamicReferenceError(err);
69120
68811
  }
69121
68812
  /**
69122
68813
  * The per-record line each counting site prints. Deliberately ONE SHORT
@@ -69222,55 +68913,49 @@ function abandonedScanVerdict(source, err) {
69222
68913
  return "count";
69223
68914
  }
69224
68915
  /**
69225
- * `source` carries a `{{resolve:...}}` token cdkd could actually have FETCHED —
69226
- * i.e. one whose argument holds no surviving `${...}` placeholder.
69227
- *
69228
- * `carriesDynamicReference` alone is too generous here, and the gap is not
69229
- * theoretical: scrub resolves with `bestEffort`, under which
69230
- * `rethrowStructuralSubFailure` returns EARLY, so an `Fn::Sub` over an unbound
69231
- * placeholder does not throw — it warn-and-KEEPS the literal `${Field}`. The
69232
- * assembled token is then a request for a JSON key literally named `${Field}`,
69233
- * which fails as `Dynamic reference: key '${Field}' not found in secret '<id>'`.
69234
- * That is not a fetch that failed; it is a token that was never fetchable,
69235
- * because scrub has only template DEFAULTS and accepts no `--parameters`. It is
69236
- * ALSO not excludable by message, since `key ... not found` is a class
69237
- * go-to-k/cdkd#3160 asks to COUNT when the key was real. So the discriminator
69238
- * has to be the TOKEN, not the error.
69239
- *
69240
- * `some`, not `every`: a bag holding one placeholder-bearing token and one
69241
- * fully substituted token still counts, because the second one genuinely was
69242
- * fetchable and its scan genuinely stopped.
69243
- *
69244
- * WHAT THIS DOES AND DOES NOT BUY, since round 5 shipped a cut that confused
69245
- * the two. A `false` here downgrades the GATE only — the record is still named
69246
- * at default verbosity by the `warn` arm. It must never decide SILENCE: the
69247
- * inner class is `[^}]`, so a placeholder ANYWHERE in a token fails this test,
69248
- * and the reference assembled by an `Fn::Sub` over DEFAULTED parameters is the
69249
- * dominant CDK spelling rather than an edge case.
69250
- *
69251
- * STATED RESIDUALS, both UNDER-counts against the gate, neither a silence:
69252
- *
69253
- * - A two-argument `Fn::Sub` whose placeholder IS bound
69254
- * (`{'Fn::Sub': ['{{resolve:ssm:${N}}}', {N: 'x'}]}`) substitutes fine and
69255
- * yields a fetchable token, but the raw bag still spells `${`, so a real
69256
- * fetch failure there warns instead of gating.
69257
- * - The same for a single-argument `Fn::Sub` over a defaulted parameter, and
69258
- * for a placeholder mid-token, which yields no token at all.
69259
- *
69260
- * And one OVER-count, recorded for symmetry: this predicate is BAG-scoped while
69261
- * the failure is TOKEN-scoped, so a bag holding a placeholder-bearing token
69262
- * BESIDE a genuinely fetchable one returns `true`, and a `key '${Field}' not
69263
- * found` raised for the first is gated as if it came from the second. Safety
69264
- * is in the right direction (a gate that fires, not a plaintext under `clean`),
69265
- * and same-bag co-location bounds it.
69266
- *
69267
- * go-to-k/cdkd#3181 is the fix that removes the need for any of these proxies.
69268
- */
69269
- function carriesFetchableDynamicReference(source) {
69270
- if (typeof source === "string") return dynamicReferenceTokens(source).some((token) => !token.includes("${"));
69271
- if (Array.isArray(source)) return source.some(carriesFetchableDynamicReference);
69272
- if (source !== null && typeof source === "object") return Object.values(source).some(carriesFetchableDynamicReference);
69273
- return false;
68916
+ * The same three-way question asked of ONE abandoned unit (issues
68917
+ * go-to-k/cdkd#3181 / go-to-k/cdkd#3218), rather than of the property enclosing
68918
+ * it.
68919
+ *
68920
+ * Identical in shape to {@link abandonedScanVerdict} and deliberately so — the
68921
+ * only difference is WHERE the two structural facts come from. There they are
68922
+ * computed from the whole property, because a thrown error is all that path
68923
+ * has. Here the resolver recorded each unit's own answer at the push, as two
68924
+ * booleans, so the verdict needs no value and can carry no plaintext.
68925
+ *
68926
+ * Asking per unit removes both of the bag-scoped proxy's errors at once:
68927
+ * a unit carrying no reference (go-to-k/cdkd#3218's `{"Ref": ...}` key) no
68928
+ * longer inherits a sibling's reference and count, and a genuinely unfetched
68929
+ * reference no longer hides behind a template-shape failure recorded before it.
68930
+ */
68931
+ function abandonedUnitVerdict(entry) {
68932
+ if (!entry.carriedDynamicReference) return "silent";
68933
+ if (isTemplateShapeResolutionFailure(entry.error) || !entry.carriedFetchableReference) return "warn";
68934
+ return "count";
68935
+ }
68936
+ /**
68937
+ * The verdict for a whole BAG of abandoned units: the most severe of them.
68938
+ *
68939
+ * `count` gates the exit code, `warn` only speaks, `silent` does neither — so
68940
+ * folding to the most severe is what stops a gateable finding hiding behind an
68941
+ * ungateable one recorded before it. That is not a hypothetical ordering
68942
+ * concern: `isTemplateShapeResolutionFailure` fires EN MASSE on healthy stacks
68943
+ * (one `Default`-less parameter makes every `{Ref: <param>}` throw), so a
68944
+ * `warn` at index 0 is the common case, and the per-unit recovery exists
68945
+ * precisely to keep walking past it to the reference that matters.
68946
+ */
68947
+ function foldAbandonedUnitVerdict(entries) {
68948
+ const RANK = {
68949
+ silent: 0,
68950
+ warn: 1,
68951
+ count: 2
68952
+ };
68953
+ let worst = "silent";
68954
+ for (const entry of entries) {
68955
+ const verdict = abandonedUnitVerdict(entry);
68956
+ if (RANK[verdict] > RANK[worst]) worst = verdict;
68957
+ }
68958
+ return worst;
69274
68959
  }
69275
68960
  /**
69276
68961
  * `err` is cdkd's OWN refusal to resolve a template SHAPE — a `Ref`, an
@@ -69548,6 +69233,35 @@ function makeCrossStackPrePass(deps) {
69548
69233
  * user-fixable — i.e. refuses the whole stack over something no user can fix.
69549
69234
  */
69550
69235
  const isByDesignRefusal = (err) => err instanceof Error && errorCauseChain(err).some((link) => link instanceof CrossAccountSecretRefusalError);
69236
+ /**
69237
+ * Is this a refusal over ANOTHER STACK's damaged record (issue
69238
+ * go-to-k/cdkd#3207)?
69239
+ *
69240
+ * A SECOND predicate rather than a widening of {@link isByDesignRefusal},
69241
+ * because the two classify the same `err` into DIFFERENT sentences and
69242
+ * different exit-code lists, and only one of them can honestly say "no
69243
+ * re-run can change it".
69244
+ *
69245
+ * Why this arm has to exist at all. Before go-to-k/cdkd#3207 the deploy-side
69246
+ * resolver RESOLVED an `Fn::GetStackOutput` against a non-object producer
69247
+ * bag — `Object.hasOwn('abcdef', '0')` is `true` — so a damaged producer
69248
+ * reached {@link storedProducerValue}, whose own guard records the finding
69249
+ * this arm now records. Closing the resolver is correct (that value is
69250
+ * APPLIED to a live system by a deploy) but it moves the refusal UP: without
69251
+ * this branch the read lands on `unresolvableCrossStackReadError` below and
69252
+ * refuses the whole stack, so a consumer's own plaintext would stay in
69253
+ * `state.json` over a producer record its owner may not be able to repair —
69254
+ * exactly the trade `docs/design/3192-outputs-consumers.md` §6 decided
69255
+ * against, silently reversed by the fix one layer up.
69256
+ *
69257
+ * SAME two lists as the classifier's arm, deliberately: `unverifiable` gates
69258
+ * the clean verdict, and `damagedProducerRecords` carries the UNCONDITIONAL
69259
+ * exit 2 that shape had before either guard existed.
69260
+ *
69261
+ * CAUSE CHAIN, not a bare `instanceof`, for the reason
69262
+ * {@link isByDesignRefusal}'s note gives — `resolver.resolve` wraps.
69263
+ */
69264
+ const isMalformedProducerRefusal = (err) => err instanceof Error && errorCauseChain(err).some((link) => link instanceof MalformedProducerRecordRefusalError);
69551
69265
  return async function resolveCrossStackReads(bag, context, origin, opts) {
69552
69266
  const canRefuse = opts?.canRefuse ?? true;
69553
69267
  const secrets = context.recordedSecretValues ?? /* @__PURE__ */ new Map();
@@ -69675,6 +69389,13 @@ function makeCrossStackPrePass(deps) {
69675
69389
  logger.warn(`Scrub of ${stackName} cannot verify ${detail} — cdkd declines this read by design, so no re-run can change it. The rest of the stack is still scrubbed; this stack is NOT reported clean.`);
69676
69390
  return;
69677
69391
  }
69392
+ if (isMalformedProducerRefusal(err)) {
69393
+ const detail = `${where}: ${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`;
69394
+ findings.unverifiable.push(detail);
69395
+ findings.damagedProducerRecords.push(detail);
69396
+ logger.warn(`Scrub of ${stackName} cannot verify ${detail} — the PRODUCER's state record is malformed or truncated, so this run cannot tell whether that producer still holds the plaintext. The rest of the stack is still scrubbed; this stack is NOT reported clean. Repair the producer's record and scrub it first, then re-run.`);
69397
+ return;
69398
+ }
69678
69399
  throw unresolvableCrossStackReadError(origin, stackName, key, path, err, secrets);
69679
69400
  }
69680
69401
  const producer = recordedProducer(key, probe);
@@ -69787,6 +69508,32 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
69787
69508
  let unverifiableLeaves = 0;
69788
69509
  let ungateableAbandonedScans = 0;
69789
69510
  /**
69511
+ * Report ONE bag of abandoned units: fold to the most severe verdict, count
69512
+ * it, warn once, then debug one line per entry.
69513
+ *
69514
+ * ONE helper shared by all THREE opted-in passes (resource properties,
69515
+ * orphan records, output values). They began as three hand-inlined copies of
69516
+ * the same eight lines, which is the shape that drifts: a round-3 review
69517
+ * measured that removing BOTH the orphan and output opt-ins reddened zero of
69518
+ * 4,180 tests, so nothing would have caught two of the three copies diverging
69519
+ * either. The passes differ only in the subject string and which needle map
69520
+ * their lines mask through, so both are parameters.
69521
+ *
69522
+ * Renders `entry.message`, never `entry.error`: the interface documents
69523
+ * `error` as classification-only, and `message` carries a twin-derived
69524
+ * redaction the needle mask alone cannot reproduce for a sub-floor plaintext.
69525
+ */
69526
+ const reportAbandonedBag = (entries, subject, secrets, debugSubject) => {
69527
+ if (entries.length === 0) return;
69528
+ const worst = foldAbandonedUnitVerdict(entries);
69529
+ if (worst !== "silent") {
69530
+ if (worst === "count") unverifiableLeaves++;
69531
+ else ungateableAbandonedScans++;
69532
+ logger.warn(maskSecretsInText(abandonedScanWarning(subject, worst), secrets));
69533
+ }
69534
+ for (const entry of entries) logger.debug(maskSecretsInText(`Resolution of ${debugSubject} during scrub was partial: ${entry.message} (abandoned one ${entry.unit})`, secrets));
69535
+ };
69536
+ /**
69790
69537
  * ONE note per stack per arm. The per-record lines are deliberately short
69791
69538
  * (see `abandonedScanWarning`), so this is where the explanation lives, and
69792
69539
  * emitting it after the loops rather than at the first occurrence is what
@@ -69896,18 +69643,24 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
69896
69643
  const bagKeys = resolveInput !== null && typeof resolveInput === "object" && !Array.isArray(resolveInput) ? Object.keys(resolveInput) : void 0;
69897
69644
  const intrinsicShapedBag = bagKeys?.some((k) => RESOLVER_DISPATCH_KEYS.has(k)) ?? false;
69898
69645
  const resolveUnits = bagKeys && !intrinsicShapedBag ? Object.entries(resolveInput) : [["", resolveInput]];
69899
- for (const [propertyName, propertyValue] of resolveUnits) try {
69900
- await resolver.resolve(propertyValue, resourceContext);
69901
- } catch (err) {
69902
- if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
69903
- const leafVerdict = abandonedScanVerdict(propertyValue, err);
69904
- if (leafVerdict !== "silent") {
69905
- if (leafVerdict === "count") unverifiableLeaves++;
69906
- else ungateableAbandonedScans++;
69907
- const subject = propertyName ? `resource '${displaySafe(logicalId)}' property '${displaySafe(propertyName)}'` : `resource '${displaySafe(logicalId)}'`;
69908
- logger.warn(maskSecretsInText(abandonedScanWarning(subject, leafVerdict), recordedSecretValues));
69646
+ for (const [propertyName, propertyValue] of resolveUnits) {
69647
+ const abandoned = [];
69648
+ try {
69649
+ await resolver.resolve(propertyValue, {
69650
+ ...resourceContext,
69651
+ abandonedResolutions: abandoned
69652
+ });
69653
+ reportAbandonedBag(abandoned, propertyName ? `resource '${displaySafe(logicalId)}' property '${displaySafe(propertyName)}'` : `resource '${displaySafe(logicalId)}'`, recordedSecretValues, `${displaySafe(logicalId)}${propertyName ? `.${displaySafe(propertyName)}` : ""}`);
69654
+ } catch (err) {
69655
+ if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
69656
+ const leafVerdict = abandonedScanVerdict(propertyValue, err);
69657
+ if (leafVerdict !== "silent") {
69658
+ if (leafVerdict === "count") unverifiableLeaves++;
69659
+ else ungateableAbandonedScans++;
69660
+ logger.warn(maskSecretsInText(abandonedScanWarning(propertyName ? `resource '${displaySafe(logicalId)}' property '${displaySafe(propertyName)}'` : `resource '${displaySafe(logicalId)}'`, leafVerdict), recordedSecretValues));
69661
+ }
69662
+ logger.debug(maskSecretsInText(`Resolution of ${displaySafe(logicalId)}${propertyName ? `.${displaySafe(propertyName)}` : ""} during scrub was partial: ${err instanceof Error ? err.message : String(err)}`, recordedSecretValues));
69909
69663
  }
69910
- logger.debug(maskSecretsInText(`Resolution of ${displaySafe(logicalId)}${propertyName ? `.${displaySafe(propertyName)}` : ""} during scrub was partial: ${err instanceof Error ? err.message : String(err)}`, recordedSecretValues));
69911
69664
  }
69912
69665
  perResourceTemplateProps.set(logicalId, templateResource.Properties);
69913
69666
  }
@@ -69926,17 +69679,24 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
69926
69679
  origin: `orphan record '${record.logicalId}'`
69927
69680
  });
69928
69681
  const orphanContext = resolverContext(recordedSecretValues);
69929
- for (const [bagName, bagValue] of Object.entries(resolveInput)) try {
69930
- await resolver.resolve(bagValue, orphanContext);
69931
- } catch (err) {
69932
- if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
69933
- const leafVerdict = abandonedScanVerdict(bagValue, err);
69934
- if (leafVerdict !== "silent") {
69935
- if (leafVerdict === "count") unverifiableLeaves++;
69936
- else ungateableAbandonedScans++;
69937
- logger.warn(maskSecretsInText(abandonedScanWarning(`orphan record '${displaySafe(record.logicalId)}' ${bagName}`, leafVerdict), recordedSecretValues));
69682
+ for (const [bagName, bagValue] of Object.entries(resolveInput)) {
69683
+ const abandoned = [];
69684
+ try {
69685
+ await resolver.resolve(bagValue, {
69686
+ ...orphanContext,
69687
+ abandonedResolutions: abandoned
69688
+ });
69689
+ reportAbandonedBag(abandoned, `orphan record '${displaySafe(record.logicalId)}' ${bagName}`, recordedSecretValues, `orphan record ${displaySafe(record.logicalId)} ${bagName}`);
69690
+ } catch (err) {
69691
+ if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
69692
+ const leafVerdict = abandonedScanVerdict(bagValue, err);
69693
+ if (leafVerdict !== "silent") {
69694
+ if (leafVerdict === "count") unverifiableLeaves++;
69695
+ else ungateableAbandonedScans++;
69696
+ logger.warn(maskSecretsInText(abandonedScanWarning(`orphan record '${displaySafe(record.logicalId)}' ${bagName}`, leafVerdict), recordedSecretValues));
69697
+ }
69698
+ logger.debug(maskSecretsInText(`Resolution of orphan record ${displaySafe(record.logicalId)} ${bagName} during scrub was partial: ${err instanceof Error ? err.message : String(err)}`, recordedSecretValues));
69938
69699
  }
69939
- logger.debug(maskSecretsInText(`Resolution of orphan record ${displaySafe(record.logicalId)} ${bagName} during scrub was partial: ${err instanceof Error ? err.message : String(err)}`, recordedSecretValues));
69940
69700
  }
69941
69701
  }
69942
69702
  for (const [name, output] of Object.entries(templateOutputs)) {
@@ -69999,8 +69759,13 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
69999
69759
  });
70000
69760
  const valueContext = resolverContext(outputSecrets);
70001
69761
  await resolveCrossStackReads(valueSource, valueContext, `output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
69762
+ const abandonedOutput = [];
70002
69763
  try {
70003
- await resolver.resolve(valueSource, valueContext);
69764
+ await resolver.resolve(valueSource, {
69765
+ ...valueContext,
69766
+ abandonedResolutions: abandonedOutput
69767
+ });
69768
+ reportAbandonedBag(abandonedOutput, `output '${displaySafe(name)}'`, outputSecrets, `output ${displaySafe(name)}`);
70004
69769
  } catch (err) {
70005
69770
  if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
70006
69771
  const leafVerdict = abandonedScanVerdict(valueSource, err);
@@ -82219,17 +81984,17 @@ async function scanReferencedAssets(stateBackend, marker, logger) {
82219
81984
  for (const key of stateKeys) {
82220
81985
  const body = await stateBackend.getRawObject(key);
82221
81986
  if (body === null) {
82222
- logger.debug(`State file ${key} disappeared during the scan — skipping`);
81987
+ logger.debug(`State file ${displayIdent(key, { maxCodePoints: STATE_KEY_MAX_CODE_POINTS })} disappeared during the scan — skipping`);
82223
81988
  continue;
82224
81989
  }
82225
81990
  let parsed;
82226
81991
  try {
82227
81992
  parsed = JSON.parse(body);
82228
81993
  } catch (error) {
82229
- const described = describeStateKey(key, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX);
82230
- const regionMatch = /^(\S+) \((\S+)\)$/.exec(described);
82231
- const inspectHint = regionMatch ? `cdkd state show ${regionMatch[1]} --stack-region ${regionMatch[2]}` : `cdkd state show ${described}`;
82232
- throw new CdkdError(`State file '${key}' is not valid JSON — aborting: gc must know every referenced asset before deleting anything, and this file's references are unreadable. Repair or remove the corrupt state file ('${inspectHint}' to inspect), then re-run.`, "GC_STATE_UNREADABLE", error);
81994
+ const { stack, region } = parseStateKey(key, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX);
81995
+ const inspectHint = isPasteableIdent(stack) && (region === void 0 || isPasteableIdent(region)) ? region === void 0 ? `cdkd state show ${stack}` : `cdkd state show ${stack} --stack-region ${region}` : void 0;
81996
+ const inspect = inspectHint === void 0 ? "its stack name is not safe to paste into a command, so inspect the object at that key directly" : `'${inspectHint}' to inspect`;
81997
+ throw new CdkdError(`State file ${displayIdent(key, { maxCodePoints: STATE_KEY_MAX_CODE_POINTS })} is not valid JSON — aborting: gc must know every referenced asset before deleting anything, and this file's references are unreadable. Repair or remove the corrupt state file (${inspect}), then re-run.`, "GC_STATE_UNREADABLE", error);
82233
81998
  }
82234
81999
  collectAssetReferences(parsed, marker, refs);
82235
82000
  }
@@ -82373,11 +82138,11 @@ async function listResponsePlaceholderCandidates(stateBackend, cutoffMs, logger)
82373
82138
  for (const obj of objects) {
82374
82139
  const leaf = obj.key.slice(`${CUSTOM_RESOURCE_RESPONSE_PREFIX}/`.length);
82375
82140
  if (!RESPONSE_PLACEHOLDER_KEY.test(leaf)) {
82376
- logger.debug(`gc: keeping ${obj.key} — not a cdkd response placeholder`);
82141
+ logger.debug(`gc: keeping ${displayIdent(obj.key, { maxCodePoints: STATE_KEY_MAX_CODE_POINTS })} — not a cdkd response placeholder`);
82377
82142
  continue;
82378
82143
  }
82379
82144
  if (obj.lastModified.getTime() >= cutoffMs) {
82380
- logger.debug(`gc: keeping ${obj.key} — newer than the --older-than cutoff`);
82145
+ logger.debug(`gc: keeping ${displayIdent(obj.key, { maxCodePoints: STATE_KEY_MAX_CODE_POINTS })} — newer than the --older-than cutoff`);
82381
82146
  continue;
82382
82147
  }
82383
82148
  candidates.push(obj);
@@ -82578,7 +82343,7 @@ async function gcCommand(options) {
82578
82343
  }
82579
82344
  const lockKeys = await listAllLockKeys(stateBackend);
82580
82345
  if (lockKeys.length > 0) {
82581
- const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${k}]`).join("\n");
82346
+ const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${displayIdent(k, { maxCodePoints: STATE_KEY_MAX_CODE_POINTS })}]`).join("\n");
82582
82347
  throw new CdkdError(`Refusing to gc while ${lockKeys.length} stack(s) hold an active lock (a deploy in flight may have published assets whose state write has not landed yet):\n${listing}\nWait for the operation(s) to finish — or clear a stale lock with 'cdkd force-unlock <stack>' — then re-run 'cdkd gc'.`, "GC_LOCKED");
82583
82348
  }
82584
82349
  const cutoffMs = Date.now() - options.olderThan;
@@ -85433,6 +85198,25 @@ async function loadBootstrapContainerRepo(synthRegion, opts) {
85433
85198
  async function buildCrossStackResolver(consumerRegion, opts) {
85434
85199
  const logger = getLogger();
85435
85200
  const prefix = opts.logPrefix ?? "--from-state";
85201
+ /**
85202
+ * Producer records already named by the malformed-outputs warning below
85203
+ * (issue go-to-k/cdkd#3207), keyed `stack@region`.
85204
+ *
85205
+ * ONCE PER PRODUCER RECORD, not per reference, which is the shape
85206
+ * `scrub.ts`'s equivalent arm already deduped ("ONCE PER PRODUCER RECORD,
85207
+ * not per reference") and this reader did not adopt: one `cdkd local` env
85208
+ * block routinely carries several `Fn::GetStackOutput` entries against the
85209
+ * SAME producer, and the warning is a paragraph. Repeating it per entry
85210
+ * buries the other diagnostics the run emits, and it reads as several
85211
+ * damaged records rather than one.
85212
+ *
85213
+ * Keyed by RECORD and not by stack, because `resolveGetStackOutput` tries
85214
+ * the exact region spelling and then folds across others: those are
85215
+ * DISTINCT state records that can be damaged independently, so each still
85216
+ * deserves its own line. The closure — not `readOutput` — is the only scope
85217
+ * that outlives a single reference.
85218
+ */
85219
+ const warnedMalformedProducers = /* @__PURE__ */ new Set();
85436
85220
  let stateBucket;
85437
85221
  try {
85438
85222
  stateBucket = await resolveStateBucketWithDefault(opts.stateBucket, canonicalizeRegion(consumerRegion));
@@ -85502,17 +85286,27 @@ async function buildCrossStackResolver(consumerRegion, opts) {
85502
85286
  }
85503
85287
  },
85504
85288
  async resolveGetStackOutput(producerStack, producerRegion, outputName) {
85505
- const readOutput = (got) => {
85506
- if (!got || !got.state.outputs) return void 0;
85507
- if (!(outputName in got.state.outputs)) return void 0;
85508
- const value = got.state.outputs[outputName];
85289
+ const readOutput = (got, recordRegion) => {
85290
+ if (!got) return void 0;
85291
+ if (!hasReadableOutputs(got.state)) {
85292
+ const recordKey = `${producerStack}\u0000${recordRegion}`;
85293
+ if (!warnedMalformedProducers.has(recordKey)) {
85294
+ warnedMalformedProducers.add(recordKey);
85295
+ logger.warn(`${prefix}: ${malformedLocalOutputsWarning(producerStack, recordRegion)}`);
85296
+ }
85297
+ return;
85298
+ }
85299
+ const outputs = got.state.outputs;
85300
+ if (!outputs) return void 0;
85301
+ if (!Object.hasOwn(outputs, outputName)) return void 0;
85302
+ const value = outputs[outputName];
85509
85303
  if (typeof value === "string") return value;
85510
85304
  if (typeof value === "number" || typeof value === "boolean") return String(value);
85511
85305
  return JSON.stringify(value);
85512
85306
  };
85513
85307
  try {
85514
85308
  const got = await stateBackend.getState(producerStack, producerRegion);
85515
- if (got) return readOutput(got);
85309
+ if (got) return readOutput(got, producerRegion);
85516
85310
  const canonicalProducerRegion = canonicalizeRegion(producerRegion);
85517
85311
  const refs = await stateBackend.listStacks();
85518
85312
  for (const ref of refs) {
@@ -85520,7 +85314,7 @@ async function buildCrossStackResolver(consumerRegion, opts) {
85520
85314
  const region = ref.region ?? producerRegion;
85521
85315
  if (region === producerRegion) continue;
85522
85316
  if (canonicalizeRegion(region) !== canonicalProducerRegion) continue;
85523
- const recovered = readOutput(await stateBackend.getState(producerStack, region));
85317
+ const recovered = readOutput(await stateBackend.getState(producerStack, region), region);
85524
85318
  if (recovered !== void 0) {
85525
85319
  logger.debug(`${prefix}: Fn::GetStackOutput '${producerStack}.${outputName}' resolved from the case-variant state record '${region}' (no record spells '${producerRegion}' exactly).`);
85526
85320
  return recovered;
@@ -85573,6 +85367,7 @@ var S3LocalStateProvider = class {
85573
85367
  };
85574
85368
  const loaded = await loadStateForStack(stackName, synthRegion, loadOpts);
85575
85369
  if (!loaded) return void 0;
85370
+ if (repairMalformedOutputsForReadOnly(loaded.state)) getLogger().warn(malformedLocalOutputsWarning(stackName, loaded.region));
85576
85371
  const outputs = {};
85577
85372
  for (const [k, v] of Object.entries(loaded.state.outputs ?? {})) if (typeof v === "string") outputs[k] = v;
85578
85373
  else if (typeof v === "number" || typeof v === "boolean") outputs[k] = String(v);
@@ -90116,6 +89911,9 @@ function readEnvOverridesFile$3(filePath) {
90116
89911
  * Forward the developer's AWS credentials into the container so the
90117
89912
  * handler's AWS SDK calls can authenticate. Used when --assume-role is
90118
89913
  * NOT set for that Lambda — SAM-compatible default.
89914
+ *
89915
+ * Exported for unit-test isolation (`local-container-caller-identity.test.ts`),
89916
+ * which drives the `--role-arn` cases without booting the API server.
90119
89917
  */
90120
89918
  function forwardAwsEnv$2(env) {
90121
89919
  for (const key of [
@@ -90128,6 +89926,7 @@ function forwardAwsEnv$2(env) {
90128
89926
  const value = process.env[key];
90129
89927
  if (value !== void 0) env[key] = value;
90130
89928
  }
89929
+ applyCallerIdentityCredentials(env);
90131
89930
  }
90132
89931
  /**
90133
89932
  * Issue #654: resolve `--profile <p>` to a concrete credential set
@@ -90164,7 +89963,10 @@ function forwardAwsEnv$2(env) {
90164
89963
  async function resolveProfileCredentials(profile) {
90165
89964
  const { STSClient } = await import("@aws-sdk/client-sts");
90166
89965
  const sts = new STSClient({
90167
- ...awsClientDefaults({ profile }),
89966
+ ...awsClientDefaults({
89967
+ profile,
89968
+ ignoreAssumedRole: true
89969
+ }),
90168
89970
  profile
90169
89971
  });
90170
89972
  try {
@@ -90188,7 +89990,7 @@ async function resolveProfileCredentials(profile) {
90188
89990
  async function assumeLambdaExecutionRole$1(roleArn, region) {
90189
89991
  const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
90190
89992
  const sts = new STSClient({
90191
- ...awsClientDefaults(),
89993
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
90192
89994
  ...region && { region }
90193
89995
  });
90194
89996
  try {
@@ -90470,7 +90272,7 @@ async function resolvePseudoParametersForStartApi(stateRegion, options) {
90470
90272
  try {
90471
90273
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
90472
90274
  const sts = new STSClient({
90473
- ...awsClientDefaults(),
90275
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
90474
90276
  ...region && { region }
90475
90277
  });
90476
90278
  try {
@@ -90747,11 +90549,11 @@ async function resolveEcsSecrets(entries, options = {}) {
90747
90549
  if (entries.length === 0) return [];
90748
90550
  const logger = getLogger().child("ecs-secrets");
90749
90551
  const secretsClient = options.secretsManagerClient ?? new SecretsManagerClient({
90750
- ...awsClientDefaults(),
90552
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
90751
90553
  ...options.region && { region: options.region }
90752
90554
  });
90753
90555
  const ssmClient = options.ssmClient ?? new SSMClient({
90754
- ...awsClientDefaults(),
90556
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
90755
90557
  ...options.region && { region: options.region }
90756
90558
  });
90757
90559
  const ownsSecretsClient = options.secretsManagerClient === void 0;
@@ -91567,7 +91369,7 @@ async function resolvePlaceholderAccount(arn, region) {
91567
91369
  if (!arn.includes("${AWS::AccountId}")) return arn;
91568
91370
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
91569
91371
  const sts = new STSClient({
91570
- ...awsClientDefaults(),
91372
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
91571
91373
  ...region && { region }
91572
91374
  });
91573
91375
  try {
@@ -91585,7 +91387,7 @@ async function resolvePlaceholderAccount(arn, region) {
91585
91387
  async function assumeTaskRole(roleArn, region) {
91586
91388
  const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
91587
91389
  const sts = new STSClient({
91588
- ...awsClientDefaults(),
91390
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
91589
91391
  ...region && { region }
91590
91392
  });
91591
91393
  try {
@@ -91727,7 +91529,7 @@ function pickCandidateStack(stackPattern, stacks) {
91727
91529
  async function resolveCallerAccountId$1(region) {
91728
91530
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
91729
91531
  const sts = new STSClient({
91730
- ...awsClientDefaults(),
91532
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
91731
91533
  ...region && { region }
91732
91534
  });
91733
91535
  try {
@@ -92603,6 +92405,17 @@ async function buildSigV4HeadersIfRequested(options, resolved, loaded, host, por
92603
92405
  *
92604
92406
  * Throws a {@link CdkdError} when none are available — `--sigv4` cannot
92605
92407
  * proceed without credentials, unlike the unsigned path.
92408
+ *
92409
+ * EXPORTED as a test seam, like the pure helpers this file already exports. It
92410
+ * is the only `reads-caller` site the env-channel fence
92411
+ * (`tests/unit/local/local-surface-env-identity.test.ts`) knows about, and that
92412
+ * fence sees the SITE, not the BEHAVIOUR — it cannot tell
92413
+ * `callerEnvCredentials()` from a `process.env` read that happens to compile.
92414
+ * What this function decides is who the emulated agent sees as its INVOKER, so
92415
+ * the arm that matters is not reachable from any other entry point: the command
92416
+ * handler builds a container around it. Kept in ONE block with the precedence
92417
+ * contract above deliberately — as two, a reader or a tool taking only the
92418
+ * adjacent block gets the seam rationale and loses the precedence.
92606
92419
  */
92607
92420
  async function resolveHostCredentialsForSigV4(options, resolved, loaded, region) {
92608
92421
  const logger = getLogger();
@@ -92620,16 +92433,12 @@ async function resolveHostCredentialsForSigV4(options, resolved, loaded, region)
92620
92433
  ...creds.sessionToken && { sessionToken: creds.sessionToken }
92621
92434
  };
92622
92435
  }
92623
- const accessKeyId = process.env["AWS_ACCESS_KEY_ID"];
92624
- const secretAccessKey = process.env["AWS_SECRET_ACCESS_KEY"];
92625
- if (accessKeyId && secretAccessKey) {
92626
- const sessionToken = process.env["AWS_SESSION_TOKEN"];
92627
- return {
92628
- accessKeyId,
92629
- secretAccessKey,
92630
- ...sessionToken && { sessionToken }
92631
- };
92632
- }
92436
+ const shellCredentials = callerEnvCredentials();
92437
+ if (shellCredentials) return {
92438
+ accessKeyId: shellCredentials.accessKeyId,
92439
+ secretAccessKey: shellCredentials.secretAccessKey,
92440
+ ...shellCredentials.sessionToken && { sessionToken: shellCredentials.sessionToken }
92441
+ };
92633
92442
  throw new CdkdError("--sigv4: no AWS credentials available to sign the request. Set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY, pass --profile <name>, or pass --assume-role <arn>.", "LOCAL_INVOKE_AGENTCORE_SIGV4_NO_CREDENTIALS");
92634
92443
  }
92635
92444
  /**
@@ -92887,7 +92696,10 @@ async function buildAgentCoreImageContext(candidate, stateProvider, options) {
92887
92696
  async function resolveCallerAccountId(region, profile) {
92888
92697
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
92889
92698
  const sts = new STSClient({
92890
- ...awsClientDefaults({ profile }),
92699
+ ...awsClientDefaults({
92700
+ profile,
92701
+ ignoreAssumedRole: true
92702
+ }),
92891
92703
  ...region && { region },
92892
92704
  ...profile && { profile }
92893
92705
  });
@@ -93062,6 +92874,9 @@ function platformToArchitecture(platform) {
93062
92874
  * open here after the same gap was closed in `local-invoke.ts` — made ONE command
93063
92875
  * yield two different container regions for the same shell: `cn-north-1` with
93064
92876
  * `--assume-role`, `CN-NORTH-1` without.
92877
+ *
92878
+ * Exported for unit-test isolation (`local-container-caller-identity.test.ts`),
92879
+ * which drives the `--role-arn` cases without the image + docker pipeline.
93065
92880
  */
93066
92881
  function forwardAwsEnv$1(env) {
93067
92882
  const passThrough = [
@@ -93077,11 +92892,12 @@ function forwardAwsEnv$1(env) {
93077
92892
  if (value === void 0) continue;
93078
92893
  env[key] = regionKeys.has(key) ? canonicalizeRegion(value) : value;
93079
92894
  }
92895
+ applyCallerIdentityCredentials(env);
93080
92896
  }
93081
92897
  async function assumeAgentCoreExecutionRole(roleArn, region) {
93082
92898
  const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
93083
92899
  const sts = new STSClient({
93084
- ...awsClientDefaults(),
92900
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
93085
92901
  ...region && { region }
93086
92902
  });
93087
92903
  try {
@@ -94054,7 +93870,7 @@ async function resolvePseudoParametersForInvoke(stackRegion, options) {
94054
93870
  try {
94055
93871
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
94056
93872
  const sts = new STSClient({
94057
- ...awsClientDefaults(),
93873
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
94058
93874
  ...region && { region }
94059
93875
  });
94060
93876
  try {
@@ -94200,7 +94016,7 @@ async function applyLambdaCredentialEnv(dockerEnv, args) {
94200
94016
  async function assumeLambdaExecutionRole(roleArn, region) {
94201
94017
  const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
94202
94018
  const sts = new STSClient({
94203
- ...awsClientDefaults(),
94019
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
94204
94020
  ...region && { region }
94205
94021
  });
94206
94022
  try {
@@ -94236,6 +94052,9 @@ async function assumeLambdaExecutionRole(roleArn, region) {
94236
94052
  * COMMERCIAL partition). Folding only inside `applyLambdaCredentialEnv`'s
94237
94053
  * assume-role arm made ONE command yield two different container regions for the
94238
94054
  * same shell — `cn-north-1` with `--assume-role`, `CN-NORTH-1` without.
94055
+ *
94056
+ * Exported for unit-test isolation (`local-container-caller-identity.test.ts`),
94057
+ * which drives the `--role-arn` cases without the synth + docker pipeline.
94239
94058
  */
94240
94059
  function forwardAwsEnv(env) {
94241
94060
  const passThrough = [
@@ -94251,6 +94070,7 @@ function forwardAwsEnv(env) {
94251
94070
  if (value === void 0) continue;
94252
94071
  env[key] = regionKeys.has(key) ? canonicalizeRegion(value) : value;
94253
94072
  }
94073
+ applyCallerIdentityCredentials(env);
94254
94074
  }
94255
94075
  /**
94256
94076
  * Issue #657: overlay `--profile <p>`-resolved credentials onto the
@@ -94488,4 +94308,4 @@ function buildProgram() {
94488
94308
 
94489
94309
  //#endregion
94490
94310
  export { buildProgram };
94491
- //# sourceMappingURL=program-U03H43s7.js.map
94311
+ //# sourceMappingURL=program-BcdccZA8.js.map