@go-to-k/cdkd 0.286.3 → 0.287.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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-Rs7TV7Gz.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"
6527
6681
  ]);
6528
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"
6714
+ ]);
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
 
@@ -10783,7 +11161,9 @@ function recordResolvedPair(secrets, expression, plaintext) {
10783
11161
  * evidence — so a literal `Output` embedding one of two same-plaintext
10784
11162
  * references would fall back to the value scan and persist the sibling's
10785
11163
  * expression. The engine's other entry-by-entry copy — an `Export.Name`'s
10786
- * secrets into the pass map — deliberately does NOT call this: a name never
11164
+ * secrets into the pass map — deliberately does NOT call this (and `cdkd
11165
+ * scrub`'s name loop resolves through a VIEW whose pairs never reach the pass
11166
+ * map at all, issue #2531): a name never
10787
11167
  * positions a leaf, a value re-using the same token records its own pair at
10788
11168
  * the seam, and the only thing the merge could add is a CONFLICT (a
10789
11169
  * non-cacheable `{{resolve:ssm:X}}` whose value moved between the value pass
@@ -11468,7 +11848,7 @@ function storeAssociation(associations, key, expression, plaintext) {
11468
11848
  * matters: an AWS resource type string is fixed by AWS, and a typo in either
11469
11849
  * copy makes that copy's gate simply never fire (no-op), never fire wrongly.
11470
11850
  */
11471
- const NESTED_STACK_RESOURCE_TYPE$1 = "AWS::CloudFormation::Stack";
11851
+ const NESTED_STACK_RESOURCE_TYPE$2 = "AWS::CloudFormation::Stack";
11472
11852
  /**
11473
11853
  * What a PARENT stack's resolution proved about each `Parameters` entry of an
11474
11854
  * `AWS::CloudFormation::Stack` row it is about to provision, keyed by the
@@ -11575,7 +11955,7 @@ const nestedStackParameterExpressions = /* @__PURE__ */ new WeakMap();
11575
11955
  * `secret-redaction-nested-parameter-source.test.ts`.
11576
11956
  */
11577
11957
  function recordNestedStackParameterExpressions(secrets, resourceType, resolvedProperties, sourceProperties, rules = TEMPLATE_DERIVED_RULES) {
11578
- if (resourceType !== NESTED_STACK_RESOURCE_TYPE$1) return;
11958
+ if (resourceType !== NESTED_STACK_RESOURCE_TYPE$2) return;
11579
11959
  if (secrets.size === 0) return;
11580
11960
  if (!isPlainObject$2(resolvedProperties) || !isPlainObject$2(sourceProperties)) return;
11581
11961
  if (!Object.hasOwn(resolvedProperties, "Parameters")) return;
@@ -16753,9 +17133,28 @@ function secretsManagerSecretId(inner) {
16753
17133
  * - A trailing `:<version>` / `:<label>` is part of the name AS SSM PARSES IT
16754
17134
  * (`GetParameter` accepts `name:3` / `name:prod`), so stripping it would name
16755
17135
  * a different thing in the refusal message than the one that would be read.
17136
+ *
17137
+ * A COLON-LESS body (`{{resolve:ssm}}` / `{{resolve:ssm-secure}}`) names no
17138
+ * parameter at all, and the empty string is what says so: the caller reads a
17139
+ * falsy name as `local`, which hands the reference on to the resolver's own
17140
+ * `PARAMETER_NAME is required` — thrown BEFORE any client is built, so no
17141
+ * `GetParameter` is ever issued for such a token. Without the guard
17142
+ * `indexOf(':')` is `-1` and `substring(0)` returns the SERVICE STRING as the
17143
+ * parameter name, so with a foreign producer region on record the same
17144
+ * malformed input drew the ambiguous-region refusal naming `'ssm-secure'` as
17145
+ * the secret. {@link secretsManagerSecretId} already answers `''` for its own
17146
+ * nameless body (a fixed-length `substring` past the end of the string), and a
17147
+ * sibling that answers something else is the kind of split a later reader has
17148
+ * to rediscover.
17149
+ *
17150
+ * The colon-less relaxation has a CONSUMER-VISIBLE consequence on a mixed
17151
+ * leaf, and it is recorded on {@link classifyReplaySecretRegion} rather than
17152
+ * here: this function produces a NAME, and the delta belongs beside the
17153
+ * VERDICT a reader arrives at it through.
16756
17154
  */
16757
17155
  function ssmParameterName(inner) {
16758
- return inner.substring(inner.indexOf(":") + 1);
17156
+ const serviceEnd = inner.indexOf(":");
17157
+ return serviceEnd < 0 ? "" : inner.substring(serviceEnd + 1);
16759
17158
  }
16760
17159
  /**
16761
17160
  * The region an ARN names, or `undefined` for anything that is not an ARN with
@@ -16893,6 +17292,101 @@ function producerRegionsFromState(state) {
16893
17292
  * A same-region ARN answers `local` even when a foreign producer region IS on
16894
17293
  * record: the expression settles the question itself, so the weaker evidence
16895
17294
  * never gets consulted.
17295
+ *
17296
+ * ---
17297
+ *
17298
+ * WHO CONSUMES THIS VERDICT, and what the issue #2501 colon-less fix changed
17299
+ * for them. Recorded here because a reader arrives at the blast radius through
17300
+ * the verdict, not through {@link ssmParameterName}, which merely produces a
17301
+ * name.
17302
+ *
17303
+ * Three LEAF PRE-PASSES consume it — `rollback-executor.ts`'s
17304
+ * `resolveLeafByRegion`, `cdkd drift`'s `resolveDriftLeafByRegion` and `cdkd
17305
+ * scrub`'s — and each refuses the WHOLE leaf as soon as ONE token classifies
17306
+ * `ambiguous`, before any token is fetched. There is a FOURTH consumer, and it
17307
+ * is not a pre-pass: `resolveDynamicReferences` classifies token-by-token
17308
+ * INSIDE its substitution loop, so it already fetches earlier tokens before
17309
+ * refusing a later one. That is why the pre-fetch refusal was never a designed
17310
+ * property of this verdict.
17311
+ *
17312
+ * Before the fix, a colon-less `{{resolve:ssm-secure}}` produced the SERVICE
17313
+ * STRING as its name, so with a foreign producer region on record it drew an
17314
+ * `ambiguous` verdict and the pre-pass refused the whole leaf: for
17315
+ * `local=<same-region ARN>;secure={{resolve:ssm-secure}}`, nothing was
17316
+ * resolved. It now answers `local`, so the leaf IS resolved — by the primary
17317
+ * resolver when every other token is `local`, or through the segment-rebuild
17318
+ * path when one is `named-region` — and the ARN sibling's plaintext is fetched
17319
+ * and cached before the degenerate token throws `PARAMETER_NAME is required`.
17320
+ * Pinned by `rollback-executor-cross-region-secret.test.ts`'s "the MIXED-leaf
17321
+ * delta the colon-less guard accepts", so the trade is visible rather than
17322
+ * argued.
17323
+ *
17324
+ * Accepted on three grounds, the third being a LIMIT rather than a
17325
+ * reassurance:
17326
+ *
17327
+ * 1. The lost refusal was an ACCIDENT of the mis-parse, not a designed
17328
+ * protection: with no foreign producer region on record — the
17329
+ * overwhelmingly common case — the colon-less token classified `local`
17330
+ * before the fix too, so the same sibling was already fetched.
17331
+ * 2. The sibling is fetched from the region its OWN verdict names, never a
17332
+ * guessed one (a name-form sibling would itself be `ambiguous` and the
17333
+ * pre-pass would still refuse), so nothing here weakens the issue #1957
17334
+ * rule this module exists to enforce. Nothing fetched reaches a DURABLE
17335
+ * sink — `state.json`, the rollback journal, the `deployments/` event
17336
+ * store, a `--json` payload — and the reason is NOT "the op throws before
17337
+ * any write", which is false for scrub (see 3: scrub swallows and keeps
17338
+ * writing). It is that both places a fetched plaintext is retained are
17339
+ * IN-PROCESS and neither is copied out: the resolver's own
17340
+ * `cachedDynamicReferences` (instance-scoped since issue #1933, so it dies
17341
+ * with the resolver) and `recordedSecretValues`, which every persist path
17342
+ * consults as a redaction NEEDLE set — an entry there causes a value to be
17343
+ * REPLACED BY its expression on the way out, never inserted.
17344
+ *
17345
+ * ON THIS PATH one more needle can only redact more, which is why the
17346
+ * direction is safe even though it fetches more — and the qualifier is
17347
+ * load-bearing rather than hedging. It is NOT a general property of the
17348
+ * needle machinery: `redactSecretsForState`'s own doc (see the
17349
+ * `preferPositionDecisions` ordering note) records that a needle rewriting
17350
+ * a FRAME ANCHOR can un-certify `unkeyedArrayPairsByAnchors`, refuse the
17351
+ * array, and leave a sibling MIXED leaf in plaintext — "a regression of
17352
+ * shipped redaction, in the GHSA disclosure direction". That is reachable
17353
+ * with a NON-EMPTY map on a `STATE_SOURCED_READBACK_RULES` caller, e.g.
17354
+ * `rollback-executor.ts`'s `redactRollbackRecord` ->
17355
+ * `scrubResourceRecord`. It is not what fires here: the case this
17356
+ * paragraph is about records FEWER needles, not more, so nothing new can
17357
+ * rewrite an anchor.
17358
+ * 3. WHAT THE OP DOES NEXT IS NOT UNIFORM. The replay fails the op and `cdkd
17359
+ * drift` reports the resource NOT compared — both loud. `cdkd scrub` is
17360
+ * NOT: `isRegionAmbiguousRefusal` re-raises only a
17361
+ * `DynamicReferenceRegionAmbiguousError`, and the resolver's replacement is
17362
+ * a plain `Error`, so scrub swallows it to `debug` and the run reports
17363
+ * clean. A LOUDNESS regression for that one command on this one input,
17364
+ * bounded by the identical silent miss that already happens for the same
17365
+ * leaf whenever no foreign producer region is on record.
17366
+ *
17367
+ * Issue [#2692](https://github.com/go-to-k/cdkd/issues/2692) tracks it, and
17368
+ * the remedy is NOT resolver-local. Two candidate fixes are wrong, each in
17369
+ * its own way, and scrub has TWO predicates that must not be confused:
17370
+ * `isRegionAmbiguousRefusal` decides RE-RAISE vs SWALLOW (its three call
17371
+ * sites), while `isByDesignRefusal` — matching
17372
+ * `CrossAccountSecretRefusalError` — decides FINDING vs REFUSE. They point
17373
+ * in opposite directions, so a fix aimed at one must not be argued from
17374
+ * the other's record.
17375
+ *
17376
+ * - Throwing `IntrinsicResolutionRefusalError` and widening
17377
+ * `isRegionAmbiguousRefusal` to match the BASE class would make every
17378
+ * user-fixable refusal that class carries RE-RAISE and fail the whole
17379
+ * stack — not the silent-downgrade hazard `isByDesignRefusal`'s doc
17380
+ * records, but the opposite over-refusal, on a class with throw sites
17381
+ * spread across the resolver.
17382
+ * - Throwing the region-ambiguous SUBCLASS is wrong differently: scrub
17383
+ * already re-raises it, but its message tells the user to spell the
17384
+ * reference as a full ARN, which cannot fix a reference naming no
17385
+ * parameter.
17386
+ *
17387
+ * So #2692 needs a NEW sibling subclass AND a scrub-side predicate change,
17388
+ * which makes it blocked on `src/cli/commands/scrub.ts` (held by PR
17389
+ * #2562), not merely on the resolver's `integ-broad` cost.
16896
17390
  */
16897
17391
  function classifyReplaySecretRegion(expression, consumerRegion, importedProducerRegions) {
16898
17392
  const inner = expression.startsWith("{{resolve:") ? expression.slice(10, -2) : void 0;
@@ -18586,7 +19080,7 @@ function carriesDynamicReference(value) {
18586
19080
  return false;
18587
19081
  }
18588
19082
  /** The nested-stack resource type, whose `Outputs.<Name>` attributes are re-resolved (issue #2055). */
18589
- const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
19083
+ const NESTED_STACK_RESOURCE_TYPE$1 = "AWS::CloudFormation::Stack";
18590
19084
  /** Prefix `NestedStackProvider` records a child stack output under. */
18591
19085
  const NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX = "Outputs.";
18592
19086
  /**
@@ -19720,6 +20214,10 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
19720
20214
  const conditions = {};
19721
20215
  const templateConditions = context.template.Conditions;
19722
20216
  if (!templateConditions || typeof templateConditions !== "object") return conditions;
20217
+ const maskingContext = context.recordedSecretValues ? context : {
20218
+ ...context,
20219
+ recordedSecretValues: /* @__PURE__ */ new Map()
20220
+ };
19723
20221
  const inProgress = /* @__PURE__ */ new Set();
19724
20222
  const evaluateByName = async (name) => {
19725
20223
  if (name in conditions) return conditions[name];
@@ -19733,7 +20231,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
19733
20231
  inProgress.add(name);
19734
20232
  try {
19735
20233
  const result = await this.resolveValue(definition, {
19736
- ...context,
20234
+ ...maskingContext,
19737
20235
  conditionResolver: evaluateByName
19738
20236
  });
19739
20237
  const value = Boolean(result);
@@ -19747,7 +20245,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
19747
20245
  for (const name of Object.keys(templateConditions)) try {
19748
20246
  await evaluateByName(name);
19749
20247
  } catch (error) {
19750
- this.logger.warn(`Failed to evaluate condition ${name}: ${error instanceof Error ? error.message : String(error)}, assuming false`);
20248
+ this.logger.warn(this.maskSecretsForLog(`Failed to evaluate condition ${name}: ${error instanceof Error ? error.message : String(error)}, assuming false`, maskingContext));
19751
20249
  conditions[name] = false;
19752
20250
  inProgress.delete(name);
19753
20251
  }
@@ -20077,7 +20575,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
20077
20575
  return this.noteAttributeSecrecy(logicalId, attributeName, nameServers, context);
20078
20576
  }
20079
20577
  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 }));
20578
+ 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
20579
  return this.noteAttributeSecrecy(logicalId, attributeName, flatValue, context);
20082
20580
  }
20083
20581
  if (attributeName.includes(".")) {
@@ -20094,7 +20592,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
20094
20592
  }
20095
20593
  }
20096
20594
  }
20097
- if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)) {
20595
+ if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE$1 && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)) {
20098
20596
  const declared = Object.keys(resource.attributes ?? {}).filter((k) => k.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)).map((k) => k.slice(8)).sort();
20099
20597
  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
20598
  }
@@ -21849,10 +22347,10 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
21849
22347
  * which caches nothing (issue #1933).
21850
22348
  */
21851
22349
  let cacheable = true;
21852
- if (service === "secretsmanager") resolved = await this.resolveSecretsManagerReference(inner);
22350
+ if (service === "secretsmanager") resolved = await this.resolveSecretsManagerReference(inner, context);
21853
22351
  else if (service === "ssm") {
21854
22352
  const decrypt = context?.skipDynamicReferences !== true;
21855
- const param = await this.resolveSSMReference(parts, decrypt);
22353
+ const param = await this.resolveSSMReference(parts, decrypt, "ssm", context);
21856
22354
  if (param.type === "SecureString") this.pinSecretVerdict(fullMatch, true);
21857
22355
  else if (!param.secure) this.pinSecretVerdict(fullMatch, false);
21858
22356
  else cacheable = false;
@@ -21862,12 +22360,12 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
21862
22360
  }
21863
22361
  resolved = param.value;
21864
22362
  } else if (service === "ssm-secure") {
21865
- const param = await this.resolveSSMReference(parts, true, "ssm-secure");
22363
+ const param = await this.resolveSSMReference(parts, true, "ssm-secure", context);
21866
22364
  if (!param.secure) throw markNonRetryable(new IntrinsicResolutionRefusalError(`Refusing to resolve ${fullMatch}: the parameter is a ${param.type} parameter, and the ssm-secure spelling is defined for SecureString parameters only. Reference it as {{resolve:ssm:...}} if it is public configuration.`));
21867
22365
  isSecret = true;
21868
22366
  resolved = param.value;
21869
22367
  } else {
21870
- this.logger.warn(`Unsupported dynamic reference service: ${service}`);
22368
+ this.logger.warn(this.maskSecretsForLog(`Unsupported dynamic reference service: ${service}`, context));
21871
22369
  continue;
21872
22370
  }
21873
22371
  if (cacheable) this.cachedDynamicReferences.set(fullMatch, {
@@ -21898,7 +22396,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
21898
22396
  * runs when no mid-string ":SecretString:" delimiter is present, so the json-key / version
21899
22397
  * forms are unaffected.)
21900
22398
  */
21901
- async resolveSecretsManagerReference(inner) {
22399
+ async resolveSecretsManagerReference(inner, context) {
21902
22400
  const afterService = inner.substring(15);
21903
22401
  let secretId;
21904
22402
  let jsonKey = "";
@@ -21927,14 +22425,14 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
21927
22425
  } else secretId = afterService;
21928
22426
  if (!versionStage) versionStage = "AWSCURRENT";
21929
22427
  if (!secretId) throw new Error("Dynamic reference: secretsmanager SECRET_ID is required");
21930
- this.logger.debug(`Resolving dynamic reference: secretsmanager:${secretId}:SecretString:${jsonKey}:${versionStage}:${versionId}`);
22428
+ this.logger.debug(this.maskSecretsForLog(`Resolving dynamic reference: secretsmanager:${secretId}:SecretString:${jsonKey}:${versionStage}:${versionId}`, context));
21931
22429
  const client = this.clientsForRegion(this.explicitRegion).secretsManager;
21932
22430
  const command = new GetSecretValueCommand({
21933
22431
  SecretId: secretId,
21934
22432
  ...versionStage && versionStage !== "" && { VersionStage: versionStage },
21935
22433
  ...versionId && versionId !== "" && { VersionId: versionId }
21936
22434
  });
21937
- const secretString = (await this.sendWithThrottleRetry(() => client.send(command), `secretsmanager:${secretId}`)).SecretString;
22435
+ const secretString = (await this.sendWithThrottleRetry(() => client.send(command), this.maskSecretsForLog(`secretsmanager:${secretId}`, context))).SecretString;
21938
22436
  if (!secretString) throw new Error(`Dynamic reference: secret '${secretId}' does not contain a SecretString value`);
21939
22437
  if (jsonKey) try {
21940
22438
  const keyValue = JSON.parse(secretString)[jsonKey];
@@ -22087,16 +22585,16 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
22087
22585
  * discard the value when `secure` is set — it is ciphertext, not the resolved
22088
22586
  * reference.
22089
22587
  */
22090
- async resolveSSMReference(parts, decrypt = true, service = "ssm") {
22588
+ async resolveSSMReference(parts, decrypt = true, service = "ssm", context) {
22091
22589
  const parameterName = parts.slice(1).join(":");
22092
22590
  if (!parameterName) throw new Error(`Dynamic reference: ${service} PARAMETER_NAME is required`);
22093
- this.logger.debug(`Resolving dynamic reference: ${service}:${parameterName}`);
22591
+ this.logger.debug(this.maskSecretsForLog(`Resolving dynamic reference: ${service}:${parameterName}`, context));
22094
22592
  const client = this.clientsForRegion(this.explicitRegion).ssm;
22095
22593
  const command = new GetParameterCommand({
22096
22594
  Name: parameterName,
22097
22595
  WithDecryption: decrypt
22098
22596
  });
22099
- const response = await this.sendWithThrottleRetry(() => client.send(command), `${service}:${parameterName}`);
22597
+ const response = await this.sendWithThrottleRetry(() => client.send(command), this.maskSecretsForLog(`${service}:${parameterName}`, context));
22100
22598
  const paramValue = response.Parameter?.Value;
22101
22599
  if (paramValue === void 0 || paramValue === null) throw new Error(`Dynamic reference: SSM parameter '${parameterName}' not found or has no value`);
22102
22600
  const paramType = response.Parameter?.Type;
@@ -22104,7 +22602,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
22104
22602
  if (secure && paramType !== "SecureString" && !this.warnedUnrecognizedSsmTypes.has(`${parameterName}\u0000${String(paramType)}`)) {
22105
22603
  this.warnedUnrecognizedSsmTypes.add(`${parameterName}\u0000${String(paramType)}`);
22106
22604
  const reported = paramType === void 0 ? "(absent)" : `'${String(paramType)}'`;
22107
- this.logger.warn(`SSM parameter '${parameterName}' reported an unrecognized Type ${reported} — treating its value as a secret, so cdkd will persist the {{resolve:${service}:...}} expression rather than the resolved value. Declare the parameter as String / StringList if it is public config.`);
22605
+ this.logger.warn(this.maskSecretsForLog(`SSM parameter '${parameterName}' reported an unrecognized Type ${reported} — treating its value as a secret, so cdkd will persist the {{resolve:${service}:...}} expression rather than the resolved value. Declare the parameter as String / StringList if it is public config.`, context));
22108
22606
  }
22109
22607
  return {
22110
22608
  value: paramValue,
@@ -23057,9 +23555,14 @@ function describeJsonKeys(document) {
23057
23555
  * `disableCcApiFallback` is not read on that path. The poisoned pre-guard
23058
23556
  * state record the issue is about is precisely a record that already says
23059
23557
  * `cc-api`, so it would still arrive here. Only adding the type to
23060
- * `STICKY_CC_MIGRATION_EXEMPT` would divert it -- and that set is reserved
23061
- * for types whose CC routing is BROKEN, which would then send the
23062
- * silent-drop property back down the dropping path on the next deploy.
23558
+ * `STICKY_CC_MIGRATION_EXEMPT` would divert it. Since issue #2719 that
23559
+ * table admits two modes, and NEITHER helps here: `'cc-broken'` is for
23560
+ * types Cloud Control cannot manage, and `'sdk-coverage'` diverts a
23561
+ * resource only when its property bags carry no actionable silent drop --
23562
+ * which is the opposite of this case by construction. Were a type somehow
23563
+ * admitted anyway, the divert would send the silent-drop property back
23564
+ * down the dropping path on the next deploy; the property gate is what
23565
+ * prevents it.
23063
23566
  *
23064
23567
  * So the confirmation belongs where the delete is actually issued. The set is
23065
23568
  * a set rather than an `if` because the hazard is not S3-specific in kind: any
@@ -23308,7 +23811,7 @@ var CloudControlProvider = class {
23308
23811
  const indeterminateGuard = await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
23309
23812
  if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
23310
23813
  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);
23814
+ const { ASGProvider } = await import("./asg-provider-D10GWdiA.js").then((n) => n.n);
23312
23815
  return withIndeterminateGuard(await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context), indeterminateGuard);
23313
23816
  }
23314
23817
  const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
@@ -25493,7 +25996,7 @@ var CustomResourceProvider = class CustomResourceProvider {
25493
25996
  reuseClientCredentials: true,
25494
25997
  tolerateNonStandardClient: true,
25495
25998
  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.`);
25999
+ 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
26000
  }
25498
26001
  });
25499
26002
  if (generation !== this.responseClientGeneration) {
@@ -26170,7 +26673,7 @@ var CustomResourceProvider = class CustomResourceProvider {
26170
26673
  Key: responseKey
26171
26674
  });
26172
26675
  const presignedUrl = await getSignedUrl(this.s3Client, command, { expiresIn: 7200 });
26173
- this.logger.debug(`Generated pre-signed URL for response: s3://${this.responseBucket}/${responseKey}`);
26676
+ this.logger.debug(`Generated pre-signed URL for response: s3://${displaySafe(this.responseBucket, { asciiOnly: true }) || "<unrenderable>"}/${displaySafe(responseKey)}`);
26174
26677
  return presignedUrl;
26175
26678
  }
26176
26679
  /**
@@ -26274,7 +26777,7 @@ var CustomResourceProvider = class CustomResourceProvider {
26274
26777
  Key: responseKey
26275
26778
  }));
26276
26779
  } 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)}`);
26780
+ 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
26781
  }
26279
26782
  await purgeNoncurrentKeyVersions(this.s3Client, bucket, [responseKey], {
26280
26783
  logger: this.logger,
@@ -28564,6 +29067,65 @@ function findActionableSilentDrops(resourceType, templateProperties, allowedKeys
28564
29067
  if (drops.length === 0) return drops;
28565
29068
  return drops.filter(({ property }) => !allowedKeys.has(`${resourceType}:${property}`));
28566
29069
  }
29070
+ /**
29071
+ * A 1-click pre-filled GitHub issue link requesting cdkd support for a
29072
+ * specific top-level property on a resource type. Surfaced in the pre-flight
29073
+ * error so a user hitting a silent drop lands directly in the "request
29074
+ * support" flow.
29075
+ */
29076
+ function unsupportedPropertyIssueUrl(resourceType, property) {
29077
+ return `https://github.com/go-to-k/cdkd/issues/new?title=${encodeURIComponent(`Support property ${resourceType}.${property}`)}&labels=resource-support`;
29078
+ }
29079
+ /**
29080
+ * Identify top-level template properties this type's committed CFn schema
29081
+ * snapshot does not know about at all — present in neither
29082
+ * `coverage.handled` nor `coverage.silentDrop` (issue
29083
+ * [#2718](https://github.com/go-to-k/cdkd/issues/2718)).
29084
+ *
29085
+ * The complement of {@link findSilentDropProperties}, which deliberately
29086
+ * PASSES these through: a property absent from the schema is indistinguishable
29087
+ * at deploy time from a user typo or an `addPropertyOverride` escape hatch, so
29088
+ * it cannot drive a routing decision. That tolerance is correct for routing
29089
+ * and wrong for silence — the routing table is built offline from
29090
+ * `tests/fixtures/cfn-schemas/*.json`, so every property AWS publishes AFTER
29091
+ * that snapshot lands here, and on the SDK route it reaches neither AWS nor an
29092
+ * error while the deploy reports success. That is the issue
29093
+ * [#614](https://github.com/go-to-k/cdkd/issues/614) failure class arriving
29094
+ * through the one input the #614 machinery cannot observe.
29095
+ *
29096
+ * **Why a warn built on this has no false-positive mode.** An SDK provider
29097
+ * writes only what it declares in `handledProperties`, so a top-level property
29098
+ * in neither set does not reach AWS under ANY of the three readings — a
29099
+ * post-snapshot AWS addition, a typo, or a deliberate `addPropertyOverride`.
29100
+ * "This value will not reach AWS" is true in all three, so the caller does not
29101
+ * have to guess intent, and at deploy time it could not: cdkd holds only the
29102
+ * template and the baked-in table. Firing on a typo is a feature rather than
29103
+ * noise — CloudFormation would have REJECTED that typo, so today's silence is
29104
+ * strictly the worst of the three behaviors.
29105
+ *
29106
+ * Deliberately NOT gated on the snapshot's age: the statement is true whatever
29107
+ * `generatedAt` says, and an age gate would trade the typo visibility away for
29108
+ * a wall-clock dependence.
29109
+ *
29110
+ * Returns `[]` for Tier 2 / Custom / unknown types (no coverage record — Cloud
29111
+ * Control forwards the full property map, so nothing is dropped) and sorts
29112
+ * alphabetically, mirroring {@link findSilentDropProperties}. The CALLER is
29113
+ * responsible for firing only when the resource actually resolves to the SDK
29114
+ * route; see `ProviderRegistry.reportSilentDropDecisions`.
29115
+ */
29116
+ function findUnrecognizedProperties(resourceType, templateProperties) {
29117
+ if (!templateProperties) return [];
29118
+ const coverage = getPropertyCoverage(resourceType);
29119
+ if (!coverage) return [];
29120
+ const unrecognized = [];
29121
+ for (const prop of Object.keys(templateProperties)) {
29122
+ if (prop === "Ref" || prop.startsWith("Fn::")) continue;
29123
+ if (coverage.handled.has(prop)) continue;
29124
+ if (coverage.silentDrop.has(prop)) continue;
29125
+ unrecognized.push(prop);
29126
+ }
29127
+ return unrecognized.sort((a, b) => a.localeCompare(b));
29128
+ }
28567
29129
 
28568
29130
  //#endregion
28569
29131
  //#region src/provisioning/mutually-exclusive-properties.ts
@@ -28673,56 +29235,39 @@ function buildMutuallyExclusiveMessage(logicalId, violation) {
28673
29235
  //#endregion
28674
29236
  //#region src/provisioning/provider-registry.ts
28675
29237
  /**
28676
- * Provider registry for managing resource providers.
28677
- *
28678
- * Selection strategy for a fresh resource (see {@link getProviderFor}):
28679
- * 1. Custom Resource (`Custom::*` / `AWS::CloudFormation::CustomResource`)
28680
- * → Custom Resource provider (recorded as `provisionedBy: 'sdk'`).
28681
- * 2. Existing-state `provisionedBy: 'cc-api'` → Cloud Control (sticky).
28682
- * 3. SDK Provider registered, no silent-drop properties (after the
28683
- * `--allow-unsupported-properties` override filter) → SDK Provider.
28684
- * 4. SDK Provider registered, silent-drop properties present, NOT all
28685
- * in the allow set → Cloud Control (auto-route, info-logged). When the
28686
- * CC route is NOT viable — the type is `NON_PROVISIONABLE` (no CC
28687
- * handlers, e.g. AWS::FSx::FileSystem) or the provider sets
28688
- * `disableCcApiFallback` (e.g. NestedStackProvider) — throw the clear
28689
- * pre-flight error instead of failing opaquely at provisioning time.
28690
- * 5. SDK Provider registered, silent-drop properties present, ALL in
28691
- * the allow set → SDK Provider (the user explicitly accepted the
28692
- * silent drop, warn-logged).
28693
- * 6. No SDK Provider, Cloud Control supports the type → Cloud Control.
28694
- * 7. `--allow-unsupported-types` escape hatch → Cloud Control optimistically.
28695
- * 8. Otherwise → throw (no provider available).
28696
- *
28697
- * SDK-provider-less Tier 3 (`NON_PROVISIONABLE`) types are rejected earlier
28698
- * by {@link validateResourceTypes}. A Tier 1 type that is ALSO
28699
- * NON_PROVISIONABLE (SDK provider registered for a type Cloud Control cannot
28700
- * manage — e.g. AWS::FSx::FileSystem, AWS::DLM::LifecyclePolicy) passes the
28701
- * type check but has no viable CC auto-route; rule 4's viability guard turns
28702
- * that case into a clear pre-flight error.
28703
- */
28704
- /**
28705
29238
  * Types exempt from the sticky `provisionedBy: 'cc-api'` routing rule.
28706
29239
  *
28707
- * The sticky rule exists to avoid physical-ID churn when an SDK provider is
28708
- * backfilled for a type Cloud Control was already managing fine. These types
28709
- * are different: their CLOUD CONTROL ROUTING IS BROKEN, so keeping existing
28710
- * state pinned to cc-api would keep the bug alive for every pre-existing
28711
- * resource. Only add a type here when BOTH hold:
28712
- *
28713
- * 1. the CC handler cannot correctly manage the resource (not a perf choice),
28714
- * 2. the SDK provider uses the SAME physicalId the CC path stored, so the
28715
- * re-route is churn-free and the record flips to `provisionedBy: 'sdk'`
28716
- * transparently on its next state write.
28717
- *
28718
- * - AWS::Scheduler::Schedule (issue #961): a schedule in a custom
28719
- * ScheduleGroup is unaddressable via CC (the handlers resolve the bare-Name
28720
- * identifier against the DEFAULT group) — CC UPDATE fails NotFound and CC
28721
- * DELETE silently no-ops, orphaning a live schedule. Both paths stored the
28722
- * bare schedule name as physicalId, and the state properties carry
28723
- * GroupName, so the SDK provider addresses existing records correctly.
28724
- */
28725
- const STICKY_CC_MIGRATION_EXEMPT = /* @__PURE__ */ new Set(["AWS::Scheduler::Schedule"]);
29240
+ * The sticky rule (rule 2 in `getProviderFor`) exists to avoid physical-ID
29241
+ * churn when an SDK provider is backfilled for a type Cloud Control was
29242
+ * already managing fine. Both exemption modes are narrow escapes from it; see
29243
+ * `StickyExemptMode` for which applies when.
29244
+ *
29245
+ * Condition 2 -- physicalId parity -- is a hard requirement in BOTH modes and
29246
+ * is what makes a flip churn-free. It is also why this is a curated table
29247
+ * rather than a predicate: an automatic flip keyed on "the type has coverage"
29248
+ * would assert parity for types nobody measured.
29249
+ */
29250
+ const STICKY_CC_MIGRATION_EXEMPT = /* @__PURE__ */ new Map([["AWS::Scheduler::Schedule", {
29251
+ mode: "cc-broken",
29252
+ physicalIdForm: "both layers store the bare schedule name; the state properties carry GroupName, so the SDK provider addresses existing records correctly",
29253
+ issue: "https://github.com/go-to-k/cdkd/issues/961",
29254
+ integFixture: "scheduler-custom-group"
29255
+ }], ["AWS::SNS::Topic", {
29256
+ mode: "sdk-coverage",
29257
+ physicalIdForm: "both layers store the topic ARN: the schema primaryIdentifier is TopicArn and SnsTopicProvider.create records the CreateTopic TopicArn",
29258
+ issue: "https://github.com/go-to-k/cdkd/issues/2719",
29259
+ integFixture: "cc-to-sdk-reroute"
29260
+ }]]);
29261
+ function wouldReturnToSdkProvider(input) {
29262
+ const { resourceType, desiredProperties, previousProperties, allowedUnsupportedProperties = /* @__PURE__ */ new Set(), forceCcApi = false, exemptions = STICKY_CC_MIGRATION_EXEMPT } = input;
29263
+ const exemption = exemptions.get(resourceType);
29264
+ if (exemption === void 0) return false;
29265
+ if (exemption.mode === "cc-broken") return true;
29266
+ if (forceCcApi) return false;
29267
+ if (desiredProperties === void 0) return false;
29268
+ if (previousProperties === void 0) return false;
29269
+ return [desiredProperties, previousProperties].every((bag) => findActionableSilentDrops(resourceType, bag, allowedUnsupportedProperties).length === 0);
29270
+ }
28726
29271
  var ProviderRegistry = class {
28727
29272
  logger = getLogger().child("ProviderRegistry");
28728
29273
  providers = /* @__PURE__ */ new Map();
@@ -28817,18 +29362,36 @@ var ProviderRegistry = class {
28817
29362
  provisionedBy: "sdk"
28818
29363
  };
28819
29364
  }
28820
- if (provisionedBy === "cc-api" && !STICKY_CC_MIGRATION_EXEMPT.has(resourceType)) {
28821
- this.logger.debug(`Routing ${resourceType} via Cloud Control (state-recorded provisionedBy=cc-api)`);
28822
- return {
28823
- provider: this.cloudControlProvider,
28824
- provisionedBy: "cc-api"
28825
- };
29365
+ let returningToSdk = false;
29366
+ if (provisionedBy === "cc-api") {
29367
+ if (!wouldReturnToSdkProvider({
29368
+ resourceType,
29369
+ desiredProperties: properties,
29370
+ previousProperties: input.previousProperties,
29371
+ allowedUnsupportedProperties: this.allowedUnsupportedProperties,
29372
+ forceCcApi: input.forceCcApi === true
29373
+ })) {
29374
+ this.logger.debug(`Routing ${resourceType} via Cloud Control (state-recorded provisionedBy=cc-api)`);
29375
+ return {
29376
+ provider: this.cloudControlProvider,
29377
+ provisionedBy: "cc-api"
29378
+ };
29379
+ }
29380
+ returningToSdk = true;
28826
29381
  }
28827
29382
  const specificProvider = this.providers.get(resourceType);
28828
29383
  if (specificProvider) {
28829
29384
  const actionableDrops = findActionableSilentDrops(resourceType, properties, this.allowedUnsupportedProperties);
28830
29385
  if (actionableDrops.length === 0) {
28831
29386
  this.logger.debug(`Using specific SDK provider for ${resourceType}`);
29387
+ if (returningToSdk) {
29388
+ this.logger.debug(`${resourceType} is returning to its SDK provider from a state-recorded cc-api route; physical id is preserved`);
29389
+ return {
29390
+ provider: specificProvider,
29391
+ provisionedBy: "sdk",
29392
+ sdkMigration: true
29393
+ };
29394
+ }
28832
29395
  return {
28833
29396
  provider: specificProvider,
28834
29397
  provisionedBy: "sdk"
@@ -29028,7 +29591,6 @@ var ProviderRegistry = class {
29028
29591
  reportSilentDropDecisions(resources) {
29029
29592
  for (const { logicalId, resourceType, properties, provisionedBy } of resources) {
29030
29593
  const drops = findSilentDropProperties(resourceType, properties);
29031
- if (drops.length === 0) continue;
29032
29594
  const overridden = [];
29033
29595
  const autoRouted = [];
29034
29596
  for (const { property } of drops) {
@@ -29047,9 +29609,71 @@ var ProviderRegistry = class {
29047
29609
  const propList = overridden.join(", ");
29048
29610
  this.logger.warn(`${logicalId} (${resourceType}): ${propList} will be silently dropped (--allow-unsupported-properties override accepted). Remove the override to route this resource via Cloud Control API instead.`);
29049
29611
  }
29612
+ this.reportUnrecognizedProperties(logicalId, resourceType, properties, {
29613
+ provisionedBy,
29614
+ autoRouted: autoRouted.length > 0
29615
+ });
29050
29616
  }
29051
29617
  }
29052
29618
  /**
29619
+ * Warn about top-level template properties this type's committed CFn schema
29620
+ * snapshot does not know about, on resources that resolve to the SDK route
29621
+ * (issue [#2718](https://github.com/go-to-k/cdkd/issues/2718)).
29622
+ *
29623
+ * The gap this closes: {@link getProviderFor} decides SDK-vs-Cloud-Control
29624
+ * from `property-coverage.generated.ts`, built offline from the schema
29625
+ * fixtures, and there is no runtime `DescribeType` on that path. So a
29626
+ * property AWS publishes AFTER the fixture snapshot produces no
29627
+ * `silentDrop` entry, does not auto-route to Cloud Control, and is dropped
29628
+ * with the deploy reporting success — the issue
29629
+ * [#614](https://github.com/go-to-k/cdkd/issues/614) failure class reached
29630
+ * through the one input the #614 machinery cannot observe. The scheduled
29631
+ * fixture-refresh job is the FIX (the property enters the fixture and the
29632
+ * existing auto-route handles it); this warn is what protects a user
29633
+ * deploying BETWEEN refresh cycles.
29634
+ *
29635
+ * Fires only on the SDK route, which is where the drop actually happens.
29636
+ * The two Cloud-Control routes both forward the full property map verbatim,
29637
+ * so the property does reach AWS there and a warn would be false:
29638
+ * - `provisionedBy: 'cc-api'` from existing state (sticky rule 2 of
29639
+ * {@link getProviderFor}), minus the `STICKY_CC_MIGRATION_EXEMPT` types
29640
+ * that deliberately re-route back to their SDK provider;
29641
+ * - an actionable silent drop auto-routing this deploy (`autoRouted`).
29642
+ *
29643
+ * The route test MIRRORS `getProviderFor` rather than re-deriving it — the
29644
+ * two answering differently is the only way this warn can be wrong about a
29645
+ * resource, and it is not decidable from the message.
29646
+ *
29647
+ * **Known divergence, in the SAFE direction.** This runs on the template's
29648
+ * RAW properties (`deploy-engine.ts` calls `validateResourceProperties`
29649
+ * pre-flight) while `getProviderFor` runs on RESOLVED ones. So a silent-drop
29650
+ * key present only behind an `Fn::If` that resolves to `AWS::NoValue` makes
29651
+ * `autoRouted` true here and suppresses the warn, while the real route ends
29652
+ * up on the SDK provider and does drop the unrecognized property. The result
29653
+ * is a MISSING warn, never a false one — which is the right direction for an
29654
+ * advisory line, and why this is documented rather than fixed by resolving
29655
+ * twice. `getProviderFor` remains the authority on routing; nothing here
29656
+ * changes a routing decision.
29657
+ *
29658
+ * Suppressed per `<Type>:<Prop>` by `--allow-unsupported-properties`, whose
29659
+ * meaning ("accept the silent drop, stay on the SDK path") is exactly this
29660
+ * case; deliberately no new flag. Warn rather than error because the drop
29661
+ * may be intended, and deliberately NOT an auto-route: flipping to
29662
+ * Cloud Control on an UNRECOGNIZED property would let a typo trigger the
29663
+ * currently one-way `cc-api` state flip (issue
29664
+ * [#2719](https://github.com/go-to-k/cdkd/issues/2719)), and CC would reject
29665
+ * the unknown key anyway.
29666
+ */
29667
+ reportUnrecognizedProperties(logicalId, resourceType, properties, route) {
29668
+ if (route.provisionedBy === "cc-api" && !STICKY_CC_MIGRATION_EXEMPT.has(resourceType) || route.autoRouted) return;
29669
+ const unrecognized = findUnrecognizedProperties(resourceType, properties).filter((property) => !this.allowedUnsupportedProperties.has(`${resourceType}:${property}`));
29670
+ if (unrecognized.length === 0) return;
29671
+ const propList = unrecognized.join(", ");
29672
+ const overrideHint = unrecognized.map((p) => `${resourceType}:${p}`).join(",");
29673
+ const one = unrecognized.length === 1;
29674
+ this.logger.warn(`${logicalId} (${resourceType}): ${propList} ${one ? "is" : "are"} not in cdkd's CFn schema snapshot for this type, so ${one ? "it" : "they"} will NOT reach AWS — the deploy will still report success. Anything of these shapes looks the same here: a misspelled name (fix the spelling); a read-only attribute, which is not settable on any engine (remove it); or a property AWS published after cdkd's snapshot, which cdkd should be routing via Cloud Control — please report that one: ${unsupportedPropertyIssueUrl(resourceType, unrecognized[0])}${one ? "" : ` (link is for ${unrecognized[0]})`}. If the drop is intended — an addPropertyOverride escape hatch — silence this via --allow-unsupported-properties ${overrideHint}.`);
29675
+ }
29676
+ /**
29053
29677
  * Pure-functional discovery of every resource whose template uses one or
29054
29678
  * more silent-drop properties that are NOT in the
29055
29679
  * `--allow-unsupported-properties` allow set — i.e. every resource that
@@ -29771,6 +30395,70 @@ function getCurrentResourceSecrets() {
29771
30395
  return currentResourceSecretsStore.getStore();
29772
30396
  }
29773
30397
 
30398
+ //#endregion
30399
+ //#region src/deployment/type-change-guard.ts
30400
+ /**
30401
+ * The CFn type of a nested stack's row in its PARENT's template.
30402
+ *
30403
+ * Spelled locally rather than imported, matching
30404
+ * `src/deployment/recreate-targets.ts`: the only exported copy lives in
30405
+ * `src/cli/commands/retire-cfn-stack.ts`, and importing a CLI command module
30406
+ * from the deployment layer would invert the dependency direction.
30407
+ */
30408
+ const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
30409
+ /**
30410
+ * Find every planned change whose recorded type and template type differ with
30411
+ * `AWS::CloudFormation::Stack` on one side.
30412
+ *
30413
+ * Reads exactly the two values the defect is made of: `change.resourceType`
30414
+ * (what `provisionResource` binds and routes BOTH replacement halves on) and
30415
+ * the state record's `resourceType` (the resource that actually exists). That
30416
+ * is deliberate — deriving the "desired" type from the template again would be
30417
+ * a second implementation of the diff's own Type-change rule (metadata skip,
30418
+ * condition pruning) which could drift away from the routing decision this
30419
+ * guards.
30420
+ *
30421
+ * `changeType` is not filtered on. A Type change surfaces as an `UPDATE`, but a
30422
+ * DELETE / NO_CHANGE row cannot diverge in the first place (the diff builds
30423
+ * both from the state record's own type), so filtering would only add a way for
30424
+ * a future change-shape to slip past.
30425
+ */
30426
+ function findNestedStackTypeChanges(input) {
30427
+ const found = [];
30428
+ for (const [logicalId, change] of input.changes) {
30429
+ if (!Object.hasOwn(input.stateResources, logicalId)) continue;
30430
+ const currentResource = input.stateResources[logicalId];
30431
+ if (!currentResource) continue;
30432
+ const currentType = currentResource.resourceType;
30433
+ const desiredType = change.resourceType;
30434
+ if (currentType === desiredType) continue;
30435
+ const intoNested = desiredType === NESTED_STACK_RESOURCE_TYPE;
30436
+ if (!intoNested && !(currentType === NESTED_STACK_RESOURCE_TYPE)) continue;
30437
+ found.push({
30438
+ logicalId,
30439
+ currentType,
30440
+ desiredType,
30441
+ physicalId: currentResource.physicalId,
30442
+ direction: intoNested ? "into-nested-stack" : "out-of-nested-stack"
30443
+ });
30444
+ }
30445
+ return found;
30446
+ }
30447
+ /**
30448
+ * Render the refusal. Names the logical id, BOTH types, the resource the
30449
+ * mis-routed delete would be aimed at, and what to do instead.
30450
+ *
30451
+ * `stackName` is the stack being deployed, so the into-nested arm can print the
30452
+ * child stack name `NestedStackProvider.delete` would derive and destroy — the
30453
+ * one piece of the damage the user cannot read off their own template.
30454
+ */
30455
+ function renderNestedStackTypeChangeRefusal(typeChanges, stackName) {
30456
+ const rows = typeChanges.map((tc) => {
30457
+ 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.`}`;
30458
+ });
30459
+ 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.`;
30460
+ }
30461
+
29774
30462
  //#endregion
29775
30463
  //#region src/deployment/outputs-export-alias.ts
29776
30464
  /**
@@ -30094,6 +30782,218 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
30094
30782
  }
30095
30783
  }
30096
30784
 
30785
+ //#endregion
30786
+ //#region src/provisioning/dynamodb-warm-throughput.ts
30787
+ /**
30788
+ * The two DynamoDB `WarmThroughput` rules that `AWS::DynamoDB::Table` and
30789
+ * `AWS::DynamoDB::GlobalTable` both need, in ONE spelling.
30790
+ *
30791
+ * `WarmThroughput` is the same CloudFormation block on both types, and both
30792
+ * providers have to answer the same two questions about it before an
30793
+ * `UpdateTable` / `CreateTable` goes out:
30794
+ *
30795
+ * 1. **Is this block SENDABLE, and as what numbers?** CloudFormation is
30796
+ * stringly typed, so `{ReadUnitsPerSecond: '12000'}` — or anything that
30797
+ * came back from an `Fn::Sub` — arrives as a STRING and must not be
30798
+ * forwarded verbatim into a numeric `Long` field.
30799
+ * 2. **Would sending it LOWER what AWS already reports?** Warm throughput
30800
+ * only ever rises with a table's traffic and AWS REJECTS a call that
30801
+ * lowers it (`decreasing WarmThroughput is not supported`, measured live
30802
+ * us-east-1 2026-08-13 for issue #1768).
30803
+ *
30804
+ * **Provenance, stated precisely because it is easy to over-claim.** Exactly
30805
+ * ONE of these rules ever shipped: `AWS::DynamoDB::Table` got them in PR #1808
30806
+ * (issues #1760 / #1768), and the `AWS::DynamoDB::GlobalTable` side never
30807
+ * existed outside the change that created this file (issue #1857). So this is
30808
+ * the Table rule LIFTED — not two shipped rules reconciled, and no deployed
30809
+ * behaviour changed for either type when it moved here.
30810
+ *
30811
+ * What WAS compared is the lifted rule against the GlobalTable spelling
30812
+ * drafted alongside it, over the shapes both had to answer (a quoted numeric
30813
+ * string, a partially usable block, a mixed decrease/increase, an absent live
30814
+ * value, a zero, a negative, a non-numeric string, a whitespace string, an
30815
+ * explicit `undefined` / `null`, a boolean, an empty block, a scalar, an
30816
+ * array). {@link isWarmThroughputDecrease} agreed on every one; the coercion
30817
+ * agreed on all but a whitespace-only string, where a bare `Number(' ')` is
30818
+ * `0` rather than `NaN`. This module keeps the REFUSING answer for that shape,
30819
+ * since `' '` is not a capacity anyone declared and a warm throughput of 0
30820
+ * is not a request AWS can honour. That is a DRAFT reconciled against a
30821
+ * shipped rule, which is worth less than two shipped rules agreeing — the
30822
+ * reason to read the probe list as a design note rather than as field
30823
+ * evidence.
30824
+ *
30825
+ * Living here rather than in either provider is the point. A decrease guard is
30826
+ * three clauses that all FAIL OPEN for different reasons, and two files
30827
+ * spelling it independently is two chances for a later "fix" to change one of
30828
+ * them — at which point the sibling type silently keeps the old answer, and
30829
+ * nothing in the tree says which is intended. Same class as
30830
+ * `emr-configuration.ts`, and the same reason.
30831
+ *
30832
+ * Deliberately NOT here: everything only ONE provider has. The `Table` side's
30833
+ * `isSendableWarmThroughput` / `isRefusedWarmThroughput` /
30834
+ * `declaresWarmThroughput` / `warmThroughputAlreadyMatches` are its drift-side
30835
+ * and already-matches gates, which `GlobalTable` has no counterpart to (issue
30836
+ * #1742 strips the per-index `WarmThroughput` from BOTH of its drift
30837
+ * comparison sides unconditionally, so drift never asks the question there);
30838
+ * the `GlobalTable` side's `warmThroughputDiagnostic` builds a
30839
+ * `ThroughputDiagnostic` that only that provider's collector understands.
30840
+ * Moving a helper with one caller here would buy nothing and cost a hop.
30841
+ *
30842
+ * Issues: #1760 / #1768 (Table, PR #1808), #1857 (GlobalTable).
30843
+ */
30844
+ /**
30845
+ * The two `WarmThroughput` members, in the ONE order every message, every
30846
+ * comparison and every emitted block uses. A shared order is what makes two
30847
+ * blocks carrying the same numbers compare equal by `deepEqual` and serialize
30848
+ * to the same wire bytes regardless of the order the template wrote them in.
30849
+ */
30850
+ const WARM_THROUGHPUT_MEMBERS = ["ReadUnitsPerSecond", "WriteUnitsPerSecond"];
30851
+ /**
30852
+ * A CloudFormation-borne numeric property, or `undefined` when the value is
30853
+ * not a usable number.
30854
+ *
30855
+ * Plain `Number()` coercion is NOT good enough and the tree learned it three
30856
+ * separate times: `Number(null)`, `Number('')`, `Number([])`, `Number(false)`
30857
+ * and `Number(' ')` are all **0**, not `NaN` — so a live `0` would compare
30858
+ * EQUAL to a desired `null` / `''` / `[]` / `false`, and a whitespace-only
30859
+ * string would be forwarded as a request for zero units.
30860
+ *
30861
+ * A YAML-borne numeric STRING is still accepted, because that is a real
30862
+ * template shape and `'12000'` genuinely means 12000.
30863
+ *
30864
+ * The accepted STRING set is `Number()`'s, which is WIDER than a decimal
30865
+ * integer: `'0x1e'`, `'0o36'`, `'1e3'`, `'30.5'` and `' 30 '` all coerce.
30866
+ * Whether CloudFormation accepts those for an `Integer`-typed property is
30867
+ * unmeasured, and issue [#2698](https://github.com/go-to-k/cdkd/issues/2698)
30868
+ * holds the live A/B that would settle it. It matters at the CALLERS that
30869
+ * FORWARD the result to AWS rather than merely compare it — narrowing this
30870
+ * shared helper would change the DynamoDB capacity readers too, so read that
30871
+ * issue before tightening anything here.
30872
+ *
30873
+ * EXPORTED, and not because warm throughput needs it exported. This exact rule
30874
+ * was hand-written three times — here, as `dynamodb-table-provider.ts`'s
30875
+ * `capacityNumber` (byte-identical), and as `dynamodb-globaltable-provider.ts`'s
30876
+ * `toFiniteNumber` (a different spelling of the same total function) — which is
30877
+ * precisely the divergence this module exists to stop: the next "fix" to one of
30878
+ * them would have left the other two answering differently, with nothing in the
30879
+ * tree saying which was intended. It reads `ReadCapacityUnits` /
30880
+ * `MaxReadRequestUnits` / `MinCapacity` as readily as it reads
30881
+ * `ReadUnitsPerSecond`; there is only ever one right answer for "is this
30882
+ * stringly-typed CFn value a number I can send?".
30883
+ */
30884
+ function toFiniteNumber(value) {
30885
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
30886
+ if (typeof value === "string" && value.trim() !== "") {
30887
+ const n = Number(value);
30888
+ return Number.isFinite(n) ? n : void 0;
30889
+ }
30890
+ }
30891
+ /**
30892
+ * Coerce a CFn `WarmThroughput` block to the numeric shape the SDK's `Long`
30893
+ * fields accept, PER MEMBER.
30894
+ *
30895
+ * PER MEMBER, not whole-block, and that distinction is the point: a block
30896
+ * whose write half is an unresolved intrinsic still has a perfectly good read
30897
+ * half, and dropping both would silently discard a value the template really
30898
+ * did ask for. The dropped member is NAMED (`droppedMembers`) so the caller's
30899
+ * warning can say which half went missing instead of reporting the whole
30900
+ * property.
30901
+ *
30902
+ * A block with NO usable member yields `spec: undefined` — refused rather than
30903
+ * forwarded, because forwarding a malformed block surfaces as an opaque AWS
30904
+ * validation error naming neither cdkd nor the property. `droppedMembers` is
30905
+ * still populated in that case, so a REFUSAL message can name what it refused;
30906
+ * a block that is not an object at all (an unresolved `Fn::If`, a scalar, an
30907
+ * array) has no member to name and reports none, which is what lets a caller
30908
+ * word "one half went missing" differently from "the block is unusable".
30909
+ *
30910
+ * Pure: takes the raw bag, returns numbers, logs nothing. Each caller owns its
30911
+ * own message, so the wording stays consistent across that provider's send
30912
+ * sites without forcing one wording across both providers.
30913
+ */
30914
+ function coerceWarmThroughput(raw) {
30915
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { droppedMembers: [] };
30916
+ const bag = raw;
30917
+ const spec = {};
30918
+ const droppedMembers = [];
30919
+ for (const member of WARM_THROUGHPUT_MEMBERS) {
30920
+ if (bag[member] === void 0) continue;
30921
+ const coerced = toFiniteNumber(bag[member]);
30922
+ if (coerced === void 0) {
30923
+ droppedMembers.push(member);
30924
+ continue;
30925
+ }
30926
+ spec[member] = coerced;
30927
+ }
30928
+ if (Object.keys(spec).length === 0) return { droppedMembers };
30929
+ return {
30930
+ spec,
30931
+ droppedMembers
30932
+ };
30933
+ }
30934
+ /**
30935
+ * Whether the COERCED desired `WarmThroughput` would LOWER what AWS already
30936
+ * reports.
30937
+ *
30938
+ * Measured live (us-east-1, 2026-08-13, issue #1768) against a table AWS
30939
+ * reports `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
30940
+ *
30941
+ * ```
30942
+ * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
30943
+ * ValidationException: One or more parameter values were invalid: Requested
30944
+ * ReadUnitsPerSecond for WarmThroughput for table is lower than current
30945
+ * WarmThroughput, decreasing WarmThroughput is not supported
30946
+ * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
30947
+ * UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
30948
+ * UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
30949
+ * ```
30950
+ *
30951
+ * So the value is one AWS raises with the table's traffic and never lowers,
30952
+ * and a decrease is REJECTED rather than accepted-and-ignored — which is what
30953
+ * had to be measured before choosing, because the two produce different
30954
+ * correct answers. The caller SKIPS the call on a true here.
30955
+ *
30956
+ * Evaluated on the COERCED spec, never on the raw bag: analysing the raw bag
30957
+ * makes the verdict describe a request that is not the one being sent (a
30958
+ * dropped member is not part of the call and must not be part of the
30959
+ * comparison).
30960
+ *
30961
+ * Semantics, all three chosen to FAIL OPEN — i.e. to let the call through and
30962
+ * leave AWS as the authority — because a false positive here silently drops a
30963
+ * legitimate INCREASE, which is a real capacity change the user asked for,
30964
+ * while a false negative merely reproduces the pre-fix behaviour of an
30965
+ * AWS-side rejection that names the property:
30966
+ * - DECLARED members only. An absent member is not a request to lower
30967
+ * anything, so it takes no part in the verdict.
30968
+ * - MIXED is not a decrease. One member below live and the other above means
30969
+ * the call carries a genuine increase; AWS decides.
30970
+ * - An absent or unusable LIVE counterpart is not a decrease. Without a
30971
+ * number to compare against there is no evidence of one. Only the LIVE side
30972
+ * can reach that arm: the desired side is a COERCED spec, so a malformed
30973
+ * template value has already been dropped by {@link coerceWarmThroughput}.
30974
+ *
30975
+ * A decrease therefore requires every declared member to be at-or-below live
30976
+ * AND at least one to be strictly below.
30977
+ *
30978
+ * The skip this drives is right for EVERY `update()` caller — the deploy
30979
+ * engine, `cdkd drift --revert`, and the rollback executor's two revert arms —
30980
+ * because none of them can make AWS lower the value, so none loses anything a
30981
+ * doomed call would have achieved.
30982
+ */
30983
+ function isWarmThroughputDecrease(desired, live) {
30984
+ if (desired === void 0 || live === void 0) return false;
30985
+ let sawDecrease = false;
30986
+ for (const member of WARM_THROUGHPUT_MEMBERS) {
30987
+ if (desired[member] === void 0) continue;
30988
+ const wanted = toFiniteNumber(desired[member]);
30989
+ const current = toFiniteNumber(live[member]);
30990
+ if (wanted === void 0 || current === void 0) return false;
30991
+ if (wanted > current) return false;
30992
+ if (wanted < current) sawDecrease = true;
30993
+ }
30994
+ return sawDecrease;
30995
+ }
30996
+
30097
30997
  //#endregion
30098
30998
  //#region src/provisioning/stateful-types.ts
30099
30999
  /**
@@ -30140,9 +31040,12 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
30140
31040
  * "never expire" — the most data-bearing configuration the type
30141
31041
  * has, and what `LogsLogGroupProvider` records `0` for — so reading
30142
31042
  * 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.
31043
+ * `cdkd deploy`. A `RetentionInDays > 0` recorded in EITHER of the
31044
+ * state record's property bags still answers `has-retention` from
31045
+ * the bags alone (a cheap positive, never probed away); every other
31046
+ * bag DEFERS, exactly as the bucket does. Which bags, and why the
31047
+ * value is coerced rather than type-tested, is issue [#2521] —
31048
+ * see {@link logGroupHasPositiveRetention}.
30146
31049
  * The pre-flight resolves the deferral with a live
30147
31050
  * `logs:DescribeLogStreams` probe (a log group with no stream can
30148
31051
  * hold no event, since every event belongs to a stream); mid-deploy,
@@ -30327,10 +31230,66 @@ const STATEFUL_TYPES = /* @__PURE__ */ new Set([
30327
31230
  */
30328
31231
  const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::DynamoDB::GlobalTable"]);
30329
31232
  /**
30330
- * Cheap, synchronous read of the resource's recorded properties only.
31233
+ * Does either recorded bag prove this log group carries a POSITIVE
31234
+ * `RetentionInDays`? (issue [#2521])
31235
+ *
31236
+ * Two decisions, and both were defects before this function existed.
31237
+ *
31238
+ * **The value is COERCED, not type-tested.** The predicate used to gate on
31239
+ * `typeof retention === 'number' && retention > 0`, and CloudFormation is
31240
+ * stringly typed: a hand-written L1, an `Fn::Sub`-produced value, a
31241
+ * `Type: String` parameter's default, or a record imported by
31242
+ * `cdkd import --migrate-from-cloudformation` all put `'30'` into the bag,
31243
+ * where the type test answers "not a number". {@link toFiniteNumber} is the
31244
+ * repo's single answer to "is this stringly-typed CFn value a number?" —
31245
+ * imported rather than re-spelled here for the reason its own doc gives, that
31246
+ * the same rule hand-written twice diverges on the next fix to one copy. It
31247
+ * rejects `''`, `' '`, `null`, `[]` and `false`, each of which a bare
31248
+ * `Number()` turns into a `0` that would read as never-expire.
31249
+ *
31250
+ * **BOTH bags are consulted, and a positive in EITHER wins.** The old read
31251
+ * saw `properties` alone, so a retention set OUT OF BAND (the console,
31252
+ * `aws logs put-retention-policy`, another tool) or one recorded only in
31253
+ * `observedProperties` — an imported record whose template never declared
31254
+ * the property — never produced `has-retention`.
31255
+ *
31256
+ * The rule is a plain OR, and the loop order below is INERT — a review round
31257
+ * caught an earlier version of this paragraph claiming `observedProperties`
31258
+ * is "read FIRST", a precedence the code does not implement and must not.
31259
+ * A strict "observed when present, else properties" rule — which is what
31260
+ * issue [#2521] literally prescribed — was tried and REJECTED:
31261
+ * `LogsLogGroupProvider.readCurrentState` writes `RetentionInDays: 0` for a
31262
+ * group with no retention policy, so the observed bag almost always CARRIES
31263
+ * the key, and precedence would make the recorded bag dead for every record
31264
+ * that has ever been captured and would DROP the `has-retention` verdict this
31265
+ * guard already produced. A test pins that rejection
31266
+ * (`tests/unit/provisioning/stateful-types.test.ts`, "reads a retention that
31267
+ * lives ONLY in properties, even against a ZERO observed one"). The OR is
31268
+ * also the strictly safer direction: it can only ADD refusals, never remove
31269
+ * one.
31270
+ *
31271
+ * What a `false` here means is DEFER, not "holds nothing" — see the callers.
31272
+ */
31273
+ function logGroupHasPositiveRetention(recordedProperties, observedProperties) {
31274
+ for (const bag of [observedProperties, recordedProperties]) {
31275
+ const retention = toFiniteNumber(bag?.["RetentionInDays"]);
31276
+ if (retention !== void 0 && retention > 0) return true;
31277
+ }
31278
+ return false;
31279
+ }
31280
+ /**
31281
+ * Cheap, synchronous read of the state record's own property bags only —
31282
+ * no AWS call. Both bags are parameters rather than one: `properties` is
31283
+ * what the last deploy applied, `observedProperties` what it read back,
31284
+ * and the log group's arm consults BOTH (issue [#2521]). Passing the
31285
+ * observed bag is REQUIRED, not optional, so a new call site has to
31286
+ * decide what it holds instead of silently repeating the omission that
31287
+ * issue records; `undefined` is the right answer where no record is in
31288
+ * hand (`recreate-confirm-prompt.ts`).
31289
+ *
30331
31290
  * 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
31291
+ * `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever neither
31292
+ * bag already proves `has-retention`. The live probes
30334
31293
  * that resolve both deferrals (`ListObjectVersions` for the bucket,
30335
31294
  * `DescribeLogStreams` for the log group) live in
30336
31295
  * `src/deployment/recreate-targets.ts#probeStatefulRecreateTargetsAsync`
@@ -30343,11 +31302,10 @@ const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::Dynam
30343
31302
  * Returns the {@link StatefulReason} when the type is stateful (or
30344
31303
  * `null` for non-stateful types).
30345
31304
  */
30346
- function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
31305
+ function isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties) {
30347
31306
  if (!STATEFUL_TYPES.has(resourceType)) return null;
30348
31307
  if (resourceType === "AWS::Logs::LogGroup") {
30349
- const retention = recordedProperties?.["RetentionInDays"];
30350
- if (typeof retention === "number" && retention > 0) return "has-retention";
31308
+ if (logGroupHasPositiveRetention(recordedProperties, observedProperties)) return "has-retention";
30351
31309
  return null;
30352
31310
  }
30353
31311
  if (resourceType === "AWS::S3::Bucket") return null;
@@ -30374,16 +31332,38 @@ function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
30374
31332
  * — be DELETE + CREATEd (data loss) without `--force-stateful-recreation`. To
30375
31333
  * stay fail-safe, both deferrals resolve to stateful here: the user must pass
30376
31334
  * `--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.
31335
+ * neither of whose recorded property bags already proves `has-retention`, on
31336
+ * any of those paths — empty or not.
31337
+ *
31338
+ * `UpdateReplacePolicy: Retain` is the standing EXEMPTION, and it covers all
31339
+ * three (issue [#2604]): the engine never consults this predicate under it —
31340
+ * the property-driven guard tests `updateReplacePolicy !== 'Retain'`
31341
+ * directly, and the fallback's two triggers short-circuit through
31342
+ * `retainOldOnReplace`, which issue [#2518] added. The old resource survives
31343
+ * the replacement, so there is no data loss to confirm, and the refusal's own
31344
+ * remedy would have destroyed exactly what the user asked to keep.
31345
+ * `Snapshot` is NOT exempt on any of them — a snapshot is a copy, not a
31346
+ * surviving resource. So read the paragraph above as scoped to a template
31347
+ * that is not retaining.
31348
+ *
31349
+ * The two engine sites are not the only callers: a THIRD sits outside those
31350
+ * paths and outside that exemption — `recreate-confirm-prompt.ts` re-derives
31351
+ * a `null` verdict for the `--recreate-via-*` pre-flight, where
31352
+ * `--force-stateful-recreation` is what skipped the probe.
31353
+ * `stateful-replace-message-doc-sync` pins the whole guard's reader list by
31354
+ * file, but the residuals comment above its own walk enumerates what that
31355
+ * cannot see — among them an ALIASED import, a `.mts` / `.cts` reader, and,
31356
+ * the one that bit, a NEW PATH routed through an existing call site, which
31357
+ * adds no file and reds nothing (issue [#2514]'s shape). So this enumeration
31358
+ * is maintained by hand.
30379
31359
  *
30380
31360
  * The log group's arm is the one issue [#2558] added, and the reason it is
30381
31361
  * needed is that the old predicate treated "no retention recorded" as "holds
30382
31362
  * nothing" when it is CloudWatch Logs' never-expire. Every other type matches
30383
31363
  * {@link isStatefulRecreateTargetSync} exactly.
30384
31364
  */
30385
- function isStatefulRecreateTargetForReplace(resourceType, recordedProperties) {
30386
- const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties);
31365
+ function isStatefulRecreateTargetForReplace(resourceType, recordedProperties, observedProperties) {
31366
+ const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties);
30387
31367
  if (sync) return sync;
30388
31368
  if (resourceType === "AWS::S3::Bucket") return "has-objects";
30389
31369
  if (resourceType === "AWS::Logs::LogGroup") return "has-log-events";
@@ -31545,6 +32525,84 @@ function rollbackFinalSnapshotId(resourceType, record, fallbackProvisionedBy) {
31545
32525
  return buildFinalSnapshotIdentifier(record.physicalId, resourceType);
31546
32526
  }
31547
32527
  /**
32528
+ * `UpdateReplacePolicy: Retain` on the resource a replacement CREATED — the
32529
+ * copy a rollback would otherwise destroy (issue
32530
+ * [#2598](https://github.com/go-to-k/cdkd/issues/2598)).
32531
+ *
32532
+ * Reads the CURRENT record, i.e. the one the replacing deploy wrote from the
32533
+ * template it was applying (`extractTemplateAttributes`), so the attribute
32534
+ * consulted is the one that was in force when the new copy was created. Its
32535
+ * `Snapshot` sibling, {@link rollbackFinalSnapshotId}, reads the same field of
32536
+ * the same record — `Retain` and `Snapshot` are alternative values of ONE
32537
+ * attribute, so the two can never both apply.
32538
+ *
32539
+ * **`UpdateReplacePolicy`, NOT `DeletionPolicy`, and that is measured, not
32540
+ * reasoned.** The repo refuses a CloudFormation-parity claim taken on
32541
+ * folklore, and the AWS documentation answers nothing here: every sentence on
32542
+ * both attribute pages, in the API reference and in the release notes
32543
+ * describes the OLD resource, never the new copy's fate during a rollback. A
32544
+ * live four-variant A/B (2026-09-05, us-east-1: a forced `AWS::SSM::Parameter`
32545
+ * replacement plus a deterministically failing sibling, rolled back) settled
32546
+ * it:
32547
+ *
32548
+ * | DeletionPolicy | UpdateReplacePolicy | new copy | decisive event |
32549
+ * | -------------- | ------------------- | --------- | ---------------- |
32550
+ * | (none) | (none) | DELETED | `DELETE_COMPLETE` |
32551
+ * | Retain | (none) | DELETED | `DELETE_COMPLETE` |
32552
+ * | (none) | Retain | SURVIVED | `DELETE_SKIPPED` |
32553
+ * | Retain | Retain | SURVIVED | `DELETE_SKIPPED` |
32554
+ *
32555
+ * Row 2 alone refutes "`DeletionPolicy` governs it"; row 3 alone refutes
32556
+ * "neither — always deleted". The old copy was restored intact in all four,
32557
+ * and both outcomes land in `UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS`.
32558
+ *
32559
+ * A retained new copy is ORPHANED OUT of the stack, not kept as a managed
32560
+ * resource — the A/B proved it by deleting the whole stack afterwards and
32561
+ * finding the retained parameter still alive. So every caller below leaves NO
32562
+ * state record naming the survivor: the two arms that can complete point state
32563
+ * at the old resource exactly as they already did, and the survivor becomes
32564
+ * untracked. That is the same disposition the deploy engine gives a
32565
+ * `Retain`-orphaned OLD resource, so the two directions agree.
32566
+ *
32567
+ * LIMIT OF THAT EVIDENCE, stated so a later reader does not over-read it: all
32568
+ * four variants carried the SAME policy in both template versions, so the A/B
32569
+ * pinned WHICH ATTRIBUTE wins and did NOT discriminate which template's copy
32570
+ * of it is read. This function reads the current record for the reasons above
32571
+ * (it is the one the new copy was created under, and it matches the
32572
+ * `Snapshot` sibling and both CREATE-rollback arms), not because the A/B
32573
+ * settled that question.
32574
+ */
32575
+ function rollbackRetainsNewResource(record) {
32576
+ return record?.updateReplacePolicy === "Retain";
32577
+ }
32578
+ /**
32579
+ * The two sentences a `UpdateReplacePolicy: Retain` survivor needs: the `⚠`
32580
+ * terminal warning and the compact `reason` that rides on the durable
32581
+ * `ROLLBACK_RESOURCE_SUCCEEDED` event.
32582
+ *
32583
+ * ONE function because the two must not drift apart. Both replacement-rollback
32584
+ * retain arms produced these by hand, four near-identical copies, and the
32585
+ * failure mode a reviewer named is precise: the warn and the DURABLE record
32586
+ * disagreeing about which id survived. Deriving both from one set of inputs
32587
+ * makes that unrepresentable. The shapes stay deliberately different -- the
32588
+ * warn carries the cost/`cdkd destroy` guidance a human reads once, the reason
32589
+ * stays compact for a `--json` consumer -- so this is one input set, not one
32590
+ * string.
32591
+ *
32592
+ * `stateClause` is the only thing that differs between the two arms (the
32593
+ * readopt arm restores the old id; the create-first arm records a re-created
32594
+ * one), so it is a parameter rather than a branch in here.
32595
+ *
32596
+ * NOT used by the delete-failed survivor a few lines down: that one is an
32597
+ * orphan by OUTCOME rather than by policy, and says so.
32598
+ */
32599
+ function retainedSurvivorMessages(logicalId, resourceType, survivorPhysicalId, stateClause) {
32600
+ return {
32601
+ 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}`,
32602
+ reason: `UpdateReplacePolicy: Retain kept the replacement's new ${resourceType} (${survivorPhysicalId}); it is live, still billing, and no longer tracked by cdkd. ${stateClause}`
32603
+ };
32604
+ }
32605
+ /**
31548
32606
  * `DeletionPolicy: Snapshot` on a rolled-back CREATE (issue #1358) — the
31549
32607
  * executor's copy of the deploy engine's `prepareFinalSnapshotForDelete`
31550
32608
  * mechanism matrix, run BEFORE the delete. Shared with the FAILED in-flight
@@ -31643,7 +32701,7 @@ function classifyRollbackOp(op, stateResources, orphanLogicalIds) {
31643
32701
  if (deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
31644
32702
  return "skip-mismatch";
31645
32703
  }
31646
- return op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
32704
+ return op.oldResourceRetained ?? op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
31647
32705
  }
31648
32706
  if (op.previousState && deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
31649
32707
  return "revert";
@@ -31683,12 +32741,16 @@ function planFailedOps(failedOps, stateResources) {
31683
32741
  */
31684
32742
  function planRollback(operations, stateResources, orphanLogicalIds = /* @__PURE__ */ new Set()) {
31685
32743
  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
- }));
32744
+ return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) => {
32745
+ const action = classifyRollbackOp(op, stateResources, orphanLogicalIds);
32746
+ return {
32747
+ op,
32748
+ action,
32749
+ replacement: isReplacementOp(op),
32750
+ effectiveProvisionedBy: effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy),
32751
+ retainsNewResource: (action === "reverse-replacement" || action === "reverse-replacement-readopt") && rollbackRetainsNewResource(stateResources[op.logicalId])
32752
+ };
32753
+ });
31692
32754
  }
31693
32755
  function partitionOps(operations) {
31694
32756
  const createOps = [];
@@ -32046,11 +33108,32 @@ async function resolveLeafByRegion(leaf, propertyPath, logicalId, execCtx, resol
32046
33108
  * writer is one of only two that can honestly claim it.
32047
33109
  *
32048
33110
  * 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.
33111
+ * quiet direction: it turns BLIND positional array descent off. BLIND is
33112
+ * load-bearing, and an earlier revision of this paragraph omitted it — the
33113
+ * concrete loss is narrower than "positional descent is off" makes it sound,
33114
+ * by TWO mechanisms rather than one:
33115
+ *
33116
+ * - Since issue #1915 a `Tags[]` / ECS `Environment[]` element is reached by
33117
+ * the order-independent KEYED descent either way.
33118
+ * - Since issue #2012 an UNKEYED list is reached too, under corroboration.
33119
+ * That is not a general relaxation: swapping this constant in would satisfy
33120
+ * all three conjuncts of `isReadbackProjectedFromState`
33121
+ * (`trustAnyExpression && !descendArrays && sourceIsSameGeneration`), which
33122
+ * ARMS `refuseUncertifiedReadbackPositions`, and its unkeyed arm walks
33123
+ * element i against element i whenever `unkeyedArrayPairsByAnchors`
33124
+ * corroborates the alignment (index counts match; every position whose
33125
+ * SOURCE subtree carries no dynamic reference is deep-equal on both sides;
33126
+ * every reference-bearing element carries a distinguishing anchor of its own
33127
+ * or, being a bare reference leaf, leans on the array's literal frame; and
33128
+ * no two reference-bearing elements share an order-insensitive anchor
33129
+ * signature).
33130
+ *
33131
+ * So the residual loss is narrower again: an unkeyed list whose positions ALSO
33132
+ * fail to corroborate. The CONCLUSION is unchanged — `STATE_DERIVED_RULES` is
33133
+ * still right here, for the reason one paragraph up (the bag was produced by
33134
+ * resolving the source, so the two correspond positionally by construction and
33135
+ * need no corroboration to say so). What changes is only how much a reader
33136
+ * should think the alternative costs (issue #2691).
32054
33137
  *
32055
33138
  * No-op when the op resolved no secret.
32056
33139
  */
@@ -32260,7 +33343,8 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32260
33343
  return;
32261
33344
  case "orphan-flag":
32262
33345
  if (op.changeType === "CREATE") {
32263
- const orphanFlagProvisionedBy = effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy);
33346
+ const record = stateResources[op.logicalId];
33347
+ const orphanFlagProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
32264
33348
  createRollbackRoute = orphanFlagProvisionedBy;
32265
33349
  delete stateResources[op.logicalId];
32266
33350
  logger.info(` Rollback: Orphaning created resource ${op.logicalId} (--orphan)`);
@@ -32271,12 +33355,17 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32271
33355
  operation: "CREATE",
32272
33356
  logicalId: op.logicalId,
32273
33357
  resourceType: op.resourceType,
32274
- ...orphanFlagProvisionedBy && { provisionedBy: orphanFlagProvisionedBy }
33358
+ ...orphanFlagProvisionedBy && { provisionedBy: orphanFlagProvisionedBy },
33359
+ ...record?.physicalId && {
33360
+ physicalId: record.physicalId,
33361
+ 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.`
33362
+ }
32275
33363
  });
32276
33364
  } else logger.info(` Rollback: Leaving ${op.logicalId} at its new state (--orphan)`);
32277
33365
  return;
32278
33366
  case "orphan-retain": {
32279
- const orphanProvisionedBy = effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy);
33367
+ const record = stateResources[op.logicalId];
33368
+ const orphanProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
32280
33369
  createRollbackRoute = orphanProvisionedBy;
32281
33370
  delete stateResources[op.logicalId];
32282
33371
  logger.info(` Rollback: Leaving ${op.logicalId} (${op.resourceType}) in AWS (DeletionPolicy: Retain) — removed from state`);
@@ -32287,7 +33376,11 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32287
33376
  operation: "CREATE",
32288
33377
  logicalId: op.logicalId,
32289
33378
  resourceType: op.resourceType,
32290
- ...orphanProvisionedBy && { provisionedBy: orphanProvisionedBy }
33379
+ ...orphanProvisionedBy && { provisionedBy: orphanProvisionedBy },
33380
+ ...record?.physicalId && {
33381
+ physicalId: record.physicalId,
33382
+ 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.`
33383
+ }
32291
33384
  });
32292
33385
  return;
32293
33386
  }
@@ -32330,11 +33423,32 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32330
33423
  const current = stateResources[op.logicalId];
32331
33424
  const prev = op.previousState;
32332
33425
  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
- {
33426
+ /**
33427
+ * Set when this arm ORPHANS the replacement's new copy. Read at the
33428
+ * `ROLLBACK_RESOURCE_SUCCEEDED` event below, which is the only channel
33429
+ * that OUTLIVES the terminal (security review of issue #2598): a
33430
+ * rollback runs during an already-failing deploy, often non-TTY with
33431
+ * the log truncated or discarded, so a `logger.warn` is the least
33432
+ * likely thing the user still has. Without this the survivor's id dies
33433
+ * with the terminal -- `cdkd events` shows a clean success and state
33434
+ * names only the OLD resource, while a live, billing, untracked copy
33435
+ * remains. `Retain` is precisely the marker users put on data-bearing
33436
+ * resources, so that is the worst population to lose the id for.
33437
+ *
33438
+ * Same shape as the `rollbackPartial` survivor record ~700 lines down
33439
+ * and as the deploy engine's `RESOURCE_SKIPPED` twin.
33440
+ */
33441
+ let survivorReason;
33442
+ if (rollbackRetainsNewResource(current)) {
33443
+ const survivorMessages = retainedSurvivorMessages(op.logicalId, op.resourceType, current.physicalId, `State is restored to the old resource (${prev.physicalId}).`);
33444
+ logger.warn(survivorMessages.warn);
33445
+ survivorReason = survivorMessages.reason;
33446
+ result.warnings++;
33447
+ } else {
33448
+ const { provider: newDeleteProvider } = ctx.providerRegistry.getProviderFor({
33449
+ resourceType: op.resourceType,
33450
+ provisionedBy: current.provisionedBy ?? op.provisionedBy
33451
+ });
32338
33452
  const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
32339
33453
  throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
32340
33454
  expectedRegion: ctx.region,
@@ -32344,13 +33458,19 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32344
33458
  stateResources[op.logicalId] = prev;
32345
33459
  logger.info(` Rollback: ${op.logicalId} restored to the retained old resource`);
32346
33460
  await afterOp?.(op.logicalId);
33461
+ const survivorProvisionedBy = current.provisionedBy;
32347
33462
  ctx.recordEvent?.({
32348
33463
  eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
32349
33464
  stackName,
32350
33465
  operation: "UPDATE",
32351
33466
  logicalId: op.logicalId,
32352
33467
  resourceType: op.resourceType,
32353
- ...op.provisionedBy && { provisionedBy: op.provisionedBy }
33468
+ ...op.provisionedBy && { provisionedBy: op.provisionedBy },
33469
+ ...survivorReason !== void 0 && {
33470
+ physicalId: current.physicalId,
33471
+ reason: maskSecretsInText(survivorReason, secrets),
33472
+ ...survivorProvisionedBy && { provisionedBy: survivorProvisionedBy }
33473
+ }
32354
33474
  });
32355
33475
  return;
32356
33476
  }
@@ -32366,10 +33486,10 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32366
33486
  resourceType: op.resourceType,
32367
33487
  provisionedBy: prev.provisionedBy
32368
33488
  });
32369
- const { provider: newDeleteProvider } = ctx.providerRegistry.getProviderFor({
33489
+ const resolveNewDeleteProvider = () => ctx.providerRegistry.getProviderFor({
32370
33490
  resourceType: op.resourceType,
32371
33491
  provisionedBy: current.provisionedBy ?? op.provisionedBy
32372
- });
33492
+ }).provider;
32373
33493
  let deletedNewFirst = false;
32374
33494
  let createResult;
32375
33495
  try {
@@ -32378,11 +33498,13 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32378
33498
  interruptedMessage: "Rollback interrupted while waiting out the name cooldown"
32379
33499
  });
32380
33500
  } catch (createError) {
32381
- if (!isNameCollisionError(createError instanceof Error ? createError.message : String(createError))) throw createError;
33501
+ const msg = createError instanceof Error ? createError.message : String(createError);
33502
+ if (!isNameCollisionError(msg)) throw createError;
33503
+ 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
33504
  logger.info(` Rollback: re-create collided with the new resource's name — deleting the new resource (${current.physicalId}) first...`);
32383
33505
  {
32384
33506
  const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
32385
- throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
33507
+ throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
32386
33508
  expectedRegion: ctx.region,
32387
33509
  ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
32388
33510
  }), op.logicalId, current.physicalId, "while clearing the new resource so the old one could be re-created");
@@ -32396,7 +33518,7 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32396
33518
  interruptedMessage: "Rollback interrupted while waiting for the old name to release"
32397
33519
  });
32398
33520
  } 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));
33521
+ 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
33522
  }
32401
33523
  }
32402
33524
  const adoptedLiveNewResource = !deletedNewFirst && createResult.physicalId === current.physicalId;
@@ -32412,24 +33534,37 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
32412
33534
  properties: recordedPropertiesAfterReplayCreate(prevRecord, createResult)
32413
33535
  }, secrets, prevRecord.properties);
32414
33536
  await afterOp?.(op.logicalId);
32415
- if (!deletedNewFirst && !adoptedLiveNewResource) try {
33537
+ let survivorReason;
33538
+ if (!deletedNewFirst && !adoptedLiveNewResource && rollbackRetainsNewResource(current)) {
33539
+ const survivorMessages = retainedSurvivorMessages(op.logicalId, op.resourceType, current.physicalId, `State records the re-created old resource (${stateResources[op.logicalId]?.physicalId ?? prev.physicalId}).`);
33540
+ logger.warn(survivorMessages.warn);
33541
+ survivorReason = survivorMessages.reason;
33542
+ result.warnings++;
33543
+ } else if (!deletedNewFirst && !adoptedLiveNewResource) try {
32416
33544
  const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
32417
- throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
33545
+ throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
32418
33546
  expectedRegion: ctx.region,
32419
33547
  ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
32420
33548
  }), op.logicalId, current.physicalId, "while deleting the new resource after re-creating the old one");
32421
33549
  } catch (deleteError) {
32422
33550
  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));
33551
+ 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
33552
  result.warnings++;
32424
33553
  }
32425
33554
  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})`);
33555
+ const survivorProvisionedBy = current.provisionedBy;
32426
33556
  ctx.recordEvent?.({
32427
33557
  eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
32428
33558
  stackName,
32429
33559
  operation: "UPDATE",
32430
33560
  logicalId: op.logicalId,
32431
33561
  resourceType: op.resourceType,
32432
- ...op.provisionedBy && { provisionedBy: op.provisionedBy }
33562
+ ...op.provisionedBy && { provisionedBy: op.provisionedBy },
33563
+ ...survivorReason !== void 0 && {
33564
+ physicalId: current.physicalId,
33565
+ reason: maskSecretsInText(survivorReason, secrets),
33566
+ ...survivorProvisionedBy && { provisionedBy: survivorProvisionedBy }
33567
+ }
32433
33568
  });
32434
33569
  return;
32435
33570
  }
@@ -33118,7 +34253,10 @@ var DeploymentEventsReader = class {
33118
34253
  * window and rewrites (or removes) `index.json` to match.
33119
34254
  *
33120
34255
  * Retention semantics (see {@link DeploymentEventsPruneOptions}):
33121
- * - `all` — delete every run + the index (full purge).
34256
+ * - `all` — delete every run + the index. Clears the LISTING only:
34257
+ * this deletes by key with no `VersionId`, so on a
34258
+ * versioned bucket every earlier version survives
34259
+ * (issue #2624).
33122
34260
  * - `keep N` — retain the newest N runs, delete the rest.
33123
34261
  * - `olderThanMs`— delete runs whose run-id timestamp is older than the
33124
34262
  * cutoff; a run id with no parseable timestamp is kept.
@@ -33238,31 +34376,15 @@ var InterruptedError = class extends Error {
33238
34376
  this.name = "InterruptedError";
33239
34377
  }
33240
34378
  };
33241
- /**
33242
- * Best-effort routing inference for the live-progress task label
33243
- * (#614 §9). Mirrors the routing decision tree but is purely cosmetic:
33244
- * errors here never surface — when the inference fails we return
33245
- * `undefined` and the label gets no `[CC API]` tag. The real
33246
- * `getProviderFor` call inside the deploy/destroy critical path is the
33247
- * load-bearing dispatch.
33248
- *
33249
- * Inputs:
33250
- * - CREATE / UPDATE → template-side `desiredProperties` (top-level CFn
33251
- * property names; intrinsic resolution does not change those, so we
33252
- * can route ahead of the resolver run).
33253
- * - DELETE → sticky `provisionedBy` from the existing-state record.
33254
- *
33255
- * Exported so {@link DeployEngine.peekRoutingForLabel} stays a 1-line
33256
- * delegate and the routing-inference logic is directly unit-testable
33257
- * without standing up a full DeployEngine harness.
33258
- */
33259
- function deriveLabelRouting(change, existingState, registry) {
34379
+ function deriveLabelRouting(change, existingState, registry, forceCcApi = false) {
33260
34380
  try {
33261
34381
  if (change.changeType === "DELETE") return existingState?.provisionedBy;
33262
34382
  return registry.getProviderFor({
33263
34383
  resourceType: change.resourceType,
33264
- properties: change.desiredProperties,
33265
- provisionedBy: existingState?.provisionedBy
34384
+ properties: change.desiredProperties ?? {},
34385
+ provisionedBy: existingState?.provisionedBy,
34386
+ previousProperties: existingState?.properties,
34387
+ forceCcApi
33266
34388
  }).provisionedBy;
33267
34389
  } catch {
33268
34390
  return;
@@ -33533,6 +34655,39 @@ var DeployEngine = class {
33533
34655
  */
33534
34656
  attemptedResolvedProps = /* @__PURE__ */ new Map();
33535
34657
  /**
34658
+ * Logical ids whose replacement this deploy DELIBERATELY left the old
34659
+ * physical resource alive for — `UpdateReplacePolicy: Retain` (issue
34660
+ * [#2603](https://github.com/go-to-k/cdkd/issues/2603)).
34661
+ *
34662
+ * Written by every engine path that skips the post-replacement delete, read
34663
+ * once at the `completedOperations.push` site to stamp
34664
+ * {@link CompletedOperation.oldResourceRetained}. It exists because the
34665
+ * rollback classifier used to re-derive the verdict from
34666
+ * `previousState.updateReplacePolicy` — a DIFFERENT source than the
34667
+ * template read the engine decides from — so the two disagreed on exactly
34668
+ * the deploy that changes the attribute, in both directions:
34669
+ *
34670
+ * - ADDING `Retain`: the deploy orphans the old resource while the
34671
+ * previous state record carries no policy, so the rollback re-CREATED a
34672
+ * resource that is still alive (a duplicate for an auto-named type, an
34673
+ * `AlreadyExists` failure for a user-named one).
34674
+ * - DROPPING `Retain`: state still carries the stale `Retain`, the
34675
+ * template omits it so the deploy correctly DELETES the old resource,
34676
+ * and the rollback then re-adopted a physical id that no longer exists,
34677
+ * leaving state naming a deleted resource.
34678
+ *
34679
+ * Records only the DELIBERATE retention. A best-effort cleanup delete that
34680
+ * FAILED, was SKIPPED, or was blocked by a failed final snapshot also leaves
34681
+ * the old resource alive, but the deploy does not KNOW it survived — those
34682
+ * stay `false` and keep today's `reverse-replacement` behaviour rather than
34683
+ * having the rollback re-adopt an id it cannot vouch for (issue
34684
+ * [#2631](https://github.com/go-to-k/cdkd/issues/2631)).
34685
+ *
34686
+ * Cleared per `deploy()` alongside the other per-run maps: a `false` here
34687
+ * must mean "this deploy deleted it", never "a previous run said so".
34688
+ */
34689
+ retainedOldOnReplacement = /* @__PURE__ */ new Set();
34690
+ /**
33536
34691
  * Target region for this stack. Required — load-bearing for the
33537
34692
  * region-prefixed S3 state key and recorded in state.json for
33538
34693
  * cross-region destroy.
@@ -33571,6 +34726,7 @@ var DeployEngine = class {
33571
34726
  this.outputSecrets = /* @__PURE__ */ new Map();
33572
34727
  this.outputsTemplateSource = {};
33573
34728
  this.outputsSourceUsable = true;
34729
+ this.retainedOldOnReplacement = /* @__PURE__ */ new Set();
33574
34730
  this.resolver.resetPhysicalIdFallbackCount();
33575
34731
  return withStackName(stackName, () => this.doDeploy(stackName, template));
33576
34732
  }
@@ -33992,7 +35148,10 @@ var DeployEngine = class {
33992
35148
  for (const { logicalId, resource } of candidates) {
33993
35149
  let provider;
33994
35150
  try {
33995
- provider = this.providerRegistry.getProvider(resource.resourceType);
35151
+ provider = this.providerRegistry.getProviderFor({
35152
+ resourceType: resource.resourceType,
35153
+ provisionedBy: resource.provisionedBy
35154
+ }).provider;
33996
35155
  } catch {
33997
35156
  continue;
33998
35157
  }
@@ -34084,6 +35243,11 @@ var DeployEngine = class {
34084
35243
  diffResolverContext.skipDynamicReferences = true;
34085
35244
  const diffResolveFn = (value) => this.resolver.resolve(value, diffResolverContext);
34086
35245
  const changes = await this.diffCalculator.calculateDiff(currentState, effectiveTemplate, diffResolveFn, makeCanonicalizePropertiesFn(this.providerRegistry));
35246
+ const nestedStackTypeChanges = findNestedStackTypeChanges({
35247
+ changes,
35248
+ stateResources: currentState.resources
35249
+ });
35250
+ if (nestedStackTypeChanges.length > 0) throw markNonRetryable(new CdkdError(renderNestedStackTypeChangeRefusal(nestedStackTypeChanges, stackName), "TYPE_CHANGE_NESTED_STACK"));
34087
35251
  if (!this.diffCalculator.hasChanges(changes)) {
34088
35252
  this.logger.info("No changes detected. Stack is up to date.");
34089
35253
  let persistedOutputs = currentState.outputs ?? {};
@@ -34293,7 +35457,8 @@ var DeployEngine = class {
34293
35457
  provisionedBy: newResources[logicalId]?.provisionedBy ?? previousState?.provisionedBy,
34294
35458
  previousState,
34295
35459
  physicalId: newResources[logicalId]?.physicalId,
34296
- properties: newResources[logicalId]?.properties
35460
+ properties: newResources[logicalId]?.properties,
35461
+ ...change.changeType === "UPDATE" && { oldResourceRetained: this.retainedOldOnReplacement.has(logicalId) }
34297
35462
  });
34298
35463
  saveStateAfterResource(logicalId);
34299
35464
  }, () => this.interrupted);
@@ -34611,9 +35776,10 @@ var DeployEngine = class {
34611
35776
  async provisionResource(logicalId, change, stateResources, stackName, template, parameterValues, conditions, counts, progress) {
34612
35777
  const resourceType = change.resourceType;
34613
35778
  const renderer = getLiveRenderer();
34614
- const needsReplacement = change.changeType === "UPDATE" && (change.propertyChanges?.some((pc) => pc.requiresReplacement) ?? false);
35779
+ const labelRecreateDirection = this.recreateDirectionFor(stackName, logicalId);
35780
+ const needsReplacement = change.changeType === "UPDATE" && (change.propertyChanges?.some((pc) => pc.requiresReplacement) ?? false) || labelRecreateDirection !== void 0;
34615
35781
  const verb = change.changeType === "CREATE" ? "Creating" : change.changeType === "DELETE" ? "Deleting" : needsReplacement ? "Replacing" : "Updating";
34616
- const labelRouting = this.peekRoutingForLabel(change, stateResources[logicalId]);
35782
+ const labelRouting = this.peekRoutingForLabel(change, stateResources[logicalId], stackName, logicalId, needsReplacement, labelRecreateDirection);
34617
35783
  const baseLabel = `${verb} ${logicalId} (${resourceType})${labelRouting === "cc-api" ? " [CC API]" : ""}`;
34618
35784
  renderer.addTask(logicalId, baseLabel);
34619
35785
  const operationKind = change.changeType === "CREATE" ? "CREATE" : change.changeType === "DELETE" ? "DELETE" : "UPDATE";
@@ -34708,8 +35874,51 @@ var DeployEngine = class {
34708
35874
  renderer.removeTask(logicalId);
34709
35875
  }
34710
35876
  }
34711
- peekRoutingForLabel(change, existingState) {
34712
- return deriveLabelRouting(change, existingState, this.providerRegistry);
35877
+ /**
35878
+ * Is this resource pinned to Cloud Control for this deploy (`--pin-cc-api`)?
35879
+ *
35880
+ * ONE implementation, called by the update dispatch and by the progress
35881
+ * label. They carried separate copies of this expression for one revision,
35882
+ * and a mutation probe caught the predictable result: neutering the LABEL's
35883
+ * copy left every test green, because the only cases that existed exercised
35884
+ * the dispatch's. Same shape as the duplicated flip predicate this lane
35885
+ * already collapsed once.
35886
+ *
35887
+ * SCOPED BY STACK, like `recreateTargets`. `NestedStackProvider.runChildDeploy`
35888
+ * spreads the parent's options into every child engine, and a logical id is
35889
+ * unique only within one template, so an unscoped set would pin a same-named
35890
+ * resource in a stack the user never named — silently, since a pin produces
35891
+ * no output of its own.
35892
+ */
35893
+ isPinnedToCcApi(stackName, logicalId) {
35894
+ return this.options.pinCcApi?.stackName === stackName && this.options.pinCcApi.logicalIds.has(logicalId);
35895
+ }
35896
+ /**
35897
+ * The `--recreate-via-*` direction for this resource, or `undefined`.
35898
+ *
35899
+ * Stack-scoped for the same reason as {@link isPinnedToCcApi}, and extracted
35900
+ * for a sharper one: the LABEL and the DISPATCH were computing "is this a
35901
+ * replacement" from DIFFERENT expressions. The dispatch asks
35902
+ * `propertyDrivenReplacement || recreateFlagged`; the label asked only the
35903
+ * property half. So a `--recreate-via-*` target whose property change does
35904
+ * not itself force a replacement took the label's non-replacement path and
35905
+ * was routed from the state record, while the dispatch routed it from the
35906
+ * flag -- mislabelling in BOTH directions, and rendering `Updating` over a
35907
+ * destroy + recreate.
35908
+ *
35909
+ * Three review rounds fixed three instances of that one class (the pin, then
35910
+ * the sticky inputs, then this) by subtracting one input at a time from the
35911
+ * label. The class closes by asking the same QUESTION at both sites instead.
35912
+ */
35913
+ recreateDirectionFor(stackName, logicalId) {
35914
+ const targets = this.options.recreateTargets?.stackName === stackName ? this.options.recreateTargets : void 0;
35915
+ if (targets === void 0) return void 0;
35916
+ if (targets.viaCcApi.has(logicalId)) return "cc-api";
35917
+ if (targets.viaSdkProvider.has(logicalId)) return "sdk";
35918
+ }
35919
+ peekRoutingForLabel(change, existingState, stackName, logicalId, needsReplacement = false, recreateDirection) {
35920
+ if (needsReplacement) return deriveLabelRouting(change, recreateDirection === void 0 ? void 0 : { provisionedBy: recreateDirection }, this.providerRegistry, recreateDirection === "cc-api");
35921
+ return deriveLabelRouting(change, existingState, this.providerRegistry, this.isPinnedToCcApi(stackName, logicalId));
34713
35922
  }
34714
35923
  /**
34715
35924
  * #808 — forward one structured deployment event to the optional
@@ -34820,7 +36029,7 @@ var DeployEngine = class {
34820
36029
  isRetryable: isRecreateRetryableError
34821
36030
  });
34822
36031
  } 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));
36032
+ 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
36033
  }
34825
36034
  }
34826
36035
  /**
@@ -34917,15 +36126,16 @@ var DeployEngine = class {
34917
36126
  break;
34918
36127
  }
34919
36128
  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;
36129
+ const recreateTargets = this.options.recreateTargets?.stackName === stackName ? this.options.recreateTargets : void 0;
36130
+ const recreateViaCcApi = recreateTargets?.viaCcApi.has(logicalId) ?? false;
36131
+ const recreateViaSdkProvider = recreateTargets?.viaSdkProvider.has(logicalId) ?? false;
34922
36132
  const recreateFlagged = recreateViaCcApi || recreateViaSdkProvider;
34923
36133
  const needsReplacement = propertyDrivenReplacement || recreateFlagged;
34924
36134
  const dependencies = this.extractAllDependencies(template, logicalId);
34925
36135
  const updateReplacePolicy = template?.Resources?.[logicalId]?.UpdateReplacePolicy;
34926
36136
  if (needsReplacement) {
34927
36137
  if (propertyDrivenReplacement && !recreateFlagged && updateReplacePolicy !== "Retain") {
34928
- const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps);
36138
+ const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
34929
36139
  if (statefulReason && this.options.forceStatefulRecreation !== true) {
34930
36140
  const immutableProps = change.propertyChanges?.filter((pc) => pc.requiresReplacement).map((pc) => pc.path).join(", ");
34931
36141
  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"));
@@ -34940,7 +36150,8 @@ var DeployEngine = class {
34940
36150
  const replaceDecision = this.providerRegistry.getProviderFor({
34941
36151
  resourceType,
34942
36152
  properties: resolvedProps,
34943
- ...recreateDirectionHint && { provisionedBy: recreateDirectionHint }
36153
+ ...recreateDirectionHint && { provisionedBy: recreateDirectionHint },
36154
+ ...recreateViaCcApi && { forceCcApi: true }
34944
36155
  });
34945
36156
  const replaceProvider = replaceDecision.provider;
34946
36157
  const replaceProps = replaceDecision.provisionedBy === "cc-api" ? this.preparePropertiesForCcApi(resourceType, resolvedProps, logicalId) : resolvedProps;
@@ -34951,8 +36162,10 @@ var DeployEngine = class {
34951
36162
  let createResult;
34952
36163
  if (recreateFlagged) {
34953
36164
  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 {
36165
+ if (updateReplacePolicy === "Retain") {
36166
+ this.retainedOldOnReplacement.add(logicalId);
36167
+ 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.`);
36168
+ } else {
34956
36169
  this.logger.info(` Destroying old ${logicalId} (${currentResource.physicalId}) before recreate...`);
34957
36170
  const recreateFinalSnapshotId = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, updateReplacePolicy);
34958
36171
  let recreateDeleteResult;
@@ -35003,8 +36216,10 @@ var DeployEngine = class {
35003
36216
  deletedOldFirst = true;
35004
36217
  createResult = await this.replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps, updateSecrets, updateReplacePolicy);
35005
36218
  }
35006
- if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain") this.logger.info(` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`);
35007
- else {
36219
+ if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain") {
36220
+ this.retainedOldOnReplacement.add(logicalId);
36221
+ this.logger.info(` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`);
36222
+ } else {
35008
36223
  this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
35009
36224
  let cleanupFinalSnapshotId;
35010
36225
  let snapshotBlockedDelete = false;
@@ -35055,12 +36270,30 @@ var DeployEngine = class {
35055
36270
  const updateDecision = this.providerRegistry.getProviderFor({
35056
36271
  resourceType,
35057
36272
  properties: resolvedProps,
35058
- provisionedBy: currentResource.provisionedBy
36273
+ provisionedBy: currentResource.provisionedBy,
36274
+ previousProperties: currentResource.properties,
36275
+ ...this.isPinnedToCcApi(stackName, logicalId) && { forceCcApi: true }
35059
36276
  });
36277
+ if (updateDecision.sdkMigration === true) {
36278
+ const exemptMode = STICKY_CC_MIGRATION_EXEMPT.get(resourceType)?.mode;
36279
+ const preserved = "The physical id is preserved";
36280
+ let message;
36281
+ switch (exemptMode) {
36282
+ case "cc-broken":
36283
+ message = `${logicalId} (${resourceType}): moving to the SDK provider — Cloud Control cannot manage this type correctly. ${preserved}, and this routing is not optional.`;
36284
+ break;
36285
+ case "sdk-coverage":
36286
+ message = `${logicalId} (${resourceType}): returning to the SDK provider — cdkd now covers every property this resource uses. ${preserved}; pass --pin-cc-api ${logicalId} to decline this for a deploy.`;
36287
+ break;
36288
+ default: message = `${logicalId} (${resourceType}): moving to the SDK provider. ${preserved}.`;
36289
+ }
36290
+ this.logger.info(message);
36291
+ }
35060
36292
  const updateProvider = updateDecision.provider;
35061
36293
  const updateProps = updateDecision.provisionedBy === "cc-api" ? this.preparePropertiesForCcApi(resourceType, resolvedProps, logicalId) : resolvedProps;
35062
36294
  let result;
35063
36295
  let resultProvisionedBy = updateDecision.provisionedBy;
36296
+ let captureProvider = updateProvider;
35064
36297
  try {
35065
36298
  result = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => updateProvider.update(logicalId, currentResource.physicalId, resourceType, updateProps, currentProps, {
35066
36299
  maskSecrets: createSecretMasker(updateSecrets),
@@ -35071,7 +36304,7 @@ var DeployEngine = class {
35071
36304
  const replaceOptIn = updateError instanceof ResourceUpdateNotSupportedError && this.options.replace === true;
35072
36305
  if (ccUnsupported || replaceOptIn) {
35073
36306
  const retainOldOnReplace = updateReplacePolicy === "Retain";
35074
- const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps);
36307
+ const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
35075
36308
  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
36309
  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
36310
  if (!retainOldOnReplace) {
@@ -35102,7 +36335,7 @@ var DeployEngine = class {
35102
36335
  try {
35103
36336
  createResult = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => replProvider.create(logicalId, resourceType, replProps, { maskSecrets: createSecretMasker(updateSecrets) })), logicalId, void 0, void 0, replProvider);
35104
36337
  } catch (createError) {
35105
- if (!retainOldOnReplace) throw createError;
36338
+ 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
36339
  if (!isNameCollisionError(createError instanceof Error ? createError.message : String(createError))) throw createError;
35107
36340
  const nameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
35108
36341
  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 +36345,7 @@ var DeployEngine = class {
35112
36345
  const idempotentNameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
35113
36346
  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
36347
  }
36348
+ this.retainedOldOnReplacement.add(logicalId);
35115
36349
  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
36350
  retainedSurvivorReason = `UpdateReplacePolicy: Retain kept the old ${resourceType} (${currentResource.physicalId}), now untracked by cdkd`;
35117
36351
  }
@@ -35129,6 +36363,7 @@ var DeployEngine = class {
35129
36363
  if (createResult.effectiveProperties) replacementResult.effectiveProperties = createResult.effectiveProperties;
35130
36364
  result = replacementResult;
35131
36365
  resultProvisionedBy = replDecision.provisionedBy;
36366
+ captureProvider = replProvider;
35132
36367
  } else throw updateError;
35133
36368
  }
35134
36369
  if (result.wasReplaced) this.logger.info(`Resource ${logicalId} was replaced: ${currentResource.physicalId} -> ${result.physicalId}`);
@@ -35148,7 +36383,7 @@ var DeployEngine = class {
35148
36383
  provisionedBy: resultProvisionedBy
35149
36384
  };
35150
36385
  const updateCaptureSiblings = await this.buildObservedCaptureSiblings(resourceType, logicalId, result.physicalId, template, stateResources, stackName, parameterValues, conditions);
35151
- this.kickOffObservedCapture(updateProvider, logicalId, result.physicalId, resourceType, resolvedProps, updateCaptureSiblings);
36386
+ this.kickOffObservedCapture(captureProvider, logicalId, result.physicalId, resourceType, resolvedProps, updateCaptureSiblings);
35152
36387
  const updatePartial = updatePartialReason(result);
35153
36388
  if (counts) if (updatePartial !== void 0) counts.updatePartial++;
35154
36389
  else counts.updated++;
@@ -35506,10 +36741,55 @@ var DeployEngine = class {
35506
36741
  * the deploy instead of silently publishing nothing (which breaks downstream
35507
36742
  * `Fn::ImportValue` consumers with "export not found" long after this deploy
35508
36743
  * exits 0).
35509
- */
35510
- handleOutputResolutionFailure(error, outputKey, outputs) {
35511
- if (this.options.strictGetAtt) throw new Error(`Failed to resolve output ${outputKey}: ${error instanceof Error ? error.message : String(error)} (--strict-getatt promotes output resolution failures to deploy errors; drop the flag to warn and skip the output instead)`, { cause: error });
35512
- this.logger.warn(`Failed to resolve output ${outputKey}: ${String(error)}`);
36744
+ *
36745
+ * The error is masked on both arms (issue
36746
+ * [#2728](https://github.com/go-to-k/cdkd/issues/2728)). The resolver's own
36747
+ * failures echo the offending reference token, a secret id / JSON key, or
36748
+ * an SSM parameter name, and `resolveSub` / `resolveJoin` re-enter
36749
+ * `resolveDynamicReferences` with the ASSEMBLED string — so a reference
36750
+ * built out of a value this same pass resolved from a secret puts that
36751
+ * plaintext into the echoed field (a JSON key assembled from the resolved
36752
+ * password says `key '<password>' not found`).
36753
+ *
36754
+ * TWO bags, the inherited one first, the same pair the resolver's
36755
+ * `maskSecretsForLog` masks against and for the same reason (issue #1903
36756
+ * round 2): on a nested-stack child the parent-decrypted parameter
36757
+ * plaintext is in `inheritedSecrets` and not in the pass map until a
36758
+ * `{Ref: <Param>}` resolution copies it across. No shape reaching this
36759
+ * handler before that copy has been constructed (every `${Param}` route
36760
+ * goes through `resolveRef`, which records), so the inherited bag is
36761
+ * defense in depth here — but two masking sites in one flow must not argue
36762
+ * opposite sides of the same question. `secrets` is the outputs pass's own
36763
+ * map: everything recorded before this handler runs, an `Export.Name`
36764
+ * resolution's entries included (its `finally` merges them back before the
36765
+ * `catch` reaches here). What a still-pending concurrent part would have
36766
+ * recorded is outside both — issue #2563's late write, the same bound every
36767
+ * other masking site in this engine has.
36768
+ *
36769
+ * The strict arm's `cause` is masked as an OBJECT, through
36770
+ * `maskSecretsInError` — a clone of each `Error` link `errorCauseChain`
36771
+ * reaches (`ERROR_CAUSE_MASK_MAX_DEPTH` links, a cycle stops it; a
36772
+ * non-`Error` cause is kept verbatim), symbols included, so
36773
+ * `isMarkedNonRetryable`'s non-enumerable marker survives (the same reason
36774
+ * `provisionResource` uses it). No sink on the deploy path renders past
36775
+ * one level today (`formatError` prints `Caused by:` for a `CdkdError`'s
36776
+ * direct cause only), but that is a property of reachability, not of the
36777
+ * sinks: `cdkd scrub`'s `describeFailure` renders a chain, and is safe
36778
+ * because its boundary masks with this same helper and it walks the same
36779
+ * bounded `errorCauseChain`. Masking here gives a renderer within that
36780
+ * bound nothing to leak. A thrown STRING is masked as text; any other
36781
+ * non-`Error` value is not threaded as a cause at all (it would travel
36782
+ * unmasked, and `markNonRetryable` cannot have marked it).
36783
+ */
36784
+ handleOutputResolutionFailure(error, outputKey, outputs, secrets, inheritedSecrets) {
36785
+ let detail = error instanceof Error ? error.message || error.name : String(error);
36786
+ for (const bag of [inheritedSecrets, secrets]) detail = maskSecretsInText(detail, bag);
36787
+ if (this.options.strictGetAtt) {
36788
+ let cause = error;
36789
+ for (const bag of [inheritedSecrets, secrets]) cause = typeof cause === "string" ? maskSecretsInText(cause, bag) : maskSecretsInError(cause, bag);
36790
+ throw new Error(`Failed to resolve output ${outputKey}: ${detail} (--strict-getatt promotes output resolution failures to deploy errors; drop the flag to warn and skip the output instead)`, cause instanceof Error || typeof cause === "string" ? { cause } : {});
36791
+ }
36792
+ this.logger.warn(`Failed to resolve output ${outputKey}: ${detail}`);
35513
36793
  outputs[outputKey] = void 0;
35514
36794
  }
35515
36795
  /**
@@ -35531,6 +36811,8 @@ var DeployEngine = class {
35531
36811
  ...parameterValues && { parameters: parameterValues },
35532
36812
  ...conditions && { conditions }
35533
36813
  }, stackName);
36814
+ const outputsPassSecrets = context.recordedSecretValues ?? EMPTY_SECRETS;
36815
+ const outputsPassInherited = context.inheritedSecrets ?? EMPTY_SECRETS;
35534
36816
  const publishedOutputNames = collectPublishedOutputNames(template.Outputs, conditions);
35535
36817
  let outputsPassCompleted = false;
35536
36818
  try {
@@ -35542,7 +36824,7 @@ var DeployEngine = class {
35542
36824
  try {
35543
36825
  outputs[outputKey] = await this.resolver.resolve(output.Value, context);
35544
36826
  } catch (error) {
35545
- this.handleOutputResolutionFailure(error, outputKey, outputs);
36827
+ this.handleOutputResolutionFailure(error, outputKey, outputs, outputsPassSecrets, outputsPassInherited);
35546
36828
  }
35547
36829
  }
35548
36830
  for (const [outputKey, output] of Object.entries(template.Outputs)) {
@@ -35562,7 +36844,7 @@ var DeployEngine = class {
35562
36844
  for (const [plaintext, expression] of nameSecrets) context.recordedSecretValues?.set(plaintext, expression);
35563
36845
  }
35564
36846
  } catch (error) {
35565
- this.handleOutputResolutionFailure(error, outputKey, outputs);
36847
+ this.handleOutputResolutionFailure(error, outputKey, outputs, outputsPassSecrets, outputsPassInherited);
35566
36848
  continue;
35567
36849
  }
35568
36850
  if (typeof exportName !== "string") continue;
@@ -35601,5 +36883,5 @@ var DeployEngine = class {
35601
36883
  };
35602
36884
 
35603
36885
  //#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
36886
+ export { findActionableSilentDrops as $, runDockerStreaming as $n, StackTerminationProtectionError as $r, carriesSecretMask as $t, WARM_THROUGHPUT_MEMBERS as A, rewriteTemplateAssetReferences as An, clearBucketRegionCache as Ar, configStringRefusal as At, yellow as B, validateContainerRepoName as Bn, DependencyError as Br, s3BucketDualStackDomainName as Bt, unsupportedFinalSnapshotError as C, shouldRetainResource as Cn, displaySafe as Cr, refStateLookupFromResource as Ct, isStatefulRecreateTargetForReplace as D, buildAssetRedirectMap as Dn, derivePartitionAndUrlSuffix as Dr, assertRegionMatch as Dt, MULTI_REGION_RECREATE_BLOCKED_TYPES as E, WorkGraph as En, canonicalizeRegion as Er, resolveExplicitPhysicalId as Et, bold as F, getBootstrapMarkerKey as Fn, setAwsClients as Fr, requireConfigString as Ft, secretBearingStateKeyWarning as G, describeDockerExecFailure as Gn, LocalStartServiceError as Gr, INTRINSIC_KEYS as Gt, collectPublishedOutputNames as H, describeAwsFailure as Hn, DynamicReferenceRegionAmbiguousError as Hr, s3BucketWebsiteUrl as Ht, cyan as I, isCrossRegionRedirect as In, AssetError as Ir, classifyReplaySecretRegion as It, IAMRoleProvider as J, formatDockerLoginError as Jn, PartialFailureError as Jr, DagBuilder as Jt, stateKeySecretExposure as K, describeDockerFailure as Kn, LockError as Kr, describeTypeWithThrottleRetry as Kt, gray as L, parseBootstrapMarker as Ln, CdkdError as Lr, producerRegionsFromState as Lt, isWarmThroughputDecrease as M, BOOTSTRAP_MARKER_PREFIX as Mn, AwsClients as Mr, replayWarn as Mt, toFiniteNumber as N, assertAssetBucketRegion as Nn, getAwsClients as Nr, requireConfigArray as Nt, isStatefulRecreateTargetSync as O, createAssetRedirectResolver as On, AssemblyReader as Or, coerceCfnBoolean as Ot, formatResourceLine as P, ensureAssetStorage as Pn, resetAwsClients as Pr, requireConfigObject as Pt, wouldReturnToSdkProvider as Q, runDockerForeground as Qn, StackHasActiveImportsError as Qr, TEMPLATE_SOURCED_RULES as Qt, green as R, readBootstrapMarkerBody as Rn, ConfigError as Rr, s3BucketArn as Rt, refusesFinalSnapshot as S, importableOutputs as Sn, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as Sr, parameterTypeMayLoseSecretIdentity as St, extractDeploymentEventError as T, stringifyValue as Tn, PARTITION_TABLE as Tr, normalizeAwsTagsToCfn as Tt, exportAliasCollisionScrubWarning as U, buildDockerImage as Un, IntrinsicResolutionRefusalError as Ur, applyRoleArnIfSet as Ut, collectDeclaredOutputNames as V, buildDenyExternalAccessPolicy as Vn, DeployCancelledError as Vr, s3BucketRegionalDomainName as Vt, isExportAliasCollision as W, describeDockerCapturedOutput as Wn, LocalInvokeBuildError as Wr, DiffCalculator as Wt, clearOnUpdateRemoval as X, partitionSensitiveEnv as Xn, ResourceTimeoutError as Xr, STATE_SOURCED_CROSS_GENERATION_RULES as Xt, collectInlinePolicyNamesManagedBySiblings as Y, getDockerCmd as Yn, ProvisioningError as Yr, TemplateParser as Yt, ProviderRegistry as Z, redactDockerArgvValues as Zn, ResourceUpdateNotSupportedError as Zr, STATE_SOURCED_READBACK_RULES as Zt, PRE_DELETE_SNAPSHOT_TYPES as _, shellQuote as _n, CFN_TEMPLATE_BODY_LIMIT as _r, carriesDynamicReference as _t, DeploymentEventsStore as a, withErrorHandling as ai, maskSecretsInText as an, synthesisStatusMessage as ar, endCommandInterruptScope as at, createPreDeleteFinalSnapshot as b, exportNamesCarriedFrom as bn, findLargeInlineResources as br, getAccountInfo as bt, replayFailedOperations as c, isThrottlingError as ci, redactSecretsForState as cn, resolveApp as cr, startInterruptWatch as ct, updatePartialReason as d, markRedactedCause as di, S3StateBackend as dn, resolveSkipPrefix as dr, UNSPECIFIED_SKIP_REASON as dt, StateError as ei, createSecretMasker as en, escapeRegExp$1 as er, findSilentDropProperties as et, withResourceDeadline as f, retryClassificationText as fi, rebuildClientForBucketRegion as fn, resolveStateBucketWithDefault as fr, deleteIndeterminateGuards as ft, ATOMIC_FINAL_SNAPSHOT_TYPES as g, forceQuitRecoveryClause as gn, warnDeprecatedNoPrefixCliFlag as gr, IntrinsicFunctionResolver as gt, computeImplicitDeleteEdges as h, buildLockContentionMessage as hn, stateBucketExistenceConfirmed as hr, isTerminationProtectionPropagationError as ht, DeploymentEventsReader as i, normalizeAwsError as ii, maskSecretsInError as in, Synthesizer as ir, beginCommandInterruptScope as it, coerceWarmThroughput as j, AssetModeResolver as jn, resolveBucketRegion as jr, readConfigString as jt, renderStatefulReason as k, loadPublishableAssetManifest as kn, processStackMessages as kr, configBooleanRefusal as kt, replayRollback as l, isTransientServerError as li, scrubResourceRecord as ln, resolveAutoAssetStorage as lr, CloudControlProvider as lt, IMPLICIT_DELETE_DEPENDENCIES as m, buildForceUnlockCommand as mn, resolveUseCdkBootstrapAssets as mr, disableInstanceApiTermination as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, formatError as ni, errorCauseChain as nn, AssetManifestLoader as nr, maskDeep as nt, planFailedOps as o, isMarkedNonRetryable as oi, recordMaskOnlyValue as on, getDefaultStateBucketName as or, interruptWatchListenerCount as ot, maskingRetryLogger as p, __exportAll as pi, UNRENDERABLE as pn, resolveStateBucketWithDefaultAndSource as pr, deleteSkipReason as pt, getCurrentResourceSecrets as q, dockerSpawnEnvWithSensitive as qn, NestedStackChildDirectDestroyError as qr, withRetry as qt, DeployEngine as r, isCdkdError as ri, isSingleDynamicReferenceToken as rn, getDockerImageBySourceHash as rr, maskerOrIdentity as rt, planRollback as s, isRetryableTransientError as si, recoverMaskedOutput as sn, getLegacyStateBucketName as sr, isInterruptedWaitError as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, SynthesisError as ti, dynamicReferenceTokens as tn, stripControlChars as tr, createMaskedRetryLogger as tt, updatePartialMessage as u, markNonRetryable as ui, LockManager as un, resolveCaptureObservedState as ur, slowCcOperationTimeoutMs as ut, buildFinalSnapshotIdentifier as v, CUSTOM_RESOURCE_RESPONSE_PREFIX as vn, CFN_TEMPLATE_URL_LIMIT as vr, cfnRefValueFromPhysicalId as vt, makeCanonicalizePropertiesFn as w, AssetPublisher as wn, expectedOwnerParam as wr, WAFv2WebACLProvider as wt, isFinalSnapshotError as x, importableOutputKeys as xn, uploadCfnTemplate as xr, isUnboundTemplateParameter as xt, ccRoutedFinalSnapshotError as y, DEFAULT_STATE_PREFIX as yn, MIGRATE_TMP_PREFIX as yr, coerceParameterTypedValue as yt, red as z, validateAssetBucketName as zn, CrossAccountSecretRefusalError as zr, s3BucketDomainName as zt };
36887
+ //# sourceMappingURL=deploy-engine-B377YKBI.js.map