@go-to-k/cdkd 0.286.3 → 0.286.4

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
1
  import { _ as withStackName, d as applyDefaultNameForFallback, f as generateResourceName, h as looksLikeCdkdGeneratedName, m as getCurrentStackName, n as getLogger, p as generateResourceNameWithFallback, r as isStdoutReservedForPayload, s as getLiveRenderer } from "./logger-Dw-3y48G.js";
2
2
  import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
3
- import { t as getCdkdVersion } from "./version-D3md0Lde.js";
3
+ import { t as getCdkdVersion } from "./version-CKOnsgg1.js";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { 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";
@@ -6085,6 +6085,49 @@ var FileAssetPublisher = class {
6085
6085
  }
6086
6086
  };
6087
6087
 
6088
+ //#endregion
6089
+ //#region src/utils/regexp.ts
6090
+ /**
6091
+ * Escape a literal string for embedding in a RegExp.
6092
+ *
6093
+ * Lives here because the same four-line helper was spelled THREE times — in
6094
+ * `src/utils/ecr-uri.ts`, `src/cli/commands/gc.ts` and
6095
+ * `src/assets/asset-redirect.ts` — each interpolating a user-controlled name
6096
+ * (an AWS region, a bootstrap-marker asset bucket / container repo, an ECR host
6097
+ * label) into a pattern. Copies of a security-shaped helper are exactly the
6098
+ * shape that drifts: the escaped set is what stops a `.` in an interpolated
6099
+ * literal matching ANY character, and in `gc.ts` a widened match decides which
6100
+ * live assets are treated as referenced.
6101
+ *
6102
+ * The character set is the union every JS engine treats as special in a
6103
+ * non-`u` pattern. `-` is deliberately absent: it is only special INSIDE a
6104
+ * character class, and no caller embeds into one.
6105
+ */
6106
+ function escapeRegExp$1(value) {
6107
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6108
+ }
6109
+ /**
6110
+ * Remove control characters from a value about to be PRINTED.
6111
+ *
6112
+ * Lives beside {@link escapeRegExp} for the reason that helper's own note
6113
+ * gives: this was spelled twice — in `src/cli/commands/diff-recursive.ts` and
6114
+ * in `src/deployment/outputs-export-alias.ts` — and copies of a
6115
+ * security-shaped helper drift. Both print the same class of string: a
6116
+ * resolved CloudFormation Output / `Export.Name`, which passed no CFn
6117
+ * validator, so it can carry ANSI escapes or bidi overrides straight into a
6118
+ * terminal or a CI log.
6119
+ *
6120
+ * C0 + DEL + C1 + the bidi marks and isolates. Apply on HUMAN-render paths
6121
+ * only: a `--json` payload is a machine interface where an export name is data
6122
+ * a consumer may match on, and mutating it there trades a correctness
6123
+ * regression for a display concern (see `stripDisplayOnlyChars`, which is the
6124
+ * narrower guard for an already-serialized payload and stays local to its
6125
+ * caller because its C0 exclusion is specific to that path).
6126
+ */
6127
+ function stripControlChars(value) {
6128
+ return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
6129
+ }
6130
+
6088
6131
  //#endregion
6089
6132
  //#region src/utils/docker-cmd.ts
6090
6133
  /**
@@ -6503,6 +6546,21 @@ const REDACTED_ARGV_VALUE = "***";
6503
6546
  * - `--opt` — `DockerVolumeConfiguration.DriverOpts`, which for the `local`
6504
6547
  * driver carries mount options (`o=addr=…,username=…,password=…`).
6505
6548
  * - `--label` — `DockerVolumeConfiguration.Labels`, user-authored metadata.
6549
+ * - `--build-arg` — a `DockerImageAsset`'s `buildArgs`, forwarded by
6550
+ * `src/assets/docker-build.ts` on BOTH the deploy-time ECR publish path and
6551
+ * `cdkd local run-task`'s image build
6552
+ * ([#2623](https://github.com/go-to-k/cdkd/issues/2623)). The value is
6553
+ * frequently NOT a secret and IS diagnostic (a version pin, a base-image
6554
+ * tag), which is why this one needed arguing rather than assuming — the
6555
+ * argument that settles it is RECOVERABILITY, not likelihood. The build-arg
6556
+ * values sit unredacted in `cdk.out/*.assets.json` on the operator's own
6557
+ * disk, so a masked `--verbose` line costs one `jq` against a file they
6558
+ * already have; the log line is the copy that travels into a CI archive and
6559
+ * a pasted issue, where a build-time registry / package token
6560
+ * (`NPM_TOKEN`, `GITHUB_TOKEN` — a common, if discouraged, use of
6561
+ * `buildArgs`) is disclosed irreversibly. The KEY survives, so "which build
6562
+ * arg" — the half of the diagnostic that identifies the failure — is
6563
+ * unaffected.
6506
6564
  *
6507
6565
  * A flag NOT in this list keeps its value. For most of the argv that is
6508
6566
  * because the value is cdkd-authored or infrastructure-shaped — a container
@@ -6518,33 +6576,348 @@ const REDACTED_ARGV_VALUE = "***";
6518
6576
  * `Environment` / `Secrets` / `DriverOpts`. Revisit per flag if a real leak is
6519
6577
  * found through one; do not widen the set on suspicion, since every addition
6520
6578
  * trades away diagnostic text.
6579
+ *
6580
+ * Two `docker build` flags were considered WITH `--build-arg` and deliberately
6581
+ * left out, because both carry a LOCATOR rather than a value
6582
+ * ([#2623](https://github.com/go-to-k/cdkd/issues/2623)):
6583
+ *
6584
+ * - `--secret id=<id>,src=<path>` (or `,env=<NAME>`) — BuildKit resolves the
6585
+ * material itself at build time and docker has NO inline-value syntax, so
6586
+ * cdkd never holds the secret to leak. Masking would also blank the `id`,
6587
+ * since the mask keys on the first `=` and `id` is what sits there — trading
6588
+ * the whole diagnostic ("which secret, sourced from where") for a path.
6589
+ * - `--build-context <name>=<path|image-ref|git-url>` — a path or a ref, like
6590
+ * the positional image. A URL form CAN embed a credential, but so can the
6591
+ * image ref; masking one of the two would be a guarantee this set does not
6592
+ * make, and neither is a documented place to put one.
6593
+ *
6594
+ * `--cache-from` / `--cache-to` were in that second bullet for one review
6595
+ * round and the security reviewer was right to refuse it: their value is a
6596
+ * comma-separated PARAM LIST, and BuildKit's cache backends take real
6597
+ * credentials inline there — s3's `access_key_id` / `secret_access_key` /
6598
+ * `session_token`, azblob's `secret_access_key`, gha's `token`. Those are not
6599
+ * locators, so they get their own structural mask keyed on the PARAM name:
6600
+ * {@link ARGV_PARAM_LIST_FLAGS} / {@link ARGV_PARAM_LIST_LOCATOR_PARAMS}.
6521
6601
  */
6522
6602
  const ARGV_VALUE_BEARING_FLAGS = /* @__PURE__ */ new Set([
6523
6603
  "-e",
6524
6604
  "--env",
6525
6605
  "--opt",
6526
- "--label"
6606
+ "--label",
6607
+ "--build-arg"
6608
+ ]);
6609
+ /**
6610
+ * `docker` argv flags whose NEXT token is a COMMA-SEPARATED `key=value` param
6611
+ * list rather than one `KEY=VALUE` pair ([#2623](https://github.com/go-to-k/cdkd/issues/2623)
6612
+ * security review).
6613
+ *
6614
+ * Only the params in {@link ARGV_PARAM_LIST_LOCATOR_PARAMS} survive, and the
6615
+ * rest of the list is masked — `type=s3,region=us-east-1,bucket=b` IS the
6616
+ * diagnostic ("which backend, where"), and blanking the whole value would
6617
+ * destroy it to hide one field.
6618
+ */
6619
+ const ARGV_PARAM_LIST_FLAGS = /* @__PURE__ */ new Set(["--cache-from", "--cache-to"]);
6620
+ /**
6621
+ * Param names inside an {@link ARGV_PARAM_LIST_FLAGS} value that are LOCATORS
6622
+ * and therefore survive. **Everything else in the list is masked** — this is
6623
+ * an ALLOWLIST, and it is one deliberately.
6624
+ *
6625
+ * The first cut was a denylist of the credential params BuildKit's backends
6626
+ * spell (`secret_access_key`, `access_key_id`, `session_token`, `token`), and
6627
+ * both round-2 reviewers broke it the same way:
6628
+ * `DockerCacheOption.params` is an arbitrary user map that
6629
+ * `cacheOptionToFlag` joins with a bare `,` and no quoting, so a value
6630
+ * CONTAINING a comma (`token=aa,bb`, or BuildKit's own legal
6631
+ * `secret_access_key="ab,cd"`) split into a masked head and a bare tail that
6632
+ * printed verbatim — output that LOOKS redacted. Patching that one shape is
6633
+ * the enumerate-bad-shapes treadmill; inverting the test ends it, because a
6634
+ * CSV continuation fragment has no recognised param name and so masks by
6635
+ * construction.
6636
+ *
6637
+ * The trade is the right way round for a leak fix: a param missing from this
6638
+ * list costs one degraded diagnostic, a param missing from a denylist costs a
6639
+ * printed credential. The list covers every backend BuildKit ships —
6640
+ * `registry` (`ref`), `local` (`dest` / `src` / `digest` / `tag`), `s3`
6641
+ * (`region` / `bucket` / `name` / the prefixes / `use_path_style`), `azblob`
6642
+ * (`account_name` / `name` / `prefix`), `gha` (`scope` / `timeout`),
6643
+ * `inline` (none) — plus the shared export options, so the common case masks
6644
+ * nothing.
6645
+ *
6646
+ * The four URL-valued locators live in {@link ARGV_PARAM_LIST_URL_PARAMS}
6647
+ * instead: they survive only after their userinfo and query are stripped. An
6648
+ * earlier revision kept them HERE, whole, arguing the repo's locator policy;
6649
+ * that paragraph is gone rather than softened, because azblob makes it false.
6650
+ *
6651
+ * Matched case-INSENSITIVELY on the trimmed param name — the comparison is
6652
+ * over a user-supplied key and BuildKit's own option parsing is
6653
+ * case-insensitive, so a `Secret_Access_Key` spelling must not walk past.
6654
+ */
6655
+ const ARGV_PARAM_LIST_LOCATOR_PARAMS = /* @__PURE__ */ new Set([
6656
+ "type",
6657
+ "mode",
6658
+ "compression",
6659
+ "compression-level",
6660
+ "force-compression",
6661
+ "ignore-error",
6662
+ "image-manifest",
6663
+ "oci-mediatypes",
6664
+ "timeout",
6665
+ "ref",
6666
+ "dest",
6667
+ "src",
6668
+ "digest",
6669
+ "tag",
6670
+ "region",
6671
+ "bucket",
6672
+ "name",
6673
+ "prefix",
6674
+ "manifests_prefix",
6675
+ "blobs_prefix",
6676
+ "use_path_style",
6677
+ "upload_parallelism",
6678
+ "touch_refresh",
6679
+ "account_name",
6680
+ "scope"
6681
+ ]);
6682
+ /**
6683
+ * Locator params whose value is a URL, and which therefore survive only after
6684
+ * their USERINFO, QUERY and FRAGMENT are stripped — and only when the value
6685
+ * parses at all
6686
+ * ([#2623](https://github.com/go-to-k/cdkd/issues/2623) round-3 security
6687
+ * review).
6688
+ *
6689
+ * These were plain allowlist entries for one round, on the reasoning that a
6690
+ * credential-in-URL is an incidental hazard shared with `--build-context` and
6691
+ * the positional image ref. That reasoning does not survive contact with
6692
+ * azblob: BuildKit builds an UNAUTHENTICATED client from `account_url` when
6693
+ * `secret_access_key` is absent, so a SAS token in its query string is that
6694
+ * backend's SUPPORTED auth path, not an accident. With every other param now
6695
+ * fail-closed, leaving the whole URL was the one entry carrying credential
6696
+ * material by design.
6697
+ *
6698
+ * Scheme and host survive, which is what "which endpoint" needs — on the
6699
+ * ordinary path. FOUR things mask more than that, and every one of them also
6700
+ * masks each param after it in the same value, because they all set `masked`.
6701
+ * Three take the value WHOLE: no recognisable `//` authority; a host that is
6702
+ * really `user:password` with its `@` cut away; and a later param carrying
6703
+ * this URL's severed `@` (the caller's rule, since only it still holds the
6704
+ * parts). The fourth is milder in what it keeps, not in what it cascades — a
6705
+ * `@` in the PATH costs the host but keeps scheme and tail (`https://h/x@y`
6706
+ * -> `https://***@y`). Over-masking is the deliberate direction: see
6707
+ * {@link redactUrlLocator} for what each boundary rule leaked before this one.
6708
+ */
6709
+ const ARGV_PARAM_LIST_URL_PARAMS = /* @__PURE__ */ new Set([
6710
+ "account_url",
6711
+ "endpoint_url",
6712
+ "url",
6713
+ "url_v2"
6527
6714
  ]);
6528
6715
  /**
6716
+ * A URL locator with its credential-bearing parts removed: `userinfo@`, and
6717
+ * everything from `?` or `#` on.
6718
+ *
6719
+ * Returns the input UNCHANGED when there was nothing to strip, which is the
6720
+ * signal the caller keys "was this masked?" on — so every other branch must
6721
+ * differ from its input. An unparseable or implausible value returns `***`
6722
+ * rather than itself: these four params are the only ones exempt from the
6723
+ * allowlist's mask-by-default rule, so this function is their whole
6724
+ * protection and it fails CLOSED.
6725
+ */
6726
+ function redactUrlLocator(value) {
6727
+ if (value === "") return value;
6728
+ const authority = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\//.exec(value);
6729
+ if (authority === null) return REDACTED_ARGV_VALUE;
6730
+ const prefix = authority[0];
6731
+ let rest = value.slice(prefix.length);
6732
+ const lastAt = rest.lastIndexOf("@");
6733
+ if (lastAt >= 0) rest = `${REDACTED_ARGV_VALUE}${rest.slice(lastAt)}`;
6734
+ else {
6735
+ const host = rest.split(/[/?#]/)[0];
6736
+ const ipv6 = /^\[[0-9A-Fa-f:.]+\]/.exec(host);
6737
+ const bare = ipv6 ? host.slice(ipv6[0].length) : host;
6738
+ if (/:(?!\d{1,5}$)/.test(bare)) return REDACTED_ARGV_VALUE;
6739
+ }
6740
+ for (const delimiter of ["?", "#"]) {
6741
+ const at = rest.indexOf(delimiter);
6742
+ if (at >= 0) rest = `${rest.slice(0, at)}${delimiter}${REDACTED_ARGV_VALUE}`;
6743
+ }
6744
+ return `${prefix}${rest}`;
6745
+ }
6746
+ /**
6747
+ * The masked rendering of `value` for `flag`, or `undefined` when this flag
6748
+ * carries nothing to mask.
6749
+ *
6750
+ * The single place every argv spelling converges — `--flag VALUE` (two
6751
+ * tokens), `--flag=VALUE` (one), and pflag's shorthand cluster in both forms,
6752
+ * which reach it through {@link maskAttachedShortFlag} and through
6753
+ * {@link trailingShortFlagOfCluster} in {@link redactDockerArgvValues}.
6754
+ *
6755
+ * The joined form is valid docker syntax for every long flag here, and it was
6756
+ * UNMASKED until the [#2623](https://github.com/go-to-k/cdkd/issues/2623)
6757
+ * review: harmless while every argv this repo builds emits two tokens, but
6758
+ * `src/assets/docker-build.ts`'s `executable` source mode renders a
6759
+ * USER-AUTHORED command line, and a wrapper script spelling
6760
+ * `--build-arg=NPM_TOKEN=…` leaked it verbatim into both the `--verbose` log
6761
+ * and a thrown error.
6762
+ */
6763
+ function maskArgvFlagValue(flag, value) {
6764
+ if (ARGV_VALUE_BEARING_FLAGS.has(flag)) {
6765
+ const eqIdx = value.indexOf("=");
6766
+ return eqIdx >= 0 ? `${value.substring(0, eqIdx)}=${REDACTED_ARGV_VALUE}` : void 0;
6767
+ }
6768
+ if (ARGV_PARAM_LIST_FLAGS.has(flag)) {
6769
+ let masked = false;
6770
+ const rawParts = value.split(",");
6771
+ const parts = rawParts.map((part, index) => {
6772
+ if (part === "") return part;
6773
+ if (masked) return REDACTED_ARGV_VALUE;
6774
+ const eqIdx = part.indexOf("=");
6775
+ if (eqIdx < 0) {
6776
+ if (index === 0) return part;
6777
+ masked = true;
6778
+ return REDACTED_ARGV_VALUE;
6779
+ }
6780
+ const name = part.substring(0, eqIdx);
6781
+ const normalized = name.trim().toLowerCase();
6782
+ if (ARGV_PARAM_LIST_URL_PARAMS.has(normalized)) {
6783
+ if (rawParts.slice(index + 1).some((later) => later.includes("@"))) {
6784
+ masked = true;
6785
+ return `${name}=${REDACTED_ARGV_VALUE}`;
6786
+ }
6787
+ const rawUrl = part.substring(eqIdx + 1);
6788
+ const safeUrl = redactUrlLocator(rawUrl);
6789
+ if (safeUrl === rawUrl) return part;
6790
+ masked = true;
6791
+ return `${name}=${safeUrl}`;
6792
+ }
6793
+ if (ARGV_PARAM_LIST_LOCATOR_PARAMS.has(normalized)) return part;
6794
+ masked = true;
6795
+ return `${name}=${REDACTED_ARGV_VALUE}`;
6796
+ });
6797
+ return masked ? parts.join(",") : void 0;
6798
+ }
6799
+ }
6800
+ /**
6801
+ * The masked rendering of an ATTACHED short-flag element (`-eKEY=VALUE`), or
6802
+ * `undefined` when this element is not one.
6803
+ *
6804
+ * pflag — which docker uses — accepts a short flag's value glued to it, so
6805
+ * `-eNPM_TOKEN=secret` is `-e NPM_TOKEN=secret`, and neither the two-token nor
6806
+ * the `--flag=VALUE` branch can see it ([#2623](https://github.com/go-to-k/cdkd/issues/2623)
6807
+ * round-2 security review). No argv this repo BUILDS uses the form, but
6808
+ * `src/assets/docker-build.ts`'s `executable` source mode hands a user's own
6809
+ * command line straight to `spawn`, which is the whole reason the joined form
6810
+ * is masked too.
6811
+ *
6812
+ * This OVER-MASKS relative to pflag, and the earlier claim that it "follows
6813
+ * docker's own parse" was wrong: pflag stops at the first cluster letter that
6814
+ * consumes a value, so it reads `-file=a=b` as `-f` taking `ile=a=b` while
6815
+ * this masks at the `e`. Every such divergence costs a diagnostic and hides
6816
+ * nothing that was not already a `KEY=VALUE` pair, so the direction is the
6817
+ * safe one — but it is a divergence, not parity.
6818
+ */
6819
+ function maskAttachedShortFlag(arg) {
6820
+ if (arg.startsWith("--") || !arg.startsWith("-")) return void 0;
6821
+ let chosen;
6822
+ for (const flag of ARGV_VALUE_BEARING_FLAGS) {
6823
+ if (flag.length !== 2 || !flag.startsWith("-") || flag[1] === "-") continue;
6824
+ const at = arg.indexOf(flag[1], 1);
6825
+ if (at < 0 || at === arg.length - 1) continue;
6826
+ if (chosen === void 0 || at < chosen.at) chosen = {
6827
+ at,
6828
+ flag
6829
+ };
6830
+ }
6831
+ if (chosen === void 0) return void 0;
6832
+ const masked = maskArgvFlagValue(chosen.flag, arg.slice(chosen.at + 1));
6833
+ return masked === void 0 ? void 0 : `${arg.slice(0, chosen.at + 1)}${masked}`;
6834
+ }
6835
+ /**
6836
+ * The value-bearing short flag a shorthand CLUSTER ENDS in, if any — the form
6837
+ * that takes its value from the NEXT argv element.
6838
+ *
6839
+ * pflag's `parseSingleShortArg` falls through to `value = args[0]` when the
6840
+ * cluster runs out, so `-itde K=v` is `-i -t -d -e K=v` with the value in a
6841
+ * separate token. {@link maskAttachedShortFlag} bails on a flag letter in last
6842
+ * position (there is nothing attached to mask) and the two-token branch looked
6843
+ * `-itde` up as a whole flag and missed, so this spelling printed verbatim
6844
+ * until [#2623](https://github.com/go-to-k/cdkd/issues/2623)'s round-4 review
6845
+ * measured it. Pass 2 cannot rescue it either: its alternation needs a
6846
+ * whitespace-preceded `-e`.
6847
+ */
6848
+ function trailingShortFlagOfCluster(arg) {
6849
+ if (arg.startsWith("--") || !arg.startsWith("-")) return void 0;
6850
+ const last = arg[arg.length - 1];
6851
+ for (const flag of ARGV_VALUE_BEARING_FLAGS) if (flag.length === 2 && flag.startsWith("-") && flag[1] !== "-" && flag[1] === last) return flag;
6852
+ }
6853
+ /**
6529
6854
  * Structural, whitespace-delimited form of {@link ARGV_VALUE_BEARING_FLAGS}
6530
6855
  * for scanning a STRING that embeds a space-joined argv. Keyed on the FLAG's
6531
6856
  * position — never on the secret's value, so an unrelated literal that merely
6532
6857
  * coincides with a value is left alone.
6533
6858
  *
6859
+ * Covers the SEPARATED and JOINED spellings of
6860
+ * {@link ARGV_VALUE_BEARING_FLAGS} only. Two gaps, recorded rather than
6861
+ * implied. pflag's shorthand cluster is not modelled here in EITHER form —
6862
+ * attached (`-eKEY=VALUE` / `-itdeKEY=VALUE`) or separated (`-itde KEY=VALUE`,
6863
+ * which this pass's alternation cannot see because it needs a
6864
+ * whitespace-preceded `-e`) — this pass has no argv to resolve
6865
+ * the cluster against, and a bare `-e` prefix scan over free text would fire
6866
+ * on any word starting with `-e`; {@link redactDockerArgvValues} and passes 1
6867
+ * / 1b handle it wherever an argv IS available, which is every composer. And
6868
+ * {@link ARGV_PARAM_LIST_FLAGS}
6869
+ * is deliberately absent: this pass runs with NO argv to key on, and picking a
6870
+ * param name out of a free-text comma list is where a structural match stops
6871
+ * being structural. The param mask therefore rides passes 1 and 1b, both of
6872
+ * which derive from {@link redactDockerArgvValues} and so have the real argv —
6873
+ * i.e. a cache credential is masked in every text composed through a composer,
6874
+ * and not in a bare text handed to {@link redactDockerArgvInText} with no
6875
+ * `args`. Recorded rather than implied.
6876
+ *
6877
+ * DERIVED from the Set rather than re-spelled beside it
6878
+ * ([#2623](https://github.com/go-to-k/cdkd/issues/2623)). Until then this was
6879
+ * a hand-written literal listing the same four flags, i.e. the shape where
6880
+ * adding a flag to one spelling and not the other masks it on the argv pass
6881
+ * and leaks it on the text pass — a divergence no behavioural test of either
6882
+ * one can see. Deriving is stronger than fencing the pair: there is nothing
6883
+ * left to diverge.
6884
+ *
6534
6885
  * What each piece actually buys, since one of them was mis-credited in review:
6535
6886
  * the `(^|\s)` lead is what stops `-e` matching inside `--env` (there is no
6536
6887
  * `m` flag, so `^` is start-of-INPUT), NOT the alternation order — reordering
6537
6888
  * the branches leaves every test green. The order is kept as belt and braces
6538
- * only. The mandatory `\s+` after the flag IS load-bearing: it is what stops
6539
- * `-easy` / `--environment` matching. The KEY is `[^\s=]*` rather than `+` so
6540
- * an EMPTY key (`-e =value`) is masked too.
6541
- */
6542
- const ARGV_VALUE_TOKEN_RE = /(^|\s)(--env|--label|--opt|-e)(\s+)([^\s=]*)=\S*/g;
6889
+ * only, which is why the derivation sorts longest-first. The separator group
6890
+ * IS load-bearing: `\s+` is what stops `-easy` / `--environment` matching, and
6891
+ * the `=` alternative is the joined `--build-arg=KEY=VALUE` spelling
6892
+ * {@link maskArgvFlagValue} handles on the argv side (the group can never be
6893
+ * empty, so `--build-argument=X=y` still cannot match). The KEY is `[^\s=]*`
6894
+ * rather than `+` so an EMPTY key (`-e =value`) is masked too.
6895
+ *
6896
+ * The alternation is asserted NON-EMPTY at construction. An empty
6897
+ * {@link ARGV_VALUE_BEARING_FLAGS} would collapse it to `(^|\s)()(\s+|=)…`,
6898
+ * which masks EVERY whitespace-preceded `k=v` in any docker text — a
6899
+ * fail-OPEN that destroys the diagnostic wholesale, and the one failure mode
6900
+ * deriving from the Set introduced.
6901
+ */
6902
+ const ARGV_VALUE_TOKEN_RE = (() => {
6903
+ const alternation = [...ARGV_VALUE_BEARING_FLAGS].sort((a, b) => b.length - a.length || a.localeCompare(b)).map(escapeRegExp$1).join("|");
6904
+ if (alternation.length === 0 || [...ARGV_VALUE_BEARING_FLAGS].some((f) => f.trim() === "")) throw new Error("ARGV_VALUE_BEARING_FLAGS is empty or holds an empty flag: the token scan would match every k=v");
6905
+ return new RegExp(`(^|\\s)(${alternation})(\\s+|=)([^\\s=]*)=\\S*`, "g");
6906
+ })();
6543
6907
  /**
6544
6908
  * Return a copy of `args` with the VALUE of every
6545
- * {@link ARGV_VALUE_BEARING_FLAGS} pair replaced by `***`. The KEY survives —
6546
- * "which variable" is the diagnostic, "what it was set to" is the disclosure.
6547
- *
6909
+ * {@link ARGV_VALUE_BEARING_FLAGS} pair replaced by `***`, and the credential
6910
+ * PARAMS of every {@link ARGV_PARAM_LIST_FLAGS} value replaced likewise. The
6911
+ * KEY survives — "which variable" is the diagnostic, "what it was set to" is
6912
+ * the disclosure.
6913
+ *
6914
+ * All FOUR argv spellings are handled: `--flag VALUE` (two tokens, what every
6915
+ * argv this repo builds emits), `--flag=VALUE`, and pflag's shorthand cluster
6916
+ * in both its forms — attached (`-itdeKEY=VALUE`) and separated
6917
+ * (`-itde KEY=VALUE`, where the cluster ends in the flag letter and pflag
6918
+ * takes the NEXT token). The last three are handled because a user's own
6919
+ * build script may spell them
6920
+ * ([#2623](https://github.com/go-to-k/cdkd/issues/2623)).
6548
6921
  * A value-less `-e KEY` (the form {@link partitionSensitiveEnv} emits for a
6549
6922
  * sensitive key) has nothing to mask and is returned unchanged. `args` is
6550
6923
  * never mutated: the redacted copy is for DISPLAY only, never for `spawn`.
@@ -6553,11 +6926,24 @@ function redactDockerArgvValues(args) {
6553
6926
  const out = [];
6554
6927
  for (let i = 0; i < args.length; i++) {
6555
6928
  const cur = args[i];
6929
+ const joinedEq = cur.indexOf("=");
6930
+ if (joinedEq > 0) {
6931
+ const maskedJoined = maskArgvFlagValue(cur.substring(0, joinedEq), cur.substring(joinedEq + 1));
6932
+ if (maskedJoined !== void 0) {
6933
+ out.push(`${cur.substring(0, joinedEq)}=${maskedJoined}`);
6934
+ continue;
6935
+ }
6936
+ }
6937
+ const maskedAttached = maskAttachedShortFlag(cur);
6938
+ if (maskedAttached !== void 0) {
6939
+ out.push(maskedAttached);
6940
+ continue;
6941
+ }
6556
6942
  const next = args[i + 1];
6557
- if (ARGV_VALUE_BEARING_FLAGS.has(cur) && typeof next === "string") {
6558
- const eqIdx = next.indexOf("=");
6559
- if (eqIdx >= 0) {
6560
- out.push(cur, `${next.substring(0, eqIdx)}=${REDACTED_ARGV_VALUE}`);
6943
+ if (typeof next === "string") {
6944
+ const maskedNext = maskArgvFlagValue(ARGV_VALUE_BEARING_FLAGS.has(cur) ? cur : trailingShortFlagOfCluster(cur) ?? cur, next);
6945
+ if (maskedNext !== void 0) {
6946
+ out.push(cur, maskedNext);
6561
6947
  i++;
6562
6948
  continue;
6563
6949
  }
@@ -6881,32 +7267,39 @@ async function buildDockerImage(asset, cdkOutDir, options) {
6881
7267
  const [cmd, ...args] = source.executable;
6882
7268
  if (!cmd) throw options.wrapError("asset source.executable[] is empty");
6883
7269
  const cwd = source.directory ? `${cdkOutDir}/${source.directory}` : cdkOutDir;
6884
- logger.debug(`Building Docker image via executable: ${source.executable.join(" ")} (cwd=${cwd})`);
7270
+ const shownExecutable = redactDockerArgvValues(source.executable).join(" ");
7271
+ logger.debug(`Building Docker image via executable: ${shownExecutable} (cwd=${cwd})`);
6885
7272
  let result;
6886
7273
  try {
6887
7274
  result = await spawnStreaming(cmd, args, { cwd });
6888
7275
  } catch (err) {
6889
- const e = err;
6890
- throw options.wrapError(e.stderr || e.message || String(err));
7276
+ throw options.wrapError(describeDockerFailure(err, args));
6891
7277
  }
6892
7278
  const tag = result.stdout.trim();
6893
- if (!tag) throw options.wrapError(`docker build executable produced no output (expected the local image tag on stdout): ${cmd} ${args.join(" ")}`);
7279
+ if (!tag) throw options.wrapError(`docker build executable produced no output (expected the local image tag on stdout): ${shownExecutable}`);
6894
7280
  return tag;
6895
7281
  }
6896
- if (!source.directory) throw options.wrapError(`DockerImageAssetSource must set either 'directory' or 'executable' (got: ${JSON.stringify(source)})`);
7282
+ if (!source.directory) {
7283
+ const carriesAValue = (v) => {
7284
+ if (v === void 0 || v === null || v === "") return false;
7285
+ if (typeof v !== "object") return true;
7286
+ return (Array.isArray(v) ? v.length : Object.keys(v).length) > 0;
7287
+ };
7288
+ const present = Object.keys(source).filter((k) => carriesAValue(source[k])).map((k) => displaySafe(k, { asciiOnly: true })).filter((k) => k !== "").sort().join(", ") || "<no fields set>";
7289
+ throw options.wrapError(`DockerImageAssetSource must set either 'directory' or 'executable' (fields present: ${present})`);
7290
+ }
6897
7291
  if (!options.tag) throw options.wrapError("buildDockerImage(directory mode) requires options.tag");
6898
7292
  const buildArgs = buildDockerBuildCommand(source, options.tag, options.platform);
6899
7293
  const contextDir = `${cdkOutDir}/${source.directory}`;
6900
7294
  buildArgs.push(".");
6901
- logger.debug(`${getDockerCmd()} ${buildArgs.join(" ")} (cwd=${contextDir})`);
7295
+ logger.debug(`${getDockerCmd()} ${redactDockerArgvValues(buildArgs).join(" ")} (cwd=${contextDir})`);
6902
7296
  try {
6903
7297
  await runDockerStreaming(buildArgs, {
6904
7298
  cwd: contextDir,
6905
7299
  env: { BUILDX_NO_DEFAULT_ATTESTATIONS: "1" }
6906
7300
  });
6907
7301
  } catch (err) {
6908
- const e = err;
6909
- throw options.wrapError(e.stderr || e.message || String(err));
7302
+ throw options.wrapError(describeDockerFailure(err, buildArgs));
6910
7303
  }
6911
7304
  return options.tag;
6912
7305
  }
@@ -7074,8 +7467,9 @@ var DockerAssetPublisher = class {
7074
7467
  }
7075
7468
  /**
7076
7469
  * Build Docker image — delegates to the shared `buildDockerImage`
7077
- * helper so this code path stays in sync with `cdkd local invoke`'s
7078
- * container-Lambda build path. `--platform` is read from the asset
7470
+ * helper so this code path stays in sync with `cdkd local run-task`'s
7471
+ * `ContainerImage.fromAsset` build path (the other caller; `cdkd local
7472
+ * invoke` moved to `cdk-local`'s own builder). `--platform` is read from the asset
7079
7473
  * manifest's `source.platform` (when set); cdkd does not currently
7080
7474
  * inject a publish-side override.
7081
7475
  *
@@ -7159,33 +7553,33 @@ var DockerAssetPublisher = class {
7159
7553
  const [username, password] = Buffer.from(authData.authorizationToken, "base64").toString().split(":");
7160
7554
  if (!username || password === void 0) throw new AssetError("ECR authorization token has unexpected shape (missing username/password)");
7161
7555
  const endpoint = authData.proxyEndpoint || `https://${accountId}.dkr.ecr.${region}.${ecrUrlSuffix(region)}`;
7556
+ const loginArgs = [
7557
+ "login",
7558
+ "--username",
7559
+ username,
7560
+ "--password-stdin",
7561
+ endpoint
7562
+ ];
7162
7563
  try {
7163
- await runDockerStreaming([
7164
- "login",
7165
- "--username",
7166
- username,
7167
- "--password-stdin",
7168
- endpoint
7169
- ], { input: password });
7564
+ await runDockerStreaming(loginArgs, { input: password });
7170
7565
  loggedInRegistries.add(registryKey);
7171
7566
  } catch (err) {
7172
- const e = err;
7173
- throw new AssetError(`ECR login failed: ${formatDockerLoginError(e.stderr || e.message || String(err), endpoint)}`);
7567
+ throw new AssetError(`ECR login failed: ${formatDockerLoginError(describeDockerFailure(err, loginArgs), endpoint)}`);
7174
7568
  }
7175
7569
  }
7176
7570
  /**
7177
7571
  * Tag Docker image
7178
7572
  */
7179
7573
  async tagImage(source, target) {
7574
+ const tagArgs = [
7575
+ "tag",
7576
+ source,
7577
+ target
7578
+ ];
7180
7579
  try {
7181
- await runDockerStreaming([
7182
- "tag",
7183
- source,
7184
- target
7185
- ]);
7580
+ await runDockerStreaming(tagArgs);
7186
7581
  } catch (err) {
7187
- const e = err;
7188
- throw new AssetError(`Docker tag failed: ${e.stderr?.trim() || e.message || String(err)}`);
7582
+ throw new AssetError(`Docker tag failed: ${describeDockerFailure(err, tagArgs)}`);
7189
7583
  }
7190
7584
  }
7191
7585
  /**
@@ -7195,11 +7589,11 @@ var DockerAssetPublisher = class {
7195
7589
  */
7196
7590
  async pushImage(uri) {
7197
7591
  this.logger.debug(`Pushing: ${uri}`);
7592
+ const pushArgs = ["push", uri];
7198
7593
  try {
7199
- await runDockerStreaming(["push", uri]);
7594
+ await runDockerStreaming(pushArgs);
7200
7595
  } catch (err) {
7201
- const e = err;
7202
- throw new AssetError(`Docker push failed: ${e.stderr?.trim() || e.message || String(err)}`);
7596
+ throw new AssetError(`Docker push failed: ${describeDockerFailure(err, pushArgs)}`);
7203
7597
  }
7204
7598
  }
7205
7599
  /**
@@ -7937,49 +8331,6 @@ var AssetModeResolver = class {
7937
8331
  }
7938
8332
  };
7939
8333
 
7940
- //#endregion
7941
- //#region src/utils/regexp.ts
7942
- /**
7943
- * Escape a literal string for embedding in a RegExp.
7944
- *
7945
- * Lives here because the same four-line helper was spelled THREE times — in
7946
- * `src/utils/ecr-uri.ts`, `src/cli/commands/gc.ts` and
7947
- * `src/assets/asset-redirect.ts` — each interpolating a user-controlled name
7948
- * (an AWS region, a bootstrap-marker asset bucket / container repo, an ECR host
7949
- * label) into a pattern. Copies of a security-shaped helper are exactly the
7950
- * shape that drifts: the escaped set is what stops a `.` in an interpolated
7951
- * literal matching ANY character, and in `gc.ts` a widened match decides which
7952
- * live assets are treated as referenced.
7953
- *
7954
- * The character set is the union every JS engine treats as special in a
7955
- * non-`u` pattern. `-` is deliberately absent: it is only special INSIDE a
7956
- * character class, and no caller embeds into one.
7957
- */
7958
- function escapeRegExp$1(value) {
7959
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7960
- }
7961
- /**
7962
- * Remove control characters from a value about to be PRINTED.
7963
- *
7964
- * Lives beside {@link escapeRegExp} for the reason that helper's own note
7965
- * gives: this was spelled twice — in `src/cli/commands/diff-recursive.ts` and
7966
- * in `src/deployment/outputs-export-alias.ts` — and copies of a
7967
- * security-shaped helper drift. Both print the same class of string: a
7968
- * resolved CloudFormation Output / `Export.Name`, which passed no CFn
7969
- * validator, so it can carry ANSI escapes or bidi overrides straight into a
7970
- * terminal or a CI log.
7971
- *
7972
- * C0 + DEL + C1 + the bidi marks and isolates. Apply on HUMAN-render paths
7973
- * only: a `--json` payload is a machine interface where an export name is data
7974
- * a consumer may match on, and mutating it there trades a correctness
7975
- * regression for a display concern (see `stripDisplayOnlyChars`, which is the
7976
- * narrower guard for an already-serialized payload and stays local to its
7977
- * caller because its C0 exclusion is specific to that path).
7978
- */
7979
- function stripControlChars(value) {
7980
- return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
7981
- }
7982
-
7983
8334
  //#endregion
7984
8335
  //#region src/assets/asset-redirect.ts
7985
8336
  /**
@@ -8861,6 +9212,16 @@ function formatRemaining(ms) {
8861
9212
  if (minutes < 1) return "in under a minute";
8862
9213
  return `in ~${minutes}m`;
8863
9214
  }
9215
+ /**
9216
+ * Quote a value for a pasteable shell command.
9217
+ *
9218
+ * EXPORTED since issue [#2610]: `src/provisioning/replacement-protection-advice.ts`
9219
+ * prints `aws <service> ...` recovery commands naming a resource's physical id,
9220
+ * which is the same hazard one directory over. A second spelling of this
9221
+ * predicate is how the two would come to disagree about which values need
9222
+ * quoting -- the reason `display-safe.ts`'s header gives for not widening a
9223
+ * rule by hand. It is a pure function of its argument and imports nothing.
9224
+ */
8864
9225
  function shellQuote(value) {
8865
9226
  return /^[A-Za-z0-9._/@:+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
8866
9227
  }
@@ -8880,6 +9241,18 @@ function buildForceUnlockCommand(stackName, region, recovery) {
8880
9241
  return parts.join(" ");
8881
9242
  }
8882
9243
  /**
9244
+ * What to say INSTEAD of a `cdkd force-unlock ...` line when
9245
+ * {@link buildForceUnlockCommand} suppresses.
9246
+ *
9247
+ * Exported since issue [#2610]: `lock-manager.ts`'s exhausted-retry arm needed
9248
+ * the same branch, and it was the THIRD place to spell it. The module header's
9249
+ * point applies to the suppression sentence as much as to the command -- a
9250
+ * banner ending in a bare `run: ` is the shape the review found, and copies are
9251
+ * how the next one drifts. Byte-identical to what `forceQuitRecoveryClause`
9252
+ * emitted before the extraction; its callers see no change.
9253
+ */
9254
+ const UNREPRODUCIBLE_LOCK_CLAUSE = "Inspect the lock object directly: the name or region recorded for this stack cannot be reproduced safely on a command line, so any command shown here would address a different lock.";
9255
+ /**
8883
9256
  * The force-quit banner's recovery sentence.
8884
9257
  *
8885
9258
  * Exported so the two `destroy-runner.ts` banners do not each decide what to
@@ -8890,7 +9263,7 @@ function buildForceUnlockCommand(stackName, region, recovery) {
8890
9263
  */
8891
9264
  function forceQuitRecoveryClause(stackName, region, recovery) {
8892
9265
  const command = buildForceUnlockCommand(stackName, region, recovery);
8893
- return command ? ` If the next run reports a lock, run: ${command}` : " Inspect the lock object directly: the name or region recorded for this stack cannot be reproduced safely on a command line, so any command shown here would address a different lock.";
9266
+ return command ? ` If the next run reports a lock, run: ${command}` : ` ${UNREPRODUCIBLE_LOCK_CLAUSE}`;
8894
9267
  }
8895
9268
  /**
8896
9269
  * Build the contention message, reading the holder's identity best-effort.
@@ -10138,7 +10511,7 @@ var LockManager = class {
10138
10511
  }
10139
10512
  return false;
10140
10513
  }
10141
- throw new LockError(`Failed to acquire lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : void 0);
10514
+ throw new LockError(`Failed to acquire lock for stack '${displaySafe(stackName, { asciiOnly: true }) || "<unrenderable>"}' (${displaySafe(region, { asciiOnly: true }) || "<unrenderable>"}): ${displaySafe(error instanceof Error ? error.message : String(error))}`, error instanceof Error ? error : void 0);
10142
10515
  }
10143
10516
  }
10144
10517
  /**
@@ -10362,8 +10735,11 @@ var LockManager = class {
10362
10735
  /**
10363
10736
  * Force release a lock regardless of owner or expiry status
10364
10737
  *
10365
- * This is intended for CLI usage (e.g., --force-unlock flag) when a lock
10366
- * is stuck and needs manual intervention.
10738
+ * This is intended for CLI usage -- it is what the `cdkd force-unlock
10739
+ * <stack>` SUBCOMMAND calls -- when a lock is stuck and needs manual
10740
+ * intervention. There is no `--force-unlock` FLAG anywhere in `src/`; this
10741
+ * comment said there was, and issue [#2610] site 14 records that the message
10742
+ * `acquireLockWithRetry` used to raise had inherited the same mistake.
10367
10743
  *
10368
10744
  * Pass `region: undefined` to operate on a legacy
10369
10745
  * `{prefix}/{stackName}/lock.json` file.
@@ -10706,7 +11082,7 @@ var LockManager = class {
10706
11082
  if (lockInfo) {
10707
11083
  const remainingMs = lockInfo.expiresAt - Date.now();
10708
11084
  if (attempt < maxRetries) {
10709
- this.logger.info(`Stack '${stackName}' (${region}) is locked by ${lockInfo.owner}${lockInfo.operation ? ` (operation: ${lockInfo.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`);
11085
+ this.logger.info(`Stack '${displaySafe(stackName, { asciiOnly: true }) || "<unrenderable>"}' (${displaySafe(region, { asciiOnly: true }) || "<unrenderable>"}) is locked by ${lockInfo.owner}${lockInfo.operation ? ` (operation: ${lockInfo.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`);
10710
11086
  await new Promise((resolve) => setTimeout(resolve, retryDelay));
10711
11087
  continue;
10712
11088
  }
@@ -10714,7 +11090,9 @@ var LockManager = class {
10714
11090
  }
10715
11091
  const lockInfo = await this.getLockInfo(stackName, region);
10716
11092
  const expiresIn = lockInfo ? this.formatDuration(lockInfo.expiresAt - Date.now()) : "unknown";
10717
- throw new LockError(`Failed to acquire lock for stack '${stackName}' (${region}) after ${maxRetries + 1} attempts. ` + (lockInfo ? `Locked by: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expires in: ${expiresIn}. Use --force-unlock to manually release the lock.` : "Lock exists but could not read lock info."));
11093
+ const forceUnlockCommand = buildForceUnlockCommand(stackName, region);
11094
+ const recovery = forceUnlockCommand ? `If you are certain no other process is active, run: ${forceUnlockCommand}` : `If you are certain no other process is active, ${UNREPRODUCIBLE_LOCK_CLAUSE.charAt(0).toLowerCase()}${UNREPRODUCIBLE_LOCK_CLAUSE.slice(1)}`;
11095
+ throw new LockError(`Failed to acquire lock for stack '${displaySafe(stackName, { asciiOnly: true }) || "<unrenderable>"}' (${displaySafe(region, { asciiOnly: true }) || "<unrenderable>"}) after ${maxRetries + 1} attempts. ` + (lockInfo ? `Locked by: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expires in: ${expiresIn}. ` + recovery : `Lock exists but could not read lock info. ${recovery}`));
10718
11096
  }
10719
11097
  };
10720
11098
 
@@ -11468,7 +11846,7 @@ function storeAssociation(associations, key, expression, plaintext) {
11468
11846
  * matters: an AWS resource type string is fixed by AWS, and a typo in either
11469
11847
  * copy makes that copy's gate simply never fire (no-op), never fire wrongly.
11470
11848
  */
11471
- const NESTED_STACK_RESOURCE_TYPE$1 = "AWS::CloudFormation::Stack";
11849
+ const NESTED_STACK_RESOURCE_TYPE$2 = "AWS::CloudFormation::Stack";
11472
11850
  /**
11473
11851
  * What a PARENT stack's resolution proved about each `Parameters` entry of an
11474
11852
  * `AWS::CloudFormation::Stack` row it is about to provision, keyed by the
@@ -11575,7 +11953,7 @@ const nestedStackParameterExpressions = /* @__PURE__ */ new WeakMap();
11575
11953
  * `secret-redaction-nested-parameter-source.test.ts`.
11576
11954
  */
11577
11955
  function recordNestedStackParameterExpressions(secrets, resourceType, resolvedProperties, sourceProperties, rules = TEMPLATE_DERIVED_RULES) {
11578
- if (resourceType !== NESTED_STACK_RESOURCE_TYPE$1) return;
11956
+ if (resourceType !== NESTED_STACK_RESOURCE_TYPE$2) return;
11579
11957
  if (secrets.size === 0) return;
11580
11958
  if (!isPlainObject$2(resolvedProperties) || !isPlainObject$2(sourceProperties)) return;
11581
11959
  if (!Object.hasOwn(resolvedProperties, "Parameters")) return;
@@ -16753,9 +17131,28 @@ function secretsManagerSecretId(inner) {
16753
17131
  * - A trailing `:<version>` / `:<label>` is part of the name AS SSM PARSES IT
16754
17132
  * (`GetParameter` accepts `name:3` / `name:prod`), so stripping it would name
16755
17133
  * a different thing in the refusal message than the one that would be read.
17134
+ *
17135
+ * A COLON-LESS body (`{{resolve:ssm}}` / `{{resolve:ssm-secure}}`) names no
17136
+ * parameter at all, and the empty string is what says so: the caller reads a
17137
+ * falsy name as `local`, which hands the reference on to the resolver's own
17138
+ * `PARAMETER_NAME is required` — thrown BEFORE any client is built, so no
17139
+ * `GetParameter` is ever issued for such a token. Without the guard
17140
+ * `indexOf(':')` is `-1` and `substring(0)` returns the SERVICE STRING as the
17141
+ * parameter name, so with a foreign producer region on record the same
17142
+ * malformed input drew the ambiguous-region refusal naming `'ssm-secure'` as
17143
+ * the secret. {@link secretsManagerSecretId} already answers `''` for its own
17144
+ * nameless body (a fixed-length `substring` past the end of the string), and a
17145
+ * sibling that answers something else is the kind of split a later reader has
17146
+ * to rediscover.
17147
+ *
17148
+ * The colon-less relaxation has a CONSUMER-VISIBLE consequence on a mixed
17149
+ * leaf, and it is recorded on {@link classifyReplaySecretRegion} rather than
17150
+ * here: this function produces a NAME, and the delta belongs beside the
17151
+ * VERDICT a reader arrives at it through.
16756
17152
  */
16757
17153
  function ssmParameterName(inner) {
16758
- return inner.substring(inner.indexOf(":") + 1);
17154
+ const serviceEnd = inner.indexOf(":");
17155
+ return serviceEnd < 0 ? "" : inner.substring(serviceEnd + 1);
16759
17156
  }
16760
17157
  /**
16761
17158
  * The region an ARN names, or `undefined` for anything that is not an ARN with
@@ -16893,6 +17290,101 @@ function producerRegionsFromState(state) {
16893
17290
  * A same-region ARN answers `local` even when a foreign producer region IS on
16894
17291
  * record: the expression settles the question itself, so the weaker evidence
16895
17292
  * never gets consulted.
17293
+ *
17294
+ * ---
17295
+ *
17296
+ * WHO CONSUMES THIS VERDICT, and what the issue #2501 colon-less fix changed
17297
+ * for them. Recorded here because a reader arrives at the blast radius through
17298
+ * the verdict, not through {@link ssmParameterName}, which merely produces a
17299
+ * name.
17300
+ *
17301
+ * Three LEAF PRE-PASSES consume it — `rollback-executor.ts`'s
17302
+ * `resolveLeafByRegion`, `cdkd drift`'s `resolveDriftLeafByRegion` and `cdkd
17303
+ * scrub`'s — and each refuses the WHOLE leaf as soon as ONE token classifies
17304
+ * `ambiguous`, before any token is fetched. There is a FOURTH consumer, and it
17305
+ * is not a pre-pass: `resolveDynamicReferences` classifies token-by-token
17306
+ * INSIDE its substitution loop, so it already fetches earlier tokens before
17307
+ * refusing a later one. That is why the pre-fetch refusal was never a designed
17308
+ * property of this verdict.
17309
+ *
17310
+ * Before the fix, a colon-less `{{resolve:ssm-secure}}` produced the SERVICE
17311
+ * STRING as its name, so with a foreign producer region on record it drew an
17312
+ * `ambiguous` verdict and the pre-pass refused the whole leaf: for
17313
+ * `local=<same-region ARN>;secure={{resolve:ssm-secure}}`, nothing was
17314
+ * resolved. It now answers `local`, so the leaf IS resolved — by the primary
17315
+ * resolver when every other token is `local`, or through the segment-rebuild
17316
+ * path when one is `named-region` — and the ARN sibling's plaintext is fetched
17317
+ * and cached before the degenerate token throws `PARAMETER_NAME is required`.
17318
+ * Pinned by `rollback-executor-cross-region-secret.test.ts`'s "the MIXED-leaf
17319
+ * delta the colon-less guard accepts", so the trade is visible rather than
17320
+ * argued.
17321
+ *
17322
+ * Accepted on three grounds, the third being a LIMIT rather than a
17323
+ * reassurance:
17324
+ *
17325
+ * 1. The lost refusal was an ACCIDENT of the mis-parse, not a designed
17326
+ * protection: with no foreign producer region on record — the
17327
+ * overwhelmingly common case — the colon-less token classified `local`
17328
+ * before the fix too, so the same sibling was already fetched.
17329
+ * 2. The sibling is fetched from the region its OWN verdict names, never a
17330
+ * guessed one (a name-form sibling would itself be `ambiguous` and the
17331
+ * pre-pass would still refuse), so nothing here weakens the issue #1957
17332
+ * rule this module exists to enforce. Nothing fetched reaches a DURABLE
17333
+ * sink — `state.json`, the rollback journal, the `deployments/` event
17334
+ * store, a `--json` payload — and the reason is NOT "the op throws before
17335
+ * any write", which is false for scrub (see 3: scrub swallows and keeps
17336
+ * writing). It is that both places a fetched plaintext is retained are
17337
+ * IN-PROCESS and neither is copied out: the resolver's own
17338
+ * `cachedDynamicReferences` (instance-scoped since issue #1933, so it dies
17339
+ * with the resolver) and `recordedSecretValues`, which every persist path
17340
+ * consults as a redaction NEEDLE set — an entry there causes a value to be
17341
+ * REPLACED BY its expression on the way out, never inserted.
17342
+ *
17343
+ * ON THIS PATH one more needle can only redact more, which is why the
17344
+ * direction is safe even though it fetches more — and the qualifier is
17345
+ * load-bearing rather than hedging. It is NOT a general property of the
17346
+ * needle machinery: `redactSecretsForState`'s own doc (see the
17347
+ * `preferPositionDecisions` ordering note) records that a needle rewriting
17348
+ * a FRAME ANCHOR can un-certify `unkeyedArrayPairsByAnchors`, refuse the
17349
+ * array, and leave a sibling MIXED leaf in plaintext — "a regression of
17350
+ * shipped redaction, in the GHSA disclosure direction". That is reachable
17351
+ * with a NON-EMPTY map on a `STATE_SOURCED_READBACK_RULES` caller, e.g.
17352
+ * `rollback-executor.ts`'s `redactRollbackRecord` ->
17353
+ * `scrubResourceRecord`. It is not what fires here: the case this
17354
+ * paragraph is about records FEWER needles, not more, so nothing new can
17355
+ * rewrite an anchor.
17356
+ * 3. WHAT THE OP DOES NEXT IS NOT UNIFORM. The replay fails the op and `cdkd
17357
+ * drift` reports the resource NOT compared — both loud. `cdkd scrub` is
17358
+ * NOT: `isRegionAmbiguousRefusal` re-raises only a
17359
+ * `DynamicReferenceRegionAmbiguousError`, and the resolver's replacement is
17360
+ * a plain `Error`, so scrub swallows it to `debug` and the run reports
17361
+ * clean. A LOUDNESS regression for that one command on this one input,
17362
+ * bounded by the identical silent miss that already happens for the same
17363
+ * leaf whenever no foreign producer region is on record.
17364
+ *
17365
+ * Issue [#2692](https://github.com/go-to-k/cdkd/issues/2692) tracks it, and
17366
+ * the remedy is NOT resolver-local. Two candidate fixes are wrong, each in
17367
+ * its own way, and scrub has TWO predicates that must not be confused:
17368
+ * `isRegionAmbiguousRefusal` decides RE-RAISE vs SWALLOW (its three call
17369
+ * sites), while `isByDesignRefusal` — matching
17370
+ * `CrossAccountSecretRefusalError` — decides FINDING vs REFUSE. They point
17371
+ * in opposite directions, so a fix aimed at one must not be argued from
17372
+ * the other's record.
17373
+ *
17374
+ * - Throwing `IntrinsicResolutionRefusalError` and widening
17375
+ * `isRegionAmbiguousRefusal` to match the BASE class would make every
17376
+ * user-fixable refusal that class carries RE-RAISE and fail the whole
17377
+ * stack — not the silent-downgrade hazard `isByDesignRefusal`'s doc
17378
+ * records, but the opposite over-refusal, on a class with throw sites
17379
+ * spread across the resolver.
17380
+ * - Throwing the region-ambiguous SUBCLASS is wrong differently: scrub
17381
+ * already re-raises it, but its message tells the user to spell the
17382
+ * reference as a full ARN, which cannot fix a reference naming no
17383
+ * parameter.
17384
+ *
17385
+ * So #2692 needs a NEW sibling subclass AND a scrub-side predicate change,
17386
+ * which makes it blocked on `src/cli/commands/scrub.ts` (held by PR
17387
+ * #2562), not merely on the resolver's `integ-broad` cost.
16896
17388
  */
16897
17389
  function classifyReplaySecretRegion(expression, consumerRegion, importedProducerRegions) {
16898
17390
  const inner = expression.startsWith("{{resolve:") ? expression.slice(10, -2) : void 0;
@@ -18586,7 +19078,7 @@ function carriesDynamicReference(value) {
18586
19078
  return false;
18587
19079
  }
18588
19080
  /** The nested-stack resource type, whose `Outputs.<Name>` attributes are re-resolved (issue #2055). */
18589
- const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
19081
+ const NESTED_STACK_RESOURCE_TYPE$1 = "AWS::CloudFormation::Stack";
18590
19082
  /** Prefix `NestedStackProvider` records a child stack output under. */
18591
19083
  const NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX = "Outputs.";
18592
19084
  /**
@@ -20077,7 +20569,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
20077
20569
  return this.noteAttributeSecrecy(logicalId, attributeName, nameServers, context);
20078
20570
  }
20079
20571
  this.logger.debug(`Resolved Fn::GetAtt from attributes: ${logicalId}.${attributeName} -> ${stringifyAttributeForLog(attributeName, flatValue)}`);
20080
- if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX) && carriesDynamicReference(flatValue)) return await this.reresolveCrossStackValue(flatValue, nestedStackChildRegionFromLocalArn(resource.physicalId), context, `nested stack ${logicalId} ${attributeName}`, crossStackSourceKey({ "Fn::GetAtt": getAtt }));
20572
+ if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE$1 && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX) && carriesDynamicReference(flatValue)) return await this.reresolveCrossStackValue(flatValue, nestedStackChildRegionFromLocalArn(resource.physicalId), context, `nested stack ${logicalId} ${attributeName}`, crossStackSourceKey({ "Fn::GetAtt": getAtt }));
20081
20573
  return this.noteAttributeSecrecy(logicalId, attributeName, flatValue, context);
20082
20574
  }
20083
20575
  if (attributeName.includes(".")) {
@@ -20094,7 +20586,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
20094
20586
  }
20095
20587
  }
20096
20588
  }
20097
- if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)) {
20589
+ if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE$1 && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)) {
20098
20590
  const declared = Object.keys(resource.attributes ?? {}).filter((k) => k.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)).map((k) => k.slice(8)).sort();
20099
20591
  throw markNonRetryable(new IntrinsicResolutionRefusalError(`Cannot resolve Fn::GetAtt [${logicalId}, ${attributeName}]: the nested stack '${logicalId}' declares no output named '${attributeName.slice(8)}'. Its outputs are ${declared.length > 0 ? declared.join(", ") : "(none)"}. Check the output name in the nested stack's template, and deploy the child stack again if you have just added it.`));
20100
20592
  }
@@ -23308,7 +23800,7 @@ var CloudControlProvider = class {
23308
23800
  const indeterminateGuard = await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
23309
23801
  if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
23310
23802
  this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
23311
- const { ASGProvider } = await import("./asg-provider-uaOnRUMe.js").then((n) => n.n);
23803
+ const { ASGProvider } = await import("./asg-provider-dBSdt3nB.js").then((n) => n.n);
23312
23804
  return withIndeterminateGuard(await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context), indeterminateGuard);
23313
23805
  }
23314
23806
  const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
@@ -25493,7 +25985,7 @@ var CustomResourceProvider = class CustomResourceProvider {
25493
25985
  reuseClientCredentials: true,
25494
25986
  tolerateNonStandardClient: true,
25495
25987
  onRebuild: ({ bucketRegion, currentRegion }) => {
25496
- this.logger.debug(`Custom resource response bucket '${bucket}' is in '${bucketRegion}' (client was '${String(currentRegion)}'); building a region-corrected S3 client for response operations.`);
25988
+ this.logger.debug(`Custom resource response bucket '${displaySafe(bucket, { asciiOnly: true }) || "<unrenderable>"}' is in '${displaySafe(bucketRegion, { asciiOnly: true }) || "<unrenderable>"}' (client was '${displaySafe(currentRegion, { asciiOnly: true }) || "<unrenderable>"}'); building a region-corrected S3 client for response operations.`);
25497
25989
  }
25498
25990
  });
25499
25991
  if (generation !== this.responseClientGeneration) {
@@ -26170,7 +26662,7 @@ var CustomResourceProvider = class CustomResourceProvider {
26170
26662
  Key: responseKey
26171
26663
  });
26172
26664
  const presignedUrl = await getSignedUrl(this.s3Client, command, { expiresIn: 7200 });
26173
- this.logger.debug(`Generated pre-signed URL for response: s3://${this.responseBucket}/${responseKey}`);
26665
+ this.logger.debug(`Generated pre-signed URL for response: s3://${displaySafe(this.responseBucket, { asciiOnly: true }) || "<unrenderable>"}/${displaySafe(responseKey)}`);
26174
26666
  return presignedUrl;
26175
26667
  }
26176
26668
  /**
@@ -26274,7 +26766,7 @@ var CustomResourceProvider = class CustomResourceProvider {
26274
26766
  Key: responseKey
26275
26767
  }));
26276
26768
  } catch (error) {
26277
- this.logger.debug(`Failed to delete custom-resource response object s3://${bucket}/${responseKey}; it remains as a current object. Underlying error: ${error instanceof Error ? error.message : String(error)}`);
26769
+ this.logger.debug(`Failed to delete custom-resource response object s3://${displaySafe(bucket, { asciiOnly: true }) || "<unrenderable>"}/${displaySafe(responseKey)}; it remains as a current object. Underlying error: ${displaySafe(error instanceof Error ? error.message : String(error)) || "<unrenderable>"}`);
26278
26770
  }
26279
26771
  await purgeNoncurrentKeyVersions(this.s3Client, bucket, [responseKey], {
26280
26772
  logger: this.logger,
@@ -29771,6 +30263,70 @@ function getCurrentResourceSecrets() {
29771
30263
  return currentResourceSecretsStore.getStore();
29772
30264
  }
29773
30265
 
30266
+ //#endregion
30267
+ //#region src/deployment/type-change-guard.ts
30268
+ /**
30269
+ * The CFn type of a nested stack's row in its PARENT's template.
30270
+ *
30271
+ * Spelled locally rather than imported, matching
30272
+ * `src/deployment/recreate-targets.ts`: the only exported copy lives in
30273
+ * `src/cli/commands/retire-cfn-stack.ts`, and importing a CLI command module
30274
+ * from the deployment layer would invert the dependency direction.
30275
+ */
30276
+ const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
30277
+ /**
30278
+ * Find every planned change whose recorded type and template type differ with
30279
+ * `AWS::CloudFormation::Stack` on one side.
30280
+ *
30281
+ * Reads exactly the two values the defect is made of: `change.resourceType`
30282
+ * (what `provisionResource` binds and routes BOTH replacement halves on) and
30283
+ * the state record's `resourceType` (the resource that actually exists). That
30284
+ * is deliberate — deriving the "desired" type from the template again would be
30285
+ * a second implementation of the diff's own Type-change rule (metadata skip,
30286
+ * condition pruning) which could drift away from the routing decision this
30287
+ * guards.
30288
+ *
30289
+ * `changeType` is not filtered on. A Type change surfaces as an `UPDATE`, but a
30290
+ * DELETE / NO_CHANGE row cannot diverge in the first place (the diff builds
30291
+ * both from the state record's own type), so filtering would only add a way for
30292
+ * a future change-shape to slip past.
30293
+ */
30294
+ function findNestedStackTypeChanges(input) {
30295
+ const found = [];
30296
+ for (const [logicalId, change] of input.changes) {
30297
+ if (!Object.hasOwn(input.stateResources, logicalId)) continue;
30298
+ const currentResource = input.stateResources[logicalId];
30299
+ if (!currentResource) continue;
30300
+ const currentType = currentResource.resourceType;
30301
+ const desiredType = change.resourceType;
30302
+ if (currentType === desiredType) continue;
30303
+ const intoNested = desiredType === NESTED_STACK_RESOURCE_TYPE;
30304
+ if (!intoNested && !(currentType === NESTED_STACK_RESOURCE_TYPE)) continue;
30305
+ found.push({
30306
+ logicalId,
30307
+ currentType,
30308
+ desiredType,
30309
+ physicalId: currentResource.physicalId,
30310
+ direction: intoNested ? "into-nested-stack" : "out-of-nested-stack"
30311
+ });
30312
+ }
30313
+ return found;
30314
+ }
30315
+ /**
30316
+ * Render the refusal. Names the logical id, BOTH types, the resource the
30317
+ * mis-routed delete would be aimed at, and what to do instead.
30318
+ *
30319
+ * `stackName` is the stack being deployed, so the into-nested arm can print the
30320
+ * child stack name `NestedStackProvider.delete` would derive and destroy — the
30321
+ * one piece of the damage the user cannot read off their own template.
30322
+ */
30323
+ function renderNestedStackTypeChangeRefusal(typeChanges, stackName) {
30324
+ const rows = typeChanges.map((tc) => {
30325
+ return `${` - ${tc.logicalId}: Type changes from ${tc.currentType} to ${tc.desiredType} (the existing ${tc.currentType} is ${tc.physicalId}).`}\n${tc.direction === "into-nested-stack" ? ` Both halves of the replacement would route on the TEMPLATE's type, so the existing resource's delete would be dispatched at the ${NESTED_STACK_RESOURCE_TYPE} provider — which ignores the physical id it is handed and instead destroys the nested child stack "${stackName}~${tc.logicalId}" and every resource that child owns. Where no such child exists the delete is a no-op and ${tc.physicalId} is silently leaked instead.` : ` Both halves of the replacement would route on the TEMPLATE's type, so the existing nested stack's delete would be dispatched at the ${tc.desiredType} provider, which cannot delete a nested stack — the child stack "${stackName}~${tc.logicalId}" and every resource it owns would be left behind, untracked.`}`;
30326
+ });
30327
+ return `Refusing to deploy ${stackName}: ` + (typeChanges.length === 1 ? `a resource changes its Type ` : `${typeChanges.length} resources change their Type `) + `into or out of ${NESTED_STACK_RESOURCE_TYPE}, which cdkd cannot replace safely (issue #2668).\n${rows.join("\n")}\n Deploy this as two changes instead: give the new resource a DIFFERENT logical id (in CDK, rename the construct) so the existing row is deleted through its own type's provider and the new one is created under its own — or remove the resource in one deploy and add its replacement in the next. There is no flag that overrides this refusal: the delete's TARGET would be wrong, not merely its consequences.`;
30328
+ }
30329
+
29774
30330
  //#endregion
29775
30331
  //#region src/deployment/outputs-export-alias.ts
29776
30332
  /**
@@ -30094,6 +30650,218 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
30094
30650
  }
30095
30651
  }
30096
30652
 
30653
+ //#endregion
30654
+ //#region src/provisioning/dynamodb-warm-throughput.ts
30655
+ /**
30656
+ * The two DynamoDB `WarmThroughput` rules that `AWS::DynamoDB::Table` and
30657
+ * `AWS::DynamoDB::GlobalTable` both need, in ONE spelling.
30658
+ *
30659
+ * `WarmThroughput` is the same CloudFormation block on both types, and both
30660
+ * providers have to answer the same two questions about it before an
30661
+ * `UpdateTable` / `CreateTable` goes out:
30662
+ *
30663
+ * 1. **Is this block SENDABLE, and as what numbers?** CloudFormation is
30664
+ * stringly typed, so `{ReadUnitsPerSecond: '12000'}` — or anything that
30665
+ * came back from an `Fn::Sub` — arrives as a STRING and must not be
30666
+ * forwarded verbatim into a numeric `Long` field.
30667
+ * 2. **Would sending it LOWER what AWS already reports?** Warm throughput
30668
+ * only ever rises with a table's traffic and AWS REJECTS a call that
30669
+ * lowers it (`decreasing WarmThroughput is not supported`, measured live
30670
+ * us-east-1 2026-08-13 for issue #1768).
30671
+ *
30672
+ * **Provenance, stated precisely because it is easy to over-claim.** Exactly
30673
+ * ONE of these rules ever shipped: `AWS::DynamoDB::Table` got them in PR #1808
30674
+ * (issues #1760 / #1768), and the `AWS::DynamoDB::GlobalTable` side never
30675
+ * existed outside the change that created this file (issue #1857). So this is
30676
+ * the Table rule LIFTED — not two shipped rules reconciled, and no deployed
30677
+ * behaviour changed for either type when it moved here.
30678
+ *
30679
+ * What WAS compared is the lifted rule against the GlobalTable spelling
30680
+ * drafted alongside it, over the shapes both had to answer (a quoted numeric
30681
+ * string, a partially usable block, a mixed decrease/increase, an absent live
30682
+ * value, a zero, a negative, a non-numeric string, a whitespace string, an
30683
+ * explicit `undefined` / `null`, a boolean, an empty block, a scalar, an
30684
+ * array). {@link isWarmThroughputDecrease} agreed on every one; the coercion
30685
+ * agreed on all but a whitespace-only string, where a bare `Number(' ')` is
30686
+ * `0` rather than `NaN`. This module keeps the REFUSING answer for that shape,
30687
+ * since `' '` is not a capacity anyone declared and a warm throughput of 0
30688
+ * is not a request AWS can honour. That is a DRAFT reconciled against a
30689
+ * shipped rule, which is worth less than two shipped rules agreeing — the
30690
+ * reason to read the probe list as a design note rather than as field
30691
+ * evidence.
30692
+ *
30693
+ * Living here rather than in either provider is the point. A decrease guard is
30694
+ * three clauses that all FAIL OPEN for different reasons, and two files
30695
+ * spelling it independently is two chances for a later "fix" to change one of
30696
+ * them — at which point the sibling type silently keeps the old answer, and
30697
+ * nothing in the tree says which is intended. Same class as
30698
+ * `emr-configuration.ts`, and the same reason.
30699
+ *
30700
+ * Deliberately NOT here: everything only ONE provider has. The `Table` side's
30701
+ * `isSendableWarmThroughput` / `isRefusedWarmThroughput` /
30702
+ * `declaresWarmThroughput` / `warmThroughputAlreadyMatches` are its drift-side
30703
+ * and already-matches gates, which `GlobalTable` has no counterpart to (issue
30704
+ * #1742 strips the per-index `WarmThroughput` from BOTH of its drift
30705
+ * comparison sides unconditionally, so drift never asks the question there);
30706
+ * the `GlobalTable` side's `warmThroughputDiagnostic` builds a
30707
+ * `ThroughputDiagnostic` that only that provider's collector understands.
30708
+ * Moving a helper with one caller here would buy nothing and cost a hop.
30709
+ *
30710
+ * Issues: #1760 / #1768 (Table, PR #1808), #1857 (GlobalTable).
30711
+ */
30712
+ /**
30713
+ * The two `WarmThroughput` members, in the ONE order every message, every
30714
+ * comparison and every emitted block uses. A shared order is what makes two
30715
+ * blocks carrying the same numbers compare equal by `deepEqual` and serialize
30716
+ * to the same wire bytes regardless of the order the template wrote them in.
30717
+ */
30718
+ const WARM_THROUGHPUT_MEMBERS = ["ReadUnitsPerSecond", "WriteUnitsPerSecond"];
30719
+ /**
30720
+ * A CloudFormation-borne numeric property, or `undefined` when the value is
30721
+ * not a usable number.
30722
+ *
30723
+ * Plain `Number()` coercion is NOT good enough and the tree learned it three
30724
+ * separate times: `Number(null)`, `Number('')`, `Number([])`, `Number(false)`
30725
+ * and `Number(' ')` are all **0**, not `NaN` — so a live `0` would compare
30726
+ * EQUAL to a desired `null` / `''` / `[]` / `false`, and a whitespace-only
30727
+ * string would be forwarded as a request for zero units.
30728
+ *
30729
+ * A YAML-borne numeric STRING is still accepted, because that is a real
30730
+ * template shape and `'12000'` genuinely means 12000.
30731
+ *
30732
+ * The accepted STRING set is `Number()`'s, which is WIDER than a decimal
30733
+ * integer: `'0x1e'`, `'0o36'`, `'1e3'`, `'30.5'` and `' 30 '` all coerce.
30734
+ * Whether CloudFormation accepts those for an `Integer`-typed property is
30735
+ * unmeasured, and issue [#2698](https://github.com/go-to-k/cdkd/issues/2698)
30736
+ * holds the live A/B that would settle it. It matters at the CALLERS that
30737
+ * FORWARD the result to AWS rather than merely compare it — narrowing this
30738
+ * shared helper would change the DynamoDB capacity readers too, so read that
30739
+ * issue before tightening anything here.
30740
+ *
30741
+ * EXPORTED, and not because warm throughput needs it exported. This exact rule
30742
+ * was hand-written three times — here, as `dynamodb-table-provider.ts`'s
30743
+ * `capacityNumber` (byte-identical), and as `dynamodb-globaltable-provider.ts`'s
30744
+ * `toFiniteNumber` (a different spelling of the same total function) — which is
30745
+ * precisely the divergence this module exists to stop: the next "fix" to one of
30746
+ * them would have left the other two answering differently, with nothing in the
30747
+ * tree saying which was intended. It reads `ReadCapacityUnits` /
30748
+ * `MaxReadRequestUnits` / `MinCapacity` as readily as it reads
30749
+ * `ReadUnitsPerSecond`; there is only ever one right answer for "is this
30750
+ * stringly-typed CFn value a number I can send?".
30751
+ */
30752
+ function toFiniteNumber(value) {
30753
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
30754
+ if (typeof value === "string" && value.trim() !== "") {
30755
+ const n = Number(value);
30756
+ return Number.isFinite(n) ? n : void 0;
30757
+ }
30758
+ }
30759
+ /**
30760
+ * Coerce a CFn `WarmThroughput` block to the numeric shape the SDK's `Long`
30761
+ * fields accept, PER MEMBER.
30762
+ *
30763
+ * PER MEMBER, not whole-block, and that distinction is the point: a block
30764
+ * whose write half is an unresolved intrinsic still has a perfectly good read
30765
+ * half, and dropping both would silently discard a value the template really
30766
+ * did ask for. The dropped member is NAMED (`droppedMembers`) so the caller's
30767
+ * warning can say which half went missing instead of reporting the whole
30768
+ * property.
30769
+ *
30770
+ * A block with NO usable member yields `spec: undefined` — refused rather than
30771
+ * forwarded, because forwarding a malformed block surfaces as an opaque AWS
30772
+ * validation error naming neither cdkd nor the property. `droppedMembers` is
30773
+ * still populated in that case, so a REFUSAL message can name what it refused;
30774
+ * a block that is not an object at all (an unresolved `Fn::If`, a scalar, an
30775
+ * array) has no member to name and reports none, which is what lets a caller
30776
+ * word "one half went missing" differently from "the block is unusable".
30777
+ *
30778
+ * Pure: takes the raw bag, returns numbers, logs nothing. Each caller owns its
30779
+ * own message, so the wording stays consistent across that provider's send
30780
+ * sites without forcing one wording across both providers.
30781
+ */
30782
+ function coerceWarmThroughput(raw) {
30783
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { droppedMembers: [] };
30784
+ const bag = raw;
30785
+ const spec = {};
30786
+ const droppedMembers = [];
30787
+ for (const member of WARM_THROUGHPUT_MEMBERS) {
30788
+ if (bag[member] === void 0) continue;
30789
+ const coerced = toFiniteNumber(bag[member]);
30790
+ if (coerced === void 0) {
30791
+ droppedMembers.push(member);
30792
+ continue;
30793
+ }
30794
+ spec[member] = coerced;
30795
+ }
30796
+ if (Object.keys(spec).length === 0) return { droppedMembers };
30797
+ return {
30798
+ spec,
30799
+ droppedMembers
30800
+ };
30801
+ }
30802
+ /**
30803
+ * Whether the COERCED desired `WarmThroughput` would LOWER what AWS already
30804
+ * reports.
30805
+ *
30806
+ * Measured live (us-east-1, 2026-08-13, issue #1768) against a table AWS
30807
+ * reports `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
30808
+ *
30809
+ * ```
30810
+ * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
30811
+ * ValidationException: One or more parameter values were invalid: Requested
30812
+ * ReadUnitsPerSecond for WarmThroughput for table is lower than current
30813
+ * WarmThroughput, decreasing WarmThroughput is not supported
30814
+ * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
30815
+ * UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
30816
+ * UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
30817
+ * ```
30818
+ *
30819
+ * So the value is one AWS raises with the table's traffic and never lowers,
30820
+ * and a decrease is REJECTED rather than accepted-and-ignored — which is what
30821
+ * had to be measured before choosing, because the two produce different
30822
+ * correct answers. The caller SKIPS the call on a true here.
30823
+ *
30824
+ * Evaluated on the COERCED spec, never on the raw bag: analysing the raw bag
30825
+ * makes the verdict describe a request that is not the one being sent (a
30826
+ * dropped member is not part of the call and must not be part of the
30827
+ * comparison).
30828
+ *
30829
+ * Semantics, all three chosen to FAIL OPEN — i.e. to let the call through and
30830
+ * leave AWS as the authority — because a false positive here silently drops a
30831
+ * legitimate INCREASE, which is a real capacity change the user asked for,
30832
+ * while a false negative merely reproduces the pre-fix behaviour of an
30833
+ * AWS-side rejection that names the property:
30834
+ * - DECLARED members only. An absent member is not a request to lower
30835
+ * anything, so it takes no part in the verdict.
30836
+ * - MIXED is not a decrease. One member below live and the other above means
30837
+ * the call carries a genuine increase; AWS decides.
30838
+ * - An absent or unusable LIVE counterpart is not a decrease. Without a
30839
+ * number to compare against there is no evidence of one. Only the LIVE side
30840
+ * can reach that arm: the desired side is a COERCED spec, so a malformed
30841
+ * template value has already been dropped by {@link coerceWarmThroughput}.
30842
+ *
30843
+ * A decrease therefore requires every declared member to be at-or-below live
30844
+ * AND at least one to be strictly below.
30845
+ *
30846
+ * The skip this drives is right for EVERY `update()` caller — the deploy
30847
+ * engine, `cdkd drift --revert`, and the rollback executor's two revert arms —
30848
+ * because none of them can make AWS lower the value, so none loses anything a
30849
+ * doomed call would have achieved.
30850
+ */
30851
+ function isWarmThroughputDecrease(desired, live) {
30852
+ if (desired === void 0 || live === void 0) return false;
30853
+ let sawDecrease = false;
30854
+ for (const member of WARM_THROUGHPUT_MEMBERS) {
30855
+ if (desired[member] === void 0) continue;
30856
+ const wanted = toFiniteNumber(desired[member]);
30857
+ const current = toFiniteNumber(live[member]);
30858
+ if (wanted === void 0 || current === void 0) return false;
30859
+ if (wanted > current) return false;
30860
+ if (wanted < current) sawDecrease = true;
30861
+ }
30862
+ return sawDecrease;
30863
+ }
30864
+
30097
30865
  //#endregion
30098
30866
  //#region src/provisioning/stateful-types.ts
30099
30867
  /**
@@ -30140,9 +30908,12 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
30140
30908
  * "never expire" — the most data-bearing configuration the type
30141
30909
  * has, and what `LogsLogGroupProvider` records `0` for — so reading
30142
30910
  * it as "holds nothing" destroyed years of events on a plain
30143
- * `cdkd deploy`. A recorded `RetentionInDays > 0` still answers
30144
- * `has-retention` from the bag alone (a cheap positive, never
30145
- * probed away); every other bag DEFERS, exactly as the bucket does.
30911
+ * `cdkd deploy`. A `RetentionInDays > 0` recorded in EITHER of the
30912
+ * state record's property bags still answers `has-retention` from
30913
+ * the bags alone (a cheap positive, never probed away); every other
30914
+ * bag DEFERS, exactly as the bucket does. Which bags, and why the
30915
+ * value is coerced rather than type-tested, is issue [#2521] —
30916
+ * see {@link logGroupHasPositiveRetention}.
30146
30917
  * The pre-flight resolves the deferral with a live
30147
30918
  * `logs:DescribeLogStreams` probe (a log group with no stream can
30148
30919
  * hold no event, since every event belongs to a stream); mid-deploy,
@@ -30327,10 +31098,66 @@ const STATEFUL_TYPES = /* @__PURE__ */ new Set([
30327
31098
  */
30328
31099
  const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::DynamoDB::GlobalTable"]);
30329
31100
  /**
30330
- * Cheap, synchronous read of the resource's recorded properties only.
31101
+ * Does either recorded bag prove this log group carries a POSITIVE
31102
+ * `RetentionInDays`? (issue [#2521])
31103
+ *
31104
+ * Two decisions, and both were defects before this function existed.
31105
+ *
31106
+ * **The value is COERCED, not type-tested.** The predicate used to gate on
31107
+ * `typeof retention === 'number' && retention > 0`, and CloudFormation is
31108
+ * stringly typed: a hand-written L1, an `Fn::Sub`-produced value, a
31109
+ * `Type: String` parameter's default, or a record imported by
31110
+ * `cdkd import --migrate-from-cloudformation` all put `'30'` into the bag,
31111
+ * where the type test answers "not a number". {@link toFiniteNumber} is the
31112
+ * repo's single answer to "is this stringly-typed CFn value a number?" —
31113
+ * imported rather than re-spelled here for the reason its own doc gives, that
31114
+ * the same rule hand-written twice diverges on the next fix to one copy. It
31115
+ * rejects `''`, `' '`, `null`, `[]` and `false`, each of which a bare
31116
+ * `Number()` turns into a `0` that would read as never-expire.
31117
+ *
31118
+ * **BOTH bags are consulted, and a positive in EITHER wins.** The old read
31119
+ * saw `properties` alone, so a retention set OUT OF BAND (the console,
31120
+ * `aws logs put-retention-policy`, another tool) or one recorded only in
31121
+ * `observedProperties` — an imported record whose template never declared
31122
+ * the property — never produced `has-retention`.
31123
+ *
31124
+ * The rule is a plain OR, and the loop order below is INERT — a review round
31125
+ * caught an earlier version of this paragraph claiming `observedProperties`
31126
+ * is "read FIRST", a precedence the code does not implement and must not.
31127
+ * A strict "observed when present, else properties" rule — which is what
31128
+ * issue [#2521] literally prescribed — was tried and REJECTED:
31129
+ * `LogsLogGroupProvider.readCurrentState` writes `RetentionInDays: 0` for a
31130
+ * group with no retention policy, so the observed bag almost always CARRIES
31131
+ * the key, and precedence would make the recorded bag dead for every record
31132
+ * that has ever been captured and would DROP the `has-retention` verdict this
31133
+ * guard already produced. A test pins that rejection
31134
+ * (`tests/unit/provisioning/stateful-types.test.ts`, "reads a retention that
31135
+ * lives ONLY in properties, even against a ZERO observed one"). The OR is
31136
+ * also the strictly safer direction: it can only ADD refusals, never remove
31137
+ * one.
31138
+ *
31139
+ * What a `false` here means is DEFER, not "holds nothing" — see the callers.
31140
+ */
31141
+ function logGroupHasPositiveRetention(recordedProperties, observedProperties) {
31142
+ for (const bag of [observedProperties, recordedProperties]) {
31143
+ const retention = toFiniteNumber(bag?.["RetentionInDays"]);
31144
+ if (retention !== void 0 && retention > 0) return true;
31145
+ }
31146
+ return false;
31147
+ }
31148
+ /**
31149
+ * Cheap, synchronous read of the state record's own property bags only —
31150
+ * no AWS call. Both bags are parameters rather than one: `properties` is
31151
+ * what the last deploy applied, `observedProperties` what it read back,
31152
+ * and the log group's arm consults BOTH (issue [#2521]). Passing the
31153
+ * observed bag is REQUIRED, not optional, so a new call site has to
31154
+ * decide what it holds instead of silently repeating the omission that
31155
+ * issue records; `undefined` is the right answer where no record is in
31156
+ * hand (`recreate-confirm-prompt.ts`).
31157
+ *
30331
31158
  * TWO types return `null` meaning DEFER rather than "not stateful":
30332
- * `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever the
30333
- * recorded bag does not already prove `has-retention`. The live probes
31159
+ * `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever neither
31160
+ * bag already proves `has-retention`. The live probes
30334
31161
  * that resolve both deferrals (`ListObjectVersions` for the bucket,
30335
31162
  * `DescribeLogStreams` for the log group) live in
30336
31163
  * `src/deployment/recreate-targets.ts#probeStatefulRecreateTargetsAsync`
@@ -30343,11 +31170,10 @@ const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::Dynam
30343
31170
  * Returns the {@link StatefulReason} when the type is stateful (or
30344
31171
  * `null` for non-stateful types).
30345
31172
  */
30346
- function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
31173
+ function isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties) {
30347
31174
  if (!STATEFUL_TYPES.has(resourceType)) return null;
30348
31175
  if (resourceType === "AWS::Logs::LogGroup") {
30349
- const retention = recordedProperties?.["RetentionInDays"];
30350
- if (typeof retention === "number" && retention > 0) return "has-retention";
31176
+ if (logGroupHasPositiveRetention(recordedProperties, observedProperties)) return "has-retention";
30351
31177
  return null;
30352
31178
  }
30353
31179
  if (resourceType === "AWS::S3::Bucket") return null;
@@ -30374,16 +31200,38 @@ function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
30374
31200
  * — be DELETE + CREATEd (data loss) without `--force-stateful-recreation`. To
30375
31201
  * stay fail-safe, both deferrals resolve to stateful here: the user must pass
30376
31202
  * `--force-stateful-recreation` to replace ANY S3 bucket, and any log group
30377
- * whose recorded bag does not already prove `has-retention`, on any of those
30378
- * paths — empty or not.
31203
+ * neither of whose recorded property bags already proves `has-retention`, on
31204
+ * any of those paths — empty or not.
31205
+ *
31206
+ * `UpdateReplacePolicy: Retain` is the standing EXEMPTION, and it covers all
31207
+ * three (issue [#2604]): the engine never consults this predicate under it —
31208
+ * the property-driven guard tests `updateReplacePolicy !== 'Retain'`
31209
+ * directly, and the fallback's two triggers short-circuit through
31210
+ * `retainOldOnReplace`, which issue [#2518] added. The old resource survives
31211
+ * the replacement, so there is no data loss to confirm, and the refusal's own
31212
+ * remedy would have destroyed exactly what the user asked to keep.
31213
+ * `Snapshot` is NOT exempt on any of them — a snapshot is a copy, not a
31214
+ * surviving resource. So read the paragraph above as scoped to a template
31215
+ * that is not retaining.
31216
+ *
31217
+ * The two engine sites are not the only callers: a THIRD sits outside those
31218
+ * paths and outside that exemption — `recreate-confirm-prompt.ts` re-derives
31219
+ * a `null` verdict for the `--recreate-via-*` pre-flight, where
31220
+ * `--force-stateful-recreation` is what skipped the probe.
31221
+ * `stateful-replace-message-doc-sync` pins the whole guard's reader list by
31222
+ * file, but the residuals comment above its own walk enumerates what that
31223
+ * cannot see — among them an ALIASED import, a `.mts` / `.cts` reader, and,
31224
+ * the one that bit, a NEW PATH routed through an existing call site, which
31225
+ * adds no file and reds nothing (issue [#2514]'s shape). So this enumeration
31226
+ * is maintained by hand.
30379
31227
  *
30380
31228
  * The log group's arm is the one issue [#2558] added, and the reason it is
30381
31229
  * needed is that the old predicate treated "no retention recorded" as "holds
30382
31230
  * nothing" when it is CloudWatch Logs' never-expire. Every other type matches
30383
31231
  * {@link isStatefulRecreateTargetSync} exactly.
30384
31232
  */
30385
- function isStatefulRecreateTargetForReplace(resourceType, recordedProperties) {
30386
- const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties);
31233
+ function isStatefulRecreateTargetForReplace(resourceType, recordedProperties, observedProperties) {
31234
+ const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties);
30387
31235
  if (sync) return sync;
30388
31236
  if (resourceType === "AWS::S3::Bucket") return "has-objects";
30389
31237
  if (resourceType === "AWS::Logs::LogGroup") return "has-log-events";
@@ -31545,6 +32393,84 @@ function rollbackFinalSnapshotId(resourceType, record, fallbackProvisionedBy) {
31545
32393
  return buildFinalSnapshotIdentifier(record.physicalId, resourceType);
31546
32394
  }
31547
32395
  /**
32396
+ * `UpdateReplacePolicy: Retain` on the resource a replacement CREATED — the
32397
+ * copy a rollback would otherwise destroy (issue
32398
+ * [#2598](https://github.com/go-to-k/cdkd/issues/2598)).
32399
+ *
32400
+ * Reads the CURRENT record, i.e. the one the replacing deploy wrote from the
32401
+ * template it was applying (`extractTemplateAttributes`), so the attribute
32402
+ * consulted is the one that was in force when the new copy was created. Its
32403
+ * `Snapshot` sibling, {@link rollbackFinalSnapshotId}, reads the same field of
32404
+ * the same record — `Retain` and `Snapshot` are alternative values of ONE
32405
+ * attribute, so the two can never both apply.
32406
+ *
32407
+ * **`UpdateReplacePolicy`, NOT `DeletionPolicy`, and that is measured, not
32408
+ * reasoned.** The repo refuses a CloudFormation-parity claim taken on
32409
+ * folklore, and the AWS documentation answers nothing here: every sentence on
32410
+ * both attribute pages, in the API reference and in the release notes
32411
+ * describes the OLD resource, never the new copy's fate during a rollback. A
32412
+ * live four-variant A/B (2026-09-05, us-east-1: a forced `AWS::SSM::Parameter`
32413
+ * replacement plus a deterministically failing sibling, rolled back) settled
32414
+ * it:
32415
+ *
32416
+ * | DeletionPolicy | UpdateReplacePolicy | new copy | decisive event |
32417
+ * | -------------- | ------------------- | --------- | ---------------- |
32418
+ * | (none) | (none) | DELETED | `DELETE_COMPLETE` |
32419
+ * | Retain | (none) | DELETED | `DELETE_COMPLETE` |
32420
+ * | (none) | Retain | SURVIVED | `DELETE_SKIPPED` |
32421
+ * | Retain | Retain | SURVIVED | `DELETE_SKIPPED` |
32422
+ *
32423
+ * Row 2 alone refutes "`DeletionPolicy` governs it"; row 3 alone refutes
32424
+ * "neither — always deleted". The old copy was restored intact in all four,
32425
+ * and both outcomes land in `UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS`.
32426
+ *
32427
+ * A retained new copy is ORPHANED OUT of the stack, not kept as a managed
32428
+ * resource — the A/B proved it by deleting the whole stack afterwards and
32429
+ * finding the retained parameter still alive. So every caller below leaves NO
32430
+ * state record naming the survivor: the two arms that can complete point state
32431
+ * at the old resource exactly as they already did, and the survivor becomes
32432
+ * untracked. That is the same disposition the deploy engine gives a
32433
+ * `Retain`-orphaned OLD resource, so the two directions agree.
32434
+ *
32435
+ * LIMIT OF THAT EVIDENCE, stated so a later reader does not over-read it: all
32436
+ * four variants carried the SAME policy in both template versions, so the A/B
32437
+ * pinned WHICH ATTRIBUTE wins and did NOT discriminate which template's copy
32438
+ * of it is read. This function reads the current record for the reasons above
32439
+ * (it is the one the new copy was created under, and it matches the
32440
+ * `Snapshot` sibling and both CREATE-rollback arms), not because the A/B
32441
+ * settled that question.
32442
+ */
32443
+ function rollbackRetainsNewResource(record) {
32444
+ return record?.updateReplacePolicy === "Retain";
32445
+ }
32446
+ /**
32447
+ * The two sentences a `UpdateReplacePolicy: Retain` survivor needs: the `⚠`
32448
+ * terminal warning and the compact `reason` that rides on the durable
32449
+ * `ROLLBACK_RESOURCE_SUCCEEDED` event.
32450
+ *
32451
+ * ONE function because the two must not drift apart. Both replacement-rollback
32452
+ * retain arms produced these by hand, four near-identical copies, and the
32453
+ * failure mode a reviewer named is precise: the warn and the DURABLE record
32454
+ * disagreeing about which id survived. Deriving both from one set of inputs
32455
+ * makes that unrepresentable. The shapes stay deliberately different -- the
32456
+ * warn carries the cost/`cdkd destroy` guidance a human reads once, the reason
32457
+ * stays compact for a `--json` consumer -- so this is one input set, not one
32458
+ * string.
32459
+ *
32460
+ * `stateClause` is the only thing that differs between the two arms (the
32461
+ * readopt arm restores the old id; the create-first arm records a re-created
32462
+ * one), so it is a parameter rather than a branch in here.
32463
+ *
32464
+ * NOT used by the delete-failed survivor a few lines down: that one is an
32465
+ * orphan by OUTCOME rather than by policy, and says so.
32466
+ */
32467
+ function retainedSurvivorMessages(logicalId, resourceType, survivorPhysicalId, stateClause) {
32468
+ return {
32469
+ warn: ` ⚠ ${logicalId} (${resourceType}) has UpdateReplacePolicy: Retain — the replacement's new physical resource (${survivorPhysicalId}) is RETAINED by this rollback and is no longer tracked by cdkd: it keeps running and incurring cost, and \`cdkd destroy\` will not remove it. Delete it yourself once you no longer need it. ${stateClause}`,
32470
+ reason: `UpdateReplacePolicy: Retain kept the replacement's new ${resourceType} (${survivorPhysicalId}); it is live, still billing, and no longer tracked by cdkd. ${stateClause}`
32471
+ };
32472
+ }
32473
+ /**
31548
32474
  * `DeletionPolicy: Snapshot` on a rolled-back CREATE (issue #1358) — the
31549
32475
  * executor's copy of the deploy engine's `prepareFinalSnapshotForDelete`
31550
32476
  * mechanism matrix, run BEFORE the delete. Shared with the FAILED in-flight
@@ -31643,7 +32569,7 @@ function classifyRollbackOp(op, stateResources, orphanLogicalIds) {
31643
32569
  if (deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
31644
32570
  return "skip-mismatch";
31645
32571
  }
31646
- return op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
32572
+ return op.oldResourceRetained ?? op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
31647
32573
  }
31648
32574
  if (op.previousState && deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
31649
32575
  return "revert";
@@ -31683,12 +32609,16 @@ function planFailedOps(failedOps, stateResources) {
31683
32609
  */
31684
32610
  function planRollback(operations, stateResources, orphanLogicalIds = /* @__PURE__ */ new Set()) {
31685
32611
  const { createOps, otherOps } = partitionOps(operations);
31686
- return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) => ({
31687
- op,
31688
- action: classifyRollbackOp(op, stateResources, orphanLogicalIds),
31689
- replacement: isReplacementOp(op),
31690
- effectiveProvisionedBy: effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy)
31691
- }));
32612
+ return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) => {
32613
+ const action = classifyRollbackOp(op, stateResources, orphanLogicalIds);
32614
+ return {
32615
+ op,
32616
+ action,
32617
+ replacement: isReplacementOp(op),
32618
+ effectiveProvisionedBy: effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy),
32619
+ retainsNewResource: (action === "reverse-replacement" || action === "reverse-replacement-readopt") && rollbackRetainsNewResource(stateResources[op.logicalId])
32620
+ };
32621
+ });
31692
32622
  }
31693
32623
  function partitionOps(operations) {
31694
32624
  const createOps = [];
@@ -32046,11 +32976,32 @@ async function resolveLeafByRegion(leaf, propertyPath, logicalId, execCtx, resol
32046
32976
  * writer is one of only two that can honestly claim it.
32047
32977
  *
32048
32978
  * Using `STATE_SOURCED_READBACK_RULES` here reads plausible and is wrong in the
32049
- * quiet direction: it turns positional array descent off. Since issue #1915 a
32050
- * `Tags[]` / ECS `Environment[]` element is reached by KEYED descent either
32051
- * way, so the concrete loss is narrower than it was it is a list whose
32052
- * elements carry no `Name` / `Key` identity, which only positional descent can
32053
- * walk.
32979
+ * quiet direction: it turns BLIND positional array descent off. BLIND is
32980
+ * load-bearing, and an earlier revision of this paragraph omitted it — the
32981
+ * concrete loss is narrower than "positional descent is off" makes it sound,
32982
+ * by TWO mechanisms rather than one:
32983
+ *
32984
+ * - Since issue #1915 a `Tags[]` / ECS `Environment[]` element is reached by
32985
+ * the order-independent KEYED descent either way.
32986
+ * - Since issue #2012 an UNKEYED list is reached too, under corroboration.
32987
+ * That is not a general relaxation: swapping this constant in would satisfy
32988
+ * all three conjuncts of `isReadbackProjectedFromState`
32989
+ * (`trustAnyExpression && !descendArrays && sourceIsSameGeneration`), which
32990
+ * ARMS `refuseUncertifiedReadbackPositions`, and its unkeyed arm walks
32991
+ * element i against element i whenever `unkeyedArrayPairsByAnchors`
32992
+ * corroborates the alignment (index counts match; every position whose
32993
+ * SOURCE subtree carries no dynamic reference is deep-equal on both sides;
32994
+ * every reference-bearing element carries a distinguishing anchor of its own
32995
+ * or, being a bare reference leaf, leans on the array's literal frame; and
32996
+ * no two reference-bearing elements share an order-insensitive anchor
32997
+ * signature).
32998
+ *
32999
+ * So the residual loss is narrower again: an unkeyed list whose positions ALSO
33000
+ * fail to corroborate. The CONCLUSION is unchanged — `STATE_DERIVED_RULES` is
33001
+ * still right here, for the reason one paragraph up (the bag was produced by
33002
+ * resolving the source, so the two correspond positionally by construction and
33003
+ * need no corroboration to say so). What changes is only how much a reader
33004
+ * should think the alternative costs (issue #2691).
32054
33005
  *
32055
33006
  * No-op when the op resolved no secret.
32056
33007
  */
@@ -32260,7 +33211,8 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32260
33211
  return;
32261
33212
  case "orphan-flag":
32262
33213
  if (op.changeType === "CREATE") {
32263
- const orphanFlagProvisionedBy = effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy);
33214
+ const record = stateResources[op.logicalId];
33215
+ const orphanFlagProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
32264
33216
  createRollbackRoute = orphanFlagProvisionedBy;
32265
33217
  delete stateResources[op.logicalId];
32266
33218
  logger.info(` Rollback: Orphaning created resource ${op.logicalId} (--orphan)`);
@@ -32271,12 +33223,17 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32271
33223
  operation: "CREATE",
32272
33224
  logicalId: op.logicalId,
32273
33225
  resourceType: op.resourceType,
32274
- ...orphanFlagProvisionedBy && { provisionedBy: orphanFlagProvisionedBy }
33226
+ ...orphanFlagProvisionedBy && { provisionedBy: orphanFlagProvisionedBy },
33227
+ ...record?.physicalId && {
33228
+ physicalId: record.physicalId,
33229
+ reason: `--orphan left ${op.logicalId} (${op.resourceType}) in AWS as ${record.physicalId} and dropped it from state; it is live, still billing, and no longer tracked by cdkd.`
33230
+ }
32275
33231
  });
32276
33232
  } else logger.info(` Rollback: Leaving ${op.logicalId} at its new state (--orphan)`);
32277
33233
  return;
32278
33234
  case "orphan-retain": {
32279
- const orphanProvisionedBy = effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy);
33235
+ const record = stateResources[op.logicalId];
33236
+ const orphanProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
32280
33237
  createRollbackRoute = orphanProvisionedBy;
32281
33238
  delete stateResources[op.logicalId];
32282
33239
  logger.info(` Rollback: Leaving ${op.logicalId} (${op.resourceType}) in AWS (DeletionPolicy: Retain) — removed from state`);
@@ -32287,7 +33244,11 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32287
33244
  operation: "CREATE",
32288
33245
  logicalId: op.logicalId,
32289
33246
  resourceType: op.resourceType,
32290
- ...orphanProvisionedBy && { provisionedBy: orphanProvisionedBy }
33247
+ ...orphanProvisionedBy && { provisionedBy: orphanProvisionedBy },
33248
+ ...record?.physicalId && {
33249
+ physicalId: record.physicalId,
33250
+ reason: `DeletionPolicy: Retain left ${op.logicalId} (${op.resourceType}) in AWS as ${record.physicalId} and dropped it from state; it is live, still billing, and no longer tracked by cdkd.`
33251
+ }
32291
33252
  });
32292
33253
  return;
32293
33254
  }
@@ -32330,11 +33291,32 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32330
33291
  const current = stateResources[op.logicalId];
32331
33292
  const prev = op.previousState;
32332
33293
  logger.info(` Rollback: Reversing replacement of ${op.logicalId} (${op.resourceType}) — deleting the new resource and re-adopting the retained old one (${prev.physicalId})`);
32333
- const { provider: newDeleteProvider } = ctx.providerRegistry.getProviderFor({
32334
- resourceType: op.resourceType,
32335
- provisionedBy: current.provisionedBy ?? op.provisionedBy
32336
- });
32337
- {
33294
+ /**
33295
+ * Set when this arm ORPHANS the replacement's new copy. Read at the
33296
+ * `ROLLBACK_RESOURCE_SUCCEEDED` event below, which is the only channel
33297
+ * that OUTLIVES the terminal (security review of issue #2598): a
33298
+ * rollback runs during an already-failing deploy, often non-TTY with
33299
+ * the log truncated or discarded, so a `logger.warn` is the least
33300
+ * likely thing the user still has. Without this the survivor's id dies
33301
+ * with the terminal -- `cdkd events` shows a clean success and state
33302
+ * names only the OLD resource, while a live, billing, untracked copy
33303
+ * remains. `Retain` is precisely the marker users put on data-bearing
33304
+ * resources, so that is the worst population to lose the id for.
33305
+ *
33306
+ * Same shape as the `rollbackPartial` survivor record ~700 lines down
33307
+ * and as the deploy engine's `RESOURCE_SKIPPED` twin.
33308
+ */
33309
+ let survivorReason;
33310
+ if (rollbackRetainsNewResource(current)) {
33311
+ const survivorMessages = retainedSurvivorMessages(op.logicalId, op.resourceType, current.physicalId, `State is restored to the old resource (${prev.physicalId}).`);
33312
+ logger.warn(survivorMessages.warn);
33313
+ survivorReason = survivorMessages.reason;
33314
+ result.warnings++;
33315
+ } else {
33316
+ const { provider: newDeleteProvider } = ctx.providerRegistry.getProviderFor({
33317
+ resourceType: op.resourceType,
33318
+ provisionedBy: current.provisionedBy ?? op.provisionedBy
33319
+ });
32338
33320
  const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
32339
33321
  throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
32340
33322
  expectedRegion: ctx.region,
@@ -32344,13 +33326,19 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32344
33326
  stateResources[op.logicalId] = prev;
32345
33327
  logger.info(` Rollback: ${op.logicalId} restored to the retained old resource`);
32346
33328
  await afterOp?.(op.logicalId);
33329
+ const survivorProvisionedBy = current.provisionedBy;
32347
33330
  ctx.recordEvent?.({
32348
33331
  eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
32349
33332
  stackName,
32350
33333
  operation: "UPDATE",
32351
33334
  logicalId: op.logicalId,
32352
33335
  resourceType: op.resourceType,
32353
- ...op.provisionedBy && { provisionedBy: op.provisionedBy }
33336
+ ...op.provisionedBy && { provisionedBy: op.provisionedBy },
33337
+ ...survivorReason !== void 0 && {
33338
+ physicalId: current.physicalId,
33339
+ reason: maskSecretsInText(survivorReason, secrets),
33340
+ ...survivorProvisionedBy && { provisionedBy: survivorProvisionedBy }
33341
+ }
32354
33342
  });
32355
33343
  return;
32356
33344
  }
@@ -32366,10 +33354,10 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32366
33354
  resourceType: op.resourceType,
32367
33355
  provisionedBy: prev.provisionedBy
32368
33356
  });
32369
- const { provider: newDeleteProvider } = ctx.providerRegistry.getProviderFor({
33357
+ const resolveNewDeleteProvider = () => ctx.providerRegistry.getProviderFor({
32370
33358
  resourceType: op.resourceType,
32371
33359
  provisionedBy: current.provisionedBy ?? op.provisionedBy
32372
- });
33360
+ }).provider;
32373
33361
  let deletedNewFirst = false;
32374
33362
  let createResult;
32375
33363
  try {
@@ -32378,11 +33366,13 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32378
33366
  interruptedMessage: "Rollback interrupted while waiting out the name cooldown"
32379
33367
  });
32380
33368
  } catch (createError) {
32381
- if (!isNameCollisionError(createError instanceof Error ? createError.message : String(createError))) throw createError;
33369
+ const msg = createError instanceof Error ? createError.message : String(createError);
33370
+ if (!isNameCollisionError(msg)) throw createError;
33371
+ if (rollbackRetainsNewResource(current)) throw markNonRetryable(new CdkdError(maskSecretsInText(`Cannot reverse the replacement of ${op.logicalId} (${op.resourceType}): the re-create of the old resource (${prev.physicalId}) collided with the name still held by the new one (${current.physicalId}), and UpdateReplacePolicy: Retain pins that new resource in place, so cdkd will not delete it to free the name. Delete the new resource yourself, or remove UpdateReplacePolicy: Retain, then re-run \`cdkd rollback\` — the journal is kept, so the revert resumes from here. To leave THIS resource alone and let the rest of the rollback proceed, re-run with \`cdkd rollback --orphan ${op.logicalId}\`: one op failure stops the segment loop, so a single pinned resource otherwise halts every OLDER segment too. Underlying collision: ${msg}`, secrets), "NAMED_REPLACEMENT_COLLISION", maskSecretsInError(createError instanceof Error ? createError : void 0, secrets)));
32382
33372
  logger.info(` Rollback: re-create collided with the new resource's name — deleting the new resource (${current.physicalId}) first...`);
32383
33373
  {
32384
33374
  const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
32385
- throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
33375
+ throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
32386
33376
  expectedRegion: ctx.region,
32387
33377
  ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
32388
33378
  }), op.logicalId, current.physicalId, "while clearing the new resource so the old one could be re-created");
@@ -32396,7 +33386,7 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32396
33386
  interruptedMessage: "Rollback interrupted while waiting for the old name to release"
32397
33387
  });
32398
33388
  } catch (recreateError) {
32399
- throw new Error(maskSecretsInText(`Failed to re-create the old ${op.logicalId} after the new resource (${current.physicalId}) was already deleted: ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. The resource is now absent — fix forward with 'cdkd deploy'.`, secrets));
33389
+ throw new Error(maskSecretsInText(`Failed to re-create the old ${op.logicalId} after the new resource (${current.physicalId}) was already deleted: ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. The resource is now absent — fix forward with 'cdkd deploy'.`, secrets), { cause: maskSecretsInError(recreateError instanceof Error ? recreateError : void 0, secrets) });
32400
33390
  }
32401
33391
  }
32402
33392
  const adoptedLiveNewResource = !deletedNewFirst && createResult.physicalId === current.physicalId;
@@ -32412,24 +33402,37 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32412
33402
  properties: recordedPropertiesAfterReplayCreate(prevRecord, createResult)
32413
33403
  }, secrets, prevRecord.properties);
32414
33404
  await afterOp?.(op.logicalId);
32415
- if (!deletedNewFirst && !adoptedLiveNewResource) try {
33405
+ let survivorReason;
33406
+ if (!deletedNewFirst && !adoptedLiveNewResource && rollbackRetainsNewResource(current)) {
33407
+ const survivorMessages = retainedSurvivorMessages(op.logicalId, op.resourceType, current.physicalId, `State records the re-created old resource (${stateResources[op.logicalId]?.physicalId ?? prev.physicalId}).`);
33408
+ logger.warn(survivorMessages.warn);
33409
+ survivorReason = survivorMessages.reason;
33410
+ result.warnings++;
33411
+ } else if (!deletedNewFirst && !adoptedLiveNewResource) try {
32416
33412
  const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
32417
- throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
33413
+ throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
32418
33414
  expectedRegion: ctx.region,
32419
33415
  ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
32420
33416
  }), op.logicalId, current.physicalId, "while deleting the new resource after re-creating the old one");
32421
33417
  } catch (deleteError) {
32422
33418
  logger.warn(maskSecretsInText(` Rollback: old ${op.logicalId} re-created, but deleting the new resource (${current.physicalId}) failed: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}. Delete it manually — it is no longer tracked in state.`, secrets));
33419
+ survivorReason = `The replacement's new ${op.resourceType} (${current.physicalId}) could not be deleted after the old resource was re-created: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}. It is live, still billing, and no longer tracked by cdkd — delete it yourself.`;
32423
33420
  result.warnings++;
32424
33421
  }
32425
33422
  logger.info(adoptedLiveNewResource ? ` Rollback: ${op.logicalId} adopted the live resource (${createResult.physicalId}) — replacement NOT fully reversed (name-idempotent Create API)` : ` Rollback: ${op.logicalId} replacement reversed (old resource re-created as ${createResult.physicalId})`);
33423
+ const survivorProvisionedBy = current.provisionedBy;
32426
33424
  ctx.recordEvent?.({
32427
33425
  eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
32428
33426
  stackName,
32429
33427
  operation: "UPDATE",
32430
33428
  logicalId: op.logicalId,
32431
33429
  resourceType: op.resourceType,
32432
- ...op.provisionedBy && { provisionedBy: op.provisionedBy }
33430
+ ...op.provisionedBy && { provisionedBy: op.provisionedBy },
33431
+ ...survivorReason !== void 0 && {
33432
+ physicalId: current.physicalId,
33433
+ reason: maskSecretsInText(survivorReason, secrets),
33434
+ ...survivorProvisionedBy && { provisionedBy: survivorProvisionedBy }
33435
+ }
32433
33436
  });
32434
33437
  return;
32435
33438
  }
@@ -33118,7 +34121,10 @@ var DeploymentEventsReader = class {
33118
34121
  * window and rewrites (or removes) `index.json` to match.
33119
34122
  *
33120
34123
  * Retention semantics (see {@link DeploymentEventsPruneOptions}):
33121
- * - `all` — delete every run + the index (full purge).
34124
+ * - `all` — delete every run + the index. Clears the LISTING only:
34125
+ * this deletes by key with no `VersionId`, so on a
34126
+ * versioned bucket every earlier version survives
34127
+ * (issue #2624).
33122
34128
  * - `keep N` — retain the newest N runs, delete the rest.
33123
34129
  * - `olderThanMs`— delete runs whose run-id timestamp is older than the
33124
34130
  * cutoff; a run id with no parseable timestamp is kept.
@@ -33533,6 +34539,39 @@ var DeployEngine = class {
33533
34539
  */
33534
34540
  attemptedResolvedProps = /* @__PURE__ */ new Map();
33535
34541
  /**
34542
+ * Logical ids whose replacement this deploy DELIBERATELY left the old
34543
+ * physical resource alive for — `UpdateReplacePolicy: Retain` (issue
34544
+ * [#2603](https://github.com/go-to-k/cdkd/issues/2603)).
34545
+ *
34546
+ * Written by every engine path that skips the post-replacement delete, read
34547
+ * once at the `completedOperations.push` site to stamp
34548
+ * {@link CompletedOperation.oldResourceRetained}. It exists because the
34549
+ * rollback classifier used to re-derive the verdict from
34550
+ * `previousState.updateReplacePolicy` — a DIFFERENT source than the
34551
+ * template read the engine decides from — so the two disagreed on exactly
34552
+ * the deploy that changes the attribute, in both directions:
34553
+ *
34554
+ * - ADDING `Retain`: the deploy orphans the old resource while the
34555
+ * previous state record carries no policy, so the rollback re-CREATED a
34556
+ * resource that is still alive (a duplicate for an auto-named type, an
34557
+ * `AlreadyExists` failure for a user-named one).
34558
+ * - DROPPING `Retain`: state still carries the stale `Retain`, the
34559
+ * template omits it so the deploy correctly DELETES the old resource,
34560
+ * and the rollback then re-adopted a physical id that no longer exists,
34561
+ * leaving state naming a deleted resource.
34562
+ *
34563
+ * Records only the DELIBERATE retention. A best-effort cleanup delete that
34564
+ * FAILED, was SKIPPED, or was blocked by a failed final snapshot also leaves
34565
+ * the old resource alive, but the deploy does not KNOW it survived — those
34566
+ * stay `false` and keep today's `reverse-replacement` behaviour rather than
34567
+ * having the rollback re-adopt an id it cannot vouch for (issue
34568
+ * [#2631](https://github.com/go-to-k/cdkd/issues/2631)).
34569
+ *
34570
+ * Cleared per `deploy()` alongside the other per-run maps: a `false` here
34571
+ * must mean "this deploy deleted it", never "a previous run said so".
34572
+ */
34573
+ retainedOldOnReplacement = /* @__PURE__ */ new Set();
34574
+ /**
33536
34575
  * Target region for this stack. Required — load-bearing for the
33537
34576
  * region-prefixed S3 state key and recorded in state.json for
33538
34577
  * cross-region destroy.
@@ -33571,6 +34610,7 @@ var DeployEngine = class {
33571
34610
  this.outputSecrets = /* @__PURE__ */ new Map();
33572
34611
  this.outputsTemplateSource = {};
33573
34612
  this.outputsSourceUsable = true;
34613
+ this.retainedOldOnReplacement = /* @__PURE__ */ new Set();
33574
34614
  this.resolver.resetPhysicalIdFallbackCount();
33575
34615
  return withStackName(stackName, () => this.doDeploy(stackName, template));
33576
34616
  }
@@ -33992,7 +35032,10 @@ var DeployEngine = class {
33992
35032
  for (const { logicalId, resource } of candidates) {
33993
35033
  let provider;
33994
35034
  try {
33995
- provider = this.providerRegistry.getProvider(resource.resourceType);
35035
+ provider = this.providerRegistry.getProviderFor({
35036
+ resourceType: resource.resourceType,
35037
+ provisionedBy: resource.provisionedBy
35038
+ }).provider;
33996
35039
  } catch {
33997
35040
  continue;
33998
35041
  }
@@ -34084,6 +35127,11 @@ var DeployEngine = class {
34084
35127
  diffResolverContext.skipDynamicReferences = true;
34085
35128
  const diffResolveFn = (value) => this.resolver.resolve(value, diffResolverContext);
34086
35129
  const changes = await this.diffCalculator.calculateDiff(currentState, effectiveTemplate, diffResolveFn, makeCanonicalizePropertiesFn(this.providerRegistry));
35130
+ const nestedStackTypeChanges = findNestedStackTypeChanges({
35131
+ changes,
35132
+ stateResources: currentState.resources
35133
+ });
35134
+ if (nestedStackTypeChanges.length > 0) throw markNonRetryable(new CdkdError(renderNestedStackTypeChangeRefusal(nestedStackTypeChanges, stackName), "TYPE_CHANGE_NESTED_STACK"));
34087
35135
  if (!this.diffCalculator.hasChanges(changes)) {
34088
35136
  this.logger.info("No changes detected. Stack is up to date.");
34089
35137
  let persistedOutputs = currentState.outputs ?? {};
@@ -34293,7 +35341,8 @@ var DeployEngine = class {
34293
35341
  provisionedBy: newResources[logicalId]?.provisionedBy ?? previousState?.provisionedBy,
34294
35342
  previousState,
34295
35343
  physicalId: newResources[logicalId]?.physicalId,
34296
- properties: newResources[logicalId]?.properties
35344
+ properties: newResources[logicalId]?.properties,
35345
+ ...change.changeType === "UPDATE" && { oldResourceRetained: this.retainedOldOnReplacement.has(logicalId) }
34297
35346
  });
34298
35347
  saveStateAfterResource(logicalId);
34299
35348
  }, () => this.interrupted);
@@ -34820,7 +35869,7 @@ var DeployEngine = class {
34820
35869
  isRetryable: isRecreateRetryableError
34821
35870
  });
34822
35871
  } catch (recreateError) {
34823
- throw new Error(maskSecretsInText(`Failed to re-create ${logicalId} after the --replace delete-first fallback already deleted the old resource (${currentResource.physicalId}): ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. Re-run the deploy to create it fresh.`, secrets));
35872
+ throw new Error(maskSecretsInText(`Failed to re-create ${logicalId} after the --replace delete-first fallback already deleted the old resource (${currentResource.physicalId}): ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. Re-run the deploy to create it fresh.`, secrets), { cause: recreateError instanceof Error ? recreateError : void 0 });
34824
35873
  }
34825
35874
  }
34826
35875
  /**
@@ -34917,15 +35966,16 @@ var DeployEngine = class {
34917
35966
  break;
34918
35967
  }
34919
35968
  const propertyDrivenReplacement = change.propertyChanges?.some((pc) => pc.requiresReplacement);
34920
- const recreateViaCcApi = this.options.recreateViaCcApiTargets?.has(logicalId) ?? false;
34921
- const recreateViaSdkProvider = this.options.recreateViaSdkProviderTargets?.has(logicalId) ?? false;
35969
+ const recreateTargets = this.options.recreateTargets?.stackName === stackName ? this.options.recreateTargets : void 0;
35970
+ const recreateViaCcApi = recreateTargets?.viaCcApi.has(logicalId) ?? false;
35971
+ const recreateViaSdkProvider = recreateTargets?.viaSdkProvider.has(logicalId) ?? false;
34922
35972
  const recreateFlagged = recreateViaCcApi || recreateViaSdkProvider;
34923
35973
  const needsReplacement = propertyDrivenReplacement || recreateFlagged;
34924
35974
  const dependencies = this.extractAllDependencies(template, logicalId);
34925
35975
  const updateReplacePolicy = template?.Resources?.[logicalId]?.UpdateReplacePolicy;
34926
35976
  if (needsReplacement) {
34927
35977
  if (propertyDrivenReplacement && !recreateFlagged && updateReplacePolicy !== "Retain") {
34928
- const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps);
35978
+ const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
34929
35979
  if (statefulReason && this.options.forceStatefulRecreation !== true) {
34930
35980
  const immutableProps = change.propertyChanges?.filter((pc) => pc.requiresReplacement).map((pc) => pc.path).join(", ");
34931
35981
  throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement (immutable property changed: ${immutableProps}) but it is a stateful resource — ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the immutable-property change.`, "STATEFUL_REPLACE_BLOCKED"));
@@ -34951,8 +36001,10 @@ var DeployEngine = class {
34951
36001
  let createResult;
34952
36002
  if (recreateFlagged) {
34953
36003
  const recreateFlagName = recreateViaCcApi ? "--recreate-via-cc-api" : "--recreate-via-sdk-provider";
34954
- if (updateReplacePolicy === "Retain") this.logger.warn(` ⚠ ${logicalId} has UpdateReplacePolicy: Retain — ${recreateFlagName} will leak the old physical resource (${currentResource.physicalId}). The new resource shares the same name where applicable; if the type has user-supplied names (e.g. functionName, bucketName), the create will deterministically collide with the retained orphan.`);
34955
- else {
36004
+ if (updateReplacePolicy === "Retain") {
36005
+ this.retainedOldOnReplacement.add(logicalId);
36006
+ this.logger.warn(` ⚠ ${logicalId} has UpdateReplacePolicy: Retain — ${recreateFlagName} will leak the old physical resource (${currentResource.physicalId}). The new resource shares the same name where applicable; if the type has user-supplied names (e.g. functionName, bucketName), the create will deterministically collide with the retained orphan.`);
36007
+ } else {
34956
36008
  this.logger.info(` Destroying old ${logicalId} (${currentResource.physicalId}) before recreate...`);
34957
36009
  const recreateFinalSnapshotId = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, updateReplacePolicy);
34958
36010
  let recreateDeleteResult;
@@ -35003,8 +36055,10 @@ var DeployEngine = class {
35003
36055
  deletedOldFirst = true;
35004
36056
  createResult = await this.replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps, updateSecrets, updateReplacePolicy);
35005
36057
  }
35006
- if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain") this.logger.info(` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`);
35007
- else {
36058
+ if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain") {
36059
+ this.retainedOldOnReplacement.add(logicalId);
36060
+ this.logger.info(` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`);
36061
+ } else {
35008
36062
  this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
35009
36063
  let cleanupFinalSnapshotId;
35010
36064
  let snapshotBlockedDelete = false;
@@ -35061,6 +36115,7 @@ var DeployEngine = class {
35061
36115
  const updateProps = updateDecision.provisionedBy === "cc-api" ? this.preparePropertiesForCcApi(resourceType, resolvedProps, logicalId) : resolvedProps;
35062
36116
  let result;
35063
36117
  let resultProvisionedBy = updateDecision.provisionedBy;
36118
+ let captureProvider = updateProvider;
35064
36119
  try {
35065
36120
  result = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => updateProvider.update(logicalId, currentResource.physicalId, resourceType, updateProps, currentProps, {
35066
36121
  maskSecrets: createSecretMasker(updateSecrets),
@@ -35071,7 +36126,7 @@ var DeployEngine = class {
35071
36126
  const replaceOptIn = updateError instanceof ResourceUpdateNotSupportedError && this.options.replace === true;
35072
36127
  if (ccUnsupported || replaceOptIn) {
35073
36128
  const retainOldOnReplace = updateReplacePolicy === "Retain";
35074
- const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps);
36129
+ const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
35075
36130
  if (statefulReason && this.options.forceStatefulRecreation !== true) throw markNonRetryable(new CdkdError(replaceOptIn ? `--replace would DELETE + CREATE the stateful resource ${logicalId} (${resourceType}) — ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the immutable-property change.` : `${logicalId} (${resourceType}) cannot be updated in place by the provisioning layer it routes through, so applying this change would DELETE + CREATE it — but it is a stateful resource: ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the update.`, "STATEFUL_REPLACE_BLOCKED", updateError instanceof Error ? updateError : void 0));
35076
36131
  this.logger.info(retainOldOnReplace ? `UPDATE not supported for ${logicalId} (${resourceType}), replacing (CREATE only — UpdateReplacePolicy: Retain keeps the old resource)` : `UPDATE not supported for ${logicalId} (${resourceType}), replacing (DELETE → CREATE)`);
35077
36132
  if (!retainOldOnReplace) {
@@ -35102,7 +36157,7 @@ var DeployEngine = class {
35102
36157
  try {
35103
36158
  createResult = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => replProvider.create(logicalId, resourceType, replProps, { maskSecrets: createSecretMasker(updateSecrets) })), logicalId, void 0, void 0, replProvider);
35104
36159
  } catch (createError) {
35105
- if (!retainOldOnReplace) throw createError;
36160
+ if (!retainOldOnReplace) throw new Error(maskSecretsInText(`Failed to create ${logicalId} after the UPDATE-not-supported replacement: the old resource (${currentResource.physicalId}) is now gone. Cause: ${createError instanceof Error ? createError.message : String(createError)}. Re-run the deploy to create it fresh.`, updateSecrets), { cause: createError instanceof Error ? createError : void 0 });
35106
36161
  if (!isNameCollisionError(createError instanceof Error ? createError.message : String(createError))) throw createError;
35107
36162
  const nameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
35108
36163
  throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement because the provisioning layer cannot update it in place — but its physical name is still held by the existing resource AND UpdateReplacePolicy: Retain pins that resource in place. ${nameOrigin.descriptor}. ${nameOrigin.remedy} — with Retain, the old resource keeps the name, so a same-name replacement can never proceed. Removing UpdateReplacePolicy: Retain lets cdkd delete the old resource first, which destroys it and any data it holds.`, "NAMED_REPLACEMENT_COLLISION", createError instanceof Error ? createError : void 0));
@@ -35112,6 +36167,7 @@ var DeployEngine = class {
35112
36167
  const idempotentNameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
35113
36168
  throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement, but its Create API is name-idempotent: the create returned the existing resource (${currentResource.physicalId}) instead of creating a new one, and UpdateReplacePolicy: Retain pins that resource in place, so the new properties were not applied. ${idempotentNameOrigin.descriptor}. ${idempotentNameOrigin.remedy} — with Retain, the old resource keeps the name, so a same-name replacement can never proceed.`, "NAMED_REPLACEMENT_IDEMPOTENT_CREATE"));
35114
36169
  }
36170
+ this.retainedOldOnReplacement.add(logicalId);
35115
36171
  this.logger.warn(` ⚠ ${logicalId} has UpdateReplacePolicy: Retain — the old physical resource (${currentResource.physicalId}) is RETAINED and is no longer tracked by cdkd: it keeps running and incurring cost, and \`cdkd destroy\` will not remove it. Delete it yourself once you no longer need its data.`);
35116
36172
  retainedSurvivorReason = `UpdateReplacePolicy: Retain kept the old ${resourceType} (${currentResource.physicalId}), now untracked by cdkd`;
35117
36173
  }
@@ -35129,6 +36185,7 @@ var DeployEngine = class {
35129
36185
  if (createResult.effectiveProperties) replacementResult.effectiveProperties = createResult.effectiveProperties;
35130
36186
  result = replacementResult;
35131
36187
  resultProvisionedBy = replDecision.provisionedBy;
36188
+ captureProvider = replProvider;
35132
36189
  } else throw updateError;
35133
36190
  }
35134
36191
  if (result.wasReplaced) this.logger.info(`Resource ${logicalId} was replaced: ${currentResource.physicalId} -> ${result.physicalId}`);
@@ -35148,7 +36205,7 @@ var DeployEngine = class {
35148
36205
  provisionedBy: resultProvisionedBy
35149
36206
  };
35150
36207
  const updateCaptureSiblings = await this.buildObservedCaptureSiblings(resourceType, logicalId, result.physicalId, template, stateResources, stackName, parameterValues, conditions);
35151
- this.kickOffObservedCapture(updateProvider, logicalId, result.physicalId, resourceType, resolvedProps, updateCaptureSiblings);
36208
+ this.kickOffObservedCapture(captureProvider, logicalId, result.physicalId, resourceType, resolvedProps, updateCaptureSiblings);
35152
36209
  const updatePartial = updatePartialReason(result);
35153
36210
  if (counts) if (updatePartial !== void 0) counts.updatePartial++;
35154
36211
  else counts.updated++;
@@ -35601,5 +36658,5 @@ var DeployEngine = class {
35601
36658
  };
35602
36659
 
35603
36660
  //#endregion
35604
- export { beginCommandInterruptScope as $, synthesisStatusMessage as $n, withErrorHandling as $r, maskSecretsInError as $t, formatResourceLine as A, ensureAssetStorage as An, AssetError as Ar, requireConfigString as At, isExportAliasCollision as B, describeDockerCapturedOutput as Bn, LockError as Br, INTRINSIC_KEYS as Bt, unsupportedFinalSnapshotError as C, loadPublishableAssetManifest as Cn, processStackMessages as Cr, coerceCfnBoolean as Ct, isStatefulRecreateTargetForReplace as D, AssetModeResolver as Dn, getAwsClients as Dr, replayWarn as Dt, MULTI_REGION_RECREATE_BLOCKED_TYPES as E, stripControlChars as En, AwsClients as Er, readConfigString as Et, red as F, validateAssetBucketName as Fn, DeployCancelledError as Fr, s3BucketDualStackDomainName as Ft, collectInlinePolicyNamesManagedBySiblings as G, getDockerCmd as Gn, ResourceUpdateNotSupportedError as Gr, STATE_SOURCED_CROSS_GENERATION_RULES as Gt, stateKeySecretExposure as H, describeDockerFailure as Hn, PartialFailureError as Hr, withRetry as Ht, yellow as I, validateContainerRepoName as In, DynamicReferenceRegionAmbiguousError as Ir, s3BucketRegionalDomainName as It, findActionableSilentDrops as J, runDockerForeground as Jn, StateError as Jr, carriesSecretMask as Jt, clearOnUpdateRemoval as K, partitionSensitiveEnv as Kn, StackHasActiveImportsError as Kr, STATE_SOURCED_READBACK_RULES as Kt, collectDeclaredOutputNames as L, buildDenyExternalAccessPolicy as Ln, IntrinsicResolutionRefusalError as Lr, s3BucketWebsiteUrl as Lt, cyan as M, isCrossRegionRedirect as Mn, ConfigError as Mr, producerRegionsFromState as Mt, gray as N, parseBootstrapMarker as Nn, CrossAccountSecretRefusalError as Nr, s3BucketArn as Nt, isStatefulRecreateTargetSync as O, BOOTSTRAP_MARKER_PREFIX as On, resetAwsClients as Or, requireConfigArray as Ot, green as P, readBootstrapMarkerBody as Pn, DependencyError as Pr, s3BucketDomainName as Pt, maskerOrIdentity as Q, Synthesizer as Qn, normalizeAwsError as Qr, isSingleDynamicReferenceToken as Qt, collectPublishedOutputNames as R, describeAwsFailure as Rn, LocalInvokeBuildError as Rr, applyRoleArnIfSet as Rt, refusesFinalSnapshot as S, createAssetRedirectResolver as Sn, AssemblyReader as Sr, assertRegionMatch as St, extractDeploymentEventError as T, escapeRegExp$1 as Tn, resolveBucketRegion as Tr, configStringRefusal as Tt, getCurrentResourceSecrets as U, dockerSpawnEnvWithSensitive as Un, ProvisioningError as Ur, DagBuilder as Ut, secretBearingStateKeyWarning as V, describeDockerExecFailure as Vn, NestedStackChildDirectDestroyError as Vr, describeTypeWithThrottleRetry as Vt, IAMRoleProvider as W, formatDockerLoginError as Wn, ResourceTimeoutError as Wr, TemplateParser as Wt, createMaskedRetryLogger as X, AssetManifestLoader as Xn, formatError as Xr, dynamicReferenceTokens as Xt, findSilentDropProperties as Y, runDockerStreaming as Yn, SynthesisError as Yr, createSecretMasker as Yt, maskDeep as Z, getDockerImageBySourceHash as Zn, isCdkdError as Zr, errorCauseChain as Zt, PRE_DELETE_SNAPSHOT_TYPES as _, shouldRetainResource as _n, displaySafe as _r, parameterTypeMayLoseSecretIdentity as _t, DeploymentEventsStore as a, markRedactedCause as ai, LockManager as an, resolveSkipPrefix as ar, slowCcOperationTimeoutMs as at, createPreDeleteFinalSnapshot as b, WorkGraph as bn, canonicalizeRegion as br, normalizeAwsTagsToCfn as bt, replayFailedOperations as c, UNRENDERABLE as cn, resolveUseCdkBootstrapAssets as cr, deleteSkipReason as ct, updatePartialReason as d, forceQuitRecoveryClause as dn, CFN_TEMPLATE_BODY_LIMIT as dr, IntrinsicFunctionResolver as dt, isMarkedNonRetryable as ei, maskSecretsInText as en, getDefaultStateBucketName as er, endCommandInterruptScope as et, withResourceDeadline as f, CUSTOM_RESOURCE_RESPONSE_PREFIX as fn, CFN_TEMPLATE_URL_LIMIT as fr, carriesDynamicReference as ft, ATOMIC_FINAL_SNAPSHOT_TYPES as g, importableOutputs as gn, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as gr, isUnboundTemplateParameter as gt, computeImplicitDeleteEdges as h, importableOutputKeys as hn, uploadCfnTemplate as hr, getAccountInfo as ht, DeploymentEventsReader as i, markNonRetryable as ii, scrubResourceRecord as in, resolveCaptureObservedState as ir, CloudControlProvider as it, bold as j, getBootstrapMarkerKey as jn, CdkdError as jr, classifyReplaySecretRegion as jt, renderStatefulReason as k, assertAssetBucketRegion as kn, setAwsClients as kr, requireConfigObject as kt, replayRollback as l, buildForceUnlockCommand as ln, stateBucketExistenceConfirmed as lr, disableInstanceApiTermination as lt, IMPLICIT_DELETE_DEPENDENCIES as m, exportNamesCarriedFrom as mn, findLargeInlineResources as mr, coerceParameterTypedValue as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, isThrottlingError as ni, recoverMaskedOutput as nn, resolveApp as nr, isInterruptedWaitError as nt, planFailedOps as o, retryClassificationText as oi, S3StateBackend as on, resolveStateBucketWithDefault as or, UNSPECIFIED_SKIP_REASON as ot, maskingRetryLogger as p, DEFAULT_STATE_PREFIX as pn, MIGRATE_TMP_PREFIX as pr, cfnRefValueFromPhysicalId as pt, ProviderRegistry as q, redactDockerArgvValues as qn, StackTerminationProtectionError as qr, TEMPLATE_SOURCED_RULES as qt, DeployEngine as r, isTransientServerError as ri, redactSecretsForState as rn, resolveAutoAssetStorage as rr, startInterruptWatch as rt, planRollback as s, __exportAll as si, rebuildClientForBucketRegion as sn, resolveStateBucketWithDefaultAndSource as sr, deleteIndeterminateGuards as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, isRetryableTransientError as ti, recordMaskOnlyValue as tn, getLegacyStateBucketName as tr, interruptWatchListenerCount as tt, updatePartialMessage as u, buildLockContentionMessage as un, warnDeprecatedNoPrefixCliFlag as ur, isTerminationProtectionPropagationError as ut, buildFinalSnapshotIdentifier as v, AssetPublisher as vn, expectedOwnerParam as vr, refStateLookupFromResource as vt, makeCanonicalizePropertiesFn as w, rewriteTemplateAssetReferences as wn, clearBucketRegionCache as wr, configBooleanRefusal as wt, isFinalSnapshotError as x, buildAssetRedirectMap as xn, derivePartitionAndUrlSuffix as xr, resolveExplicitPhysicalId as xt, ccRoutedFinalSnapshotError as y, stringifyValue as yn, PARTITION_TABLE as yr, WAFv2WebACLProvider as yt, exportAliasCollisionScrubWarning as z, buildDockerImage as zn, LocalStartServiceError as zr, DiffCalculator as zt };
35605
- //# sourceMappingURL=deploy-engine--rkIGhow.js.map
36661
+ export { findSilentDropProperties as $, escapeRegExp$1 as $n, StateError as $r, createSecretMasker as $t, WARM_THROUGHPUT_MEMBERS as A, AssetModeResolver as An, resolveBucketRegion as Ar, readConfigString as At, yellow as B, buildDenyExternalAccessPolicy as Bn, DeployCancelledError as Br, s3BucketRegionalDomainName as Bt, unsupportedFinalSnapshotError as C, AssetPublisher as Cn, expectedOwnerParam as Cr, WAFv2WebACLProvider as Ct, isStatefulRecreateTargetForReplace as D, createAssetRedirectResolver as Dn, AssemblyReader as Dr, coerceCfnBoolean as Dt, MULTI_REGION_RECREATE_BLOCKED_TYPES as E, buildAssetRedirectMap as En, derivePartitionAndUrlSuffix as Er, assertRegionMatch as Et, bold as F, isCrossRegionRedirect as Fn, AssetError as Fr, classifyReplaySecretRegion as Ft, secretBearingStateKeyWarning as G, describeDockerFailure as Gn, LockError as Gr, describeTypeWithThrottleRetry as Gt, collectPublishedOutputNames as H, buildDockerImage as Hn, IntrinsicResolutionRefusalError as Hr, applyRoleArnIfSet as Ht, cyan as I, parseBootstrapMarker as In, CdkdError as Ir, producerRegionsFromState as It, IAMRoleProvider as J, getDockerCmd as Jn, ProvisioningError as Jr, TemplateParser as Jt, stateKeySecretExposure as K, dockerSpawnEnvWithSensitive as Kn, NestedStackChildDirectDestroyError as Kr, withRetry as Kt, gray as L, readBootstrapMarkerBody as Ln, ConfigError as Lr, s3BucketArn as Lt, isWarmThroughputDecrease as M, assertAssetBucketRegion as Mn, getAwsClients as Mr, requireConfigArray as Mt, toFiniteNumber as N, ensureAssetStorage as Nn, resetAwsClients as Nr, requireConfigObject as Nt, isStatefulRecreateTargetSync as O, loadPublishableAssetManifest as On, processStackMessages as Or, configBooleanRefusal as Ot, formatResourceLine as P, getBootstrapMarkerKey as Pn, setAwsClients as Pr, requireConfigString as Pt, findActionableSilentDrops as Q, runDockerStreaming as Qn, StackTerminationProtectionError as Qr, carriesSecretMask as Qt, green as R, validateAssetBucketName as Rn, CrossAccountSecretRefusalError as Rr, s3BucketDomainName as Rt, refusesFinalSnapshot as S, shouldRetainResource as Sn, displaySafe as Sr, refStateLookupFromResource as St, extractDeploymentEventError as T, WorkGraph as Tn, canonicalizeRegion as Tr, resolveExplicitPhysicalId as Tt, exportAliasCollisionScrubWarning as U, describeDockerCapturedOutput as Un, LocalInvokeBuildError as Ur, DiffCalculator as Ut, collectDeclaredOutputNames as V, describeAwsFailure as Vn, DynamicReferenceRegionAmbiguousError as Vr, s3BucketWebsiteUrl as Vt, isExportAliasCollision as W, describeDockerExecFailure as Wn, LocalStartServiceError as Wr, INTRINSIC_KEYS as Wt, clearOnUpdateRemoval as X, redactDockerArgvValues as Xn, ResourceUpdateNotSupportedError as Xr, STATE_SOURCED_READBACK_RULES as Xt, collectInlinePolicyNamesManagedBySiblings as Y, partitionSensitiveEnv as Yn, ResourceTimeoutError as Yr, STATE_SOURCED_CROSS_GENERATION_RULES as Yt, ProviderRegistry as Z, runDockerForeground as Zn, StackHasActiveImportsError as Zr, TEMPLATE_SOURCED_RULES as Zt, PRE_DELETE_SNAPSHOT_TYPES as _, CUSTOM_RESOURCE_RESPONSE_PREFIX as _n, CFN_TEMPLATE_URL_LIMIT as _r, cfnRefValueFromPhysicalId as _t, DeploymentEventsStore as a, isMarkedNonRetryable as ai, recordMaskOnlyValue as an, getDefaultStateBucketName as ar, interruptWatchListenerCount as at, createPreDeleteFinalSnapshot as b, importableOutputKeys as bn, uploadCfnTemplate as br, isUnboundTemplateParameter as bt, replayFailedOperations as c, isTransientServerError as ci, scrubResourceRecord as cn, resolveAutoAssetStorage as cr, CloudControlProvider as ct, updatePartialReason as d, retryClassificationText as di, rebuildClientForBucketRegion as dn, resolveStateBucketWithDefault as dr, deleteIndeterminateGuards as dt, SynthesisError as ei, dynamicReferenceTokens as en, stripControlChars as er, createMaskedRetryLogger as et, withResourceDeadline as f, __exportAll as fi, UNRENDERABLE as fn, resolveStateBucketWithDefaultAndSource as fr, deleteSkipReason as ft, ATOMIC_FINAL_SNAPSHOT_TYPES as g, shellQuote as gn, CFN_TEMPLATE_BODY_LIMIT as gr, carriesDynamicReference as gt, computeImplicitDeleteEdges as h, forceQuitRecoveryClause as hn, warnDeprecatedNoPrefixCliFlag as hr, IntrinsicFunctionResolver as ht, DeploymentEventsReader as i, withErrorHandling as ii, maskSecretsInText as in, synthesisStatusMessage as ir, endCommandInterruptScope as it, coerceWarmThroughput as j, BOOTSTRAP_MARKER_PREFIX as jn, AwsClients as jr, replayWarn as jt, renderStatefulReason as k, rewriteTemplateAssetReferences as kn, clearBucketRegionCache as kr, configStringRefusal as kt, replayRollback as l, markNonRetryable as li, LockManager as ln, resolveCaptureObservedState as lr, slowCcOperationTimeoutMs as lt, IMPLICIT_DELETE_DEPENDENCIES as m, buildLockContentionMessage as mn, stateBucketExistenceConfirmed as mr, isTerminationProtectionPropagationError as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, isCdkdError as ni, isSingleDynamicReferenceToken as nn, getDockerImageBySourceHash as nr, maskerOrIdentity as nt, planFailedOps as o, isRetryableTransientError as oi, recoverMaskedOutput as on, getLegacyStateBucketName as or, isInterruptedWaitError as ot, maskingRetryLogger as p, buildForceUnlockCommand as pn, resolveUseCdkBootstrapAssets as pr, disableInstanceApiTermination as pt, getCurrentResourceSecrets as q, formatDockerLoginError as qn, PartialFailureError as qr, DagBuilder as qt, DeployEngine as r, normalizeAwsError as ri, maskSecretsInError as rn, Synthesizer as rr, beginCommandInterruptScope as rt, planRollback as s, isThrottlingError as si, redactSecretsForState as sn, resolveApp as sr, startInterruptWatch as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, formatError as ti, errorCauseChain as tn, AssetManifestLoader as tr, maskDeep as tt, updatePartialMessage as u, markRedactedCause as ui, S3StateBackend as un, resolveSkipPrefix as ur, UNSPECIFIED_SKIP_REASON as ut, buildFinalSnapshotIdentifier as v, DEFAULT_STATE_PREFIX as vn, MIGRATE_TMP_PREFIX as vr, coerceParameterTypedValue as vt, makeCanonicalizePropertiesFn as w, stringifyValue as wn, PARTITION_TABLE as wr, normalizeAwsTagsToCfn as wt, isFinalSnapshotError as x, importableOutputs as xn, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as xr, parameterTypeMayLoseSecretIdentity as xt, ccRoutedFinalSnapshotError as y, exportNamesCarriedFrom as yn, findLargeInlineResources as yr, getAccountInfo as yt, red as z, validateContainerRepoName as zn, DependencyError as zr, s3BucketDualStackDomainName as zt };
36662
+ //# sourceMappingURL=deploy-engine-DylsbAdu.js.map