@go-to-k/cdkd 0.286.2 → 0.286.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
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-Crg_SdRh.js";
3
+ import { t as getCdkdVersion } from "./version-CKOnsgg1.js";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { randomUUID } from "node:crypto";
6
- import { CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketLocationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchKey, PutBucketEncryptionCommand, PutBucketPolicyCommand, PutObjectCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
6
+ import { CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketLocationCommand, GetBucketReplicationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchKey, PutBucketEncryptionCommand, PutBucketPolicyCommand, PutObjectCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
7
7
  import { CloudControlClient, CreateResourceCommand, DeleteResourceCommand, GetResourceCommand, GetResourceRequestStatusCommand, ListResourcesCommand, UpdateResourceCommand } from "@aws-sdk/client-cloudcontrol";
8
8
  import { AttachRolePolicyCommand, CreateRoleCommand, DeleteRoleCommand, DeleteRolePermissionsBoundaryCommand, DeleteRolePolicyCommand, DetachRolePolicyCommand, GetRoleCommand, GetRolePolicyCommand, IAMClient, ListAttachedRolePoliciesCommand, ListInstanceProfilesForRoleCommand, ListRolePoliciesCommand, ListRoleTagsCommand, NoSuchEntityException, PutRolePermissionsBoundaryCommand, PutRolePolicyCommand, RemoveRoleFromInstanceProfileCommand, TagRoleCommand, UntagRoleCommand, UpdateAssumeRolePolicyCommand, UpdateRoleCommand } from "@aws-sdk/client-iam";
9
9
  import { SQSClient } from "@aws-sdk/client-sqs";
@@ -35,6 +35,7 @@ import { KMSClient, ListAliasesCommand } from "@aws-sdk/client-kms";
35
35
  import { readFile } from "fs/promises";
36
36
  import { join as join$1 } from "path";
37
37
  import { CreateRepositoryCommand, DescribeImagesCommand as DescribeImagesCommand$1, DescribeRepositoriesCommand, ECRClient, GetAuthorizationTokenCommand, PutImageTagMutabilityCommand } from "@aws-sdk/client-ecr";
38
+ import { inspect } from "node:util";
38
39
  import { hostname } from "os";
39
40
  import graphlib from "graphlib";
40
41
  import { DescribeDBClustersCommand, DescribeDBInstancesCommand, RDSClient } from "@aws-sdk/client-rds";
@@ -4027,16 +4028,402 @@ function displaySafe(value, opts) {
4027
4028
  }
4028
4029
 
4029
4030
  //#endregion
4030
- //#region src/state/s3-noncurrent-version-purge.ts
4031
+ //#region src/state/s3-replication-purge-gap.ts
4032
+ /**
4033
+ * Tell the user when S3 REPLICATION has silently kept the bodies that
4034
+ * {@link ../state/s3-noncurrent-version-purge.js | the noncurrent-version
4035
+ * purge} just removed (issue
4036
+ * [#2447](https://github.com/go-to-k/cdkd/issues/2447)).
4037
+ *
4038
+ * ## The hole this closes
4039
+ *
4040
+ * `purgeNoncurrentKeyVersions` exists so that "cdkd deleted the object" means
4041
+ * the body is no longer retrievable. It reaches that by issuing
4042
+ * `DeleteObjects` entries that name a specific `VersionId`.
4043
+ *
4044
+ * **S3 never replicates a version-id delete.** Replication propagates PUTs
4045
+ * and — only when `DeleteMarkerReplication` is enabled — delete markers; a
4046
+ * delete naming a version id is deliberately not replicated, so that a delete
4047
+ * on the source cannot destroy data on the destination. On a state bucket
4048
+ * with Cross-Region (CRR) or Same-Region (SRR) replication the purge is
4049
+ * therefore SOURCE-ONLY: the replica keeps its own copy of every version,
4050
+ * `GetObject` with a `VersionId` still returns the secret, and cdkd reports
4051
+ * the purge as complete because on the only bucket it knows about, it was.
4052
+ *
4053
+ * That is exactly the shape the purge was built to remove — an operation that
4054
+ * reports success while the value stays readable — reproduced one bucket over
4055
+ * and invisible from every signal cdkd emitted. The user is not merely
4056
+ * unprotected, they are REASSURED, which is the direction this repo treats as
4057
+ * a defect rather than a gap.
4058
+ *
4059
+ * ## Why a probe and not only a doc line
4060
+ *
4061
+ * Documentation reaches the reader who goes looking. This reaches the one who
4062
+ * does not, at the moment the false belief would be formed. The whole cost is
4063
+ * one bucket-metadata call per process (see the cache below) and one IAM
4064
+ * action the caller may well not hold.
4065
+ *
4066
+ * ## Degrading is the DEFAULT, not the failure
4067
+ *
4068
+ * The purge's contract is that a missing permission produces a warning and
4069
+ * never an abort, and a detector bolted onto it must not be able to break
4070
+ * that. So this module NEVER THROWS, and treats every non-answer as "we do
4071
+ * not know" rather than as either polarity:
4072
+ *
4073
+ * - `ReplicationConfigurationNotFoundError` — the answer "no replication
4074
+ * configuration exists". S3 reports the ABSENCE of a configuration as an
4075
+ * ERROR, not as an empty response body, so this arm is the ordinary case
4076
+ * for almost every bucket and must not read as a failure.
4077
+ * `ReplicationConfiguration` ABSENT from a 200 response is treated the same
4078
+ * way: the SDK models the field as optional, and a modelled field is not a
4079
+ * promise that the API populates it.
4080
+ * - `AccessDenied` and friends — the caller lacks
4081
+ * `s3:GetReplicationConfiguration`. Logged at DEBUG and never at `warn`:
4082
+ * the overwhelming majority of buckets are not replicated, so warning here
4083
+ * would tell almost every user to grant a permission in order to be told
4084
+ * nothing. The doc names the action for the user who wants the check.
4085
+ * - anything else (throttle, 5xx, network) — DEBUG, and deliberately NOT
4086
+ * cached, so the next purge in the same process retries.
4087
+ *
4088
+ * ## What is cached, what is deduped, and why they are different keys
4089
+ *
4090
+ * The PROBE is cached per (bucket, asserted owner) for the process lifetime, as
4091
+ * the sibling `src/provisioning/create-only-properties.ts` caches its
4092
+ * `DescribeType` lookups and for the same reason: the answer cannot change
4093
+ * mid-run, the value stored is the in-flight PROMISE so concurrent purges share
4094
+ * one call, and only non-transient outcomes are kept. Without it a stack with
4095
+ * thirty custom resources would issue thirty identical `GetBucketReplication`
4096
+ * calls.
4097
+ *
4098
+ * The WARNING is deduped too, but on a DIFFERENT and deliberately wider key —
4099
+ * (bucket, object description, destinations) — with the repeats sent to
4100
+ * `debug`. The two keys answer two questions and must not be merged:
4101
+ *
4102
+ * - the bucket alone is not enough, because `lock-manager.ts` routes this
4103
+ * module's `warn` sink to `debug` on the ordinary release path (it purges a
4104
+ * heartbeat record on every command and must not warn about it). Keyed on the
4105
+ * bucket, that demoted line would consume the one warning the rollback
4106
+ * journal or the response sidecar needed;
4107
+ * - adding the description is still not enough, because two rules covering
4108
+ * different prefixes can name DIFFERENT destinations. Purging a `cdkd/dev/…`
4109
+ * key can match only the first rule; a later `cdkd/prod/…` purge under the
4110
+ * same description matches both, and without the destinations in the key the
4111
+ * user would clean the first replica and never learn the second one holds the
4112
+ * production journal.
4113
+ *
4114
+ * Deduping at all is a departure from the sibling purge module, which
4115
+ * deliberately repeats its own failure warning. The difference is that a
4116
+ * failure RECURS — each one is another object cdkd could not remove — while
4117
+ * this is a fact about a BUCKET, identical every time it is re-derived.
4118
+ * Repeating it thirty times in a deploy adds nothing and erodes the failure
4119
+ * warning standing next to it.
4120
+ */
4031
4121
  /**
4032
4122
  * Parenthetical used when a caller names nothing.
4033
4123
  *
4034
- * True of ANY object this function is pointed at, which is the bar for a
4035
- * default here: a caller that forgets to describe its object must still emit a
4036
- * warning that is correct, just less specific. It deliberately does not guess
4037
- * at content.
4124
+ * True of ANY object the purge is pointed at, which is the bar for a default
4125
+ * here: a caller that forgets to describe its object must still emit a warning
4126
+ * that is correct, just less specific. It deliberately does not guess at
4127
+ * content.
4128
+ *
4129
+ * Lives HERE rather than in `s3-noncurrent-version-purge.ts` only to keep the
4130
+ * import one-directional — the purge imports this module, so a shared constant
4131
+ * defined there and read here would be a cycle. It is the same sentence both
4132
+ * warnings fall back to, and one definition is what stops the two drifting.
4133
+ */
4134
+ const DEFAULT_PURGED_OBJECT_DESCRIPTION = "the body of an object cdkd has just reported as removed";
4135
+ /**
4136
+ * Cache of the replication probe, holding the in-flight promise.
4137
+ *
4138
+ * Keyed on the bucket AND the `ExpectedBucketOwner` the caller asserts. The
4139
+ * SUCCESS answer does not depend on the owner assertion — two callers
4140
+ * differing only in whether they assert it read the same configuration — but
4141
+ * the FAILURE does: a mismatched-owner 403 is an answer about that caller's
4142
+ * request, and caching it under the bare bucket name silenced the detector for
4143
+ * every correctly-scoped caller in the process. cdkd has two shapes today (the
4144
+ * provider probes without an owner, the state backend and lock manager with
4145
+ * one), so the two are distinguishable in practice.
4146
+ */
4147
+ const replicationProbeCache = /* @__PURE__ */ new Map();
4148
+ /**
4149
+ * (bucket, object description, destinations) triples already warned about.
4150
+ *
4151
+ * The one thing the warning says that VARIES is `objectDescription`; the rest
4152
+ * is a fact about the bucket, identical every time. So a stack with thirty
4153
+ * custom resources repeated a ~600-character warning thirty times while adding
4154
+ * no information after the first — and warning fatigue erodes the purge's own
4155
+ * failure warning standing next to it.
4156
+ *
4157
+ * Keyed on all three precisely so this cannot become a silence hole; the
4158
+ * module doc above gives the two failures a narrower key produces. Entries are
4159
+ * added only after the warning is actually emitted, so a throwing sink does
4160
+ * not burn a slot.
4161
+ *
4162
+ * RESIDUAL, bounded and recorded: cdkd cannot see whether the caller's sink
4163
+ * DEMOTED the line, so a `lock-manager.ts` release (which routes `warn` to
4164
+ * `debug`) claims the lock's slot and a later reap of a stale lock — which
4165
+ * would have warned — is suppressed. It is bounded to the LOCK description,
4166
+ * whose object the docs describe as carrying no secret, and to one process.
4167
+ */
4168
+ const warnedWarnings = /* @__PURE__ */ new Set();
4169
+ /** How many destinations the warning names before it truncates. */
4170
+ const MAX_NAMED_DESTINATIONS = 3;
4171
+ /** Shown when a rule carries no `Destination.Bucket` for us to name. */
4172
+ const UNNAMED_DESTINATION = "<destination not named by the replication rule>";
4173
+ /**
4174
+ * Error codes meaning "this principal will never get an answer here".
4175
+ *
4176
+ * Cached like a real answer, because unlike a throttle they do not clear
4177
+ * within the process: retrying on every purge would spend one denied API call
4178
+ * per custom resource for a result that cannot change.
4179
+ */
4180
+ const PERMANENT_DENIALS = /* @__PURE__ */ new Set([
4181
+ "AccessDenied",
4182
+ "AccessDeniedException",
4183
+ "AllAccessDisabled",
4184
+ "MethodNotAllowed",
4185
+ "NotImplemented"
4186
+ ]);
4187
+ /**
4188
+ * Credential-shaped failures that ARRIVE AS 403 and are nonetheless transient.
4189
+ *
4190
+ * Checked BEFORE the blanket 403 rule, which is the only way they can win:
4191
+ * `InvalidAccessKeyId` is the classic blip while an IAM principal or an
4192
+ * assumed-role credential propagates — this repo retries that whole class over
4193
+ * a 47.75 s schedule elsewhere — and an expired or skewed token clears on
4194
+ * refresh. Cached as permanent, ONE such blip on the first purge of a long
4195
+ * deploy would silence the detector for the rest of the process, which is the
4196
+ * "false silence leaves them believing a secret is gone" direction this module
4197
+ * forbids.
4198
+ */
4199
+ const TRANSIENT_CREDENTIAL_CODES = /* @__PURE__ */ new Set([
4200
+ "InvalidAccessKeyId",
4201
+ "ExpiredToken",
4202
+ "ExpiredTokenException",
4203
+ "InvalidToken",
4204
+ "TokenRefreshRequired",
4205
+ "RequestTimeTooSkewed",
4206
+ "RequestExpired"
4207
+ ]);
4208
+ const describe$2 = (error) => error instanceof Error ? error.message : String(error);
4209
+ /**
4210
+ * Every wire error code the value offers, `name` and `Code` alike.
4211
+ *
4212
+ * NO PRECEDENCE, deliberately. An earlier revision collapsed the two to one
4213
+ * string and preferred a specific `name`, which had two failure modes in
4214
+ * opposite directions: preferring `name` unconditionally made the `Code`
4215
+ * fallback dead (`{ name: 'Error', Code: 'AccessDenied' }` classified as
4216
+ * retryable, so a denied principal re-probed on every purge), while
4217
+ * preferring a SPECIFIC `name` let a wrapper's own name hide
4218
+ * `Code: 'InvalidAccessKeyId'` and cache a credential blip as a permanent
4219
+ * denial. Membership is tested against every code the error carries, so
4220
+ * neither field can mask the other. (Verified against real S3: a missing
4221
+ * replication configuration arrives with that string on BOTH fields.)
4222
+ *
4223
+ * Null-safe because a `send` that rejects with `null` used to throw a
4224
+ * TypeError INSIDE the probe's own catch, leaving a REJECTED promise in the
4225
+ * cache forever: every later purge on that bucket then fell into the outer
4226
+ * `catch {}` with no warning and no debug line — a silently dead detector,
4227
+ * which is the worst possible failure for this module.
4228
+ *
4229
+ * This guard and `probe`'s rejection arm both serve one invariant — **no
4230
+ * rejection ever reaches the cache** — and the honest statement of their
4231
+ * relationship, measured one mutation at a time on 2026-09-05, is:
4232
+ *
4233
+ * - removing the ARM alone REDS a case (a rejection whose own `toString`
4234
+ * throws: the guards pass that value through and `describe()` is what
4235
+ * raises), so the arm is independently fenced;
4236
+ * - removing these GUARDS alone leaves the CACHING behaviour identical:
4237
+ * `errorCodes` runs inside `runProbe`'s own catch, so a TypeError here is
4238
+ * caught by the arm and produces the same `unknown` / `retry: true` /
4239
+ * evicted outcome. What DOES change is the debug line's reason — guarded it
4240
+ * reports the rejection (`: null`), unguarded it reports the classifier's
4241
+ * own crash — and that difference is what the `throw null` case asserts, so
4242
+ * the guards are fenced after all.
4243
+ *
4244
+ * They also earn their place structurally: classification is inside the catch
4245
+ * TODAY. An edit that hoists it out — or that adds a caller of `errorCodes`
4246
+ * outside a try — reinstates the original defect, in which a `null` rejection
4247
+ * crashed the classifier and left a REJECTED promise cached for the process,
4248
+ * so every later purge on that bucket fell into the outer `catch {}` with no
4249
+ * warning and no debug line: a silently dead detector.
4250
+ */
4251
+ const errorCodes = (error) => {
4252
+ if (error === null || typeof error !== "object" && typeof error !== "function") return [];
4253
+ const e = error;
4254
+ const codes = [];
4255
+ if (typeof e.name === "string" && e.name.length > 0) codes.push(e.name);
4256
+ if (typeof e.Code === "string" && e.Code.length > 0) codes.push(e.Code);
4257
+ return codes;
4258
+ };
4259
+ /** True when ANY code the error carries is in `set`. */
4260
+ const hasCode = (error, set) => errorCodes(error).some((code) => set.has(code));
4261
+ const httpStatus = (error) => {
4262
+ if (error === null || typeof error !== "object" && typeof error !== "function") return void 0;
4263
+ const meta = error.$metadata;
4264
+ return typeof meta?.httpStatusCode === "number" ? meta.httpStatusCode : void 0;
4265
+ };
4266
+ /**
4267
+ * Strip the ARN wrapper off `Destination.Bucket`, which S3 returns as
4268
+ * `arn:<partition>:s3:::<name>`.
4269
+ *
4270
+ * Partition-agnostic on purpose: `aws-cn` and `aws-us-gov` produce a
4271
+ * different second segment, and a hard-coded `arn:aws:s3:::` would leave the
4272
+ * whole ARN in the message there rather than mis-parse it — survivable, but
4273
+ * the regex costs nothing. A value that is not ARN-shaped is passed through
4274
+ * unchanged rather than rejected; the field is only ever displayed.
4275
+ */
4276
+ function destinationName(bucketArn, account) {
4277
+ if (bucketArn === void 0 || bucketArn === "") return UNNAMED_DESTINATION;
4278
+ const name = bucketArn.replace(/^arn:[^:]*:s3:::/, "");
4279
+ return account !== void 0 && account !== "" ? `${name} (account ${account})` : name;
4280
+ }
4281
+ /**
4282
+ * Reduce a `ReplicationConfiguration` to the ENABLED rules and their effective
4283
+ * prefixes.
4284
+ *
4285
+ * Exported for its own unit tests: the mapping from the four filter shapes S3
4286
+ * accepts onto one prefix string is the part with real cases in it, and
4287
+ * pinning it through a stubbed client would test the plumbing instead.
4288
+ *
4289
+ * Over-approximates DELIBERATELY, in the direction of warning:
4290
+ *
4291
+ * - a rule filtering on a TAG (`Filter.Tag`, or `Filter.And.Tags` with no
4292
+ * `Prefix`) cannot be evaluated without reading each object's tag set, so
4293
+ * its prefix reduces to `''` and it covers everything. A false warning
4294
+ * sends the reader to look at a replica; a false silence leaves them
4295
+ * believing a secret is gone.
4296
+ * - a `Disabled` rule is KEPT, flagged rather than dropped. Disabling a rule
4297
+ * stops FUTURE replication; it does not remove what the rule already
4298
+ * copied, so a bucket whose rule covered `cdkd/` last month and is disabled
4299
+ * today still holds every body ever purged. Dropping it was the first cut
4300
+ * and is the reassured-user failure this module exists to remove, arriving
4301
+ * inside the module that removes it. A rule with no `Status` at all is kept
4302
+ * as ENABLED: `Status` is required by the model, so an absent one means the
4303
+ * response was not what we assumed — the same reason the sibling purge
4304
+ * treats an absent `IsLatest` as "assume the unsafe thing is true".
4305
+ *
4306
+ * Pure: it reads only its argument, so no ambient locale, clock or
4307
+ * environment can change the answer.
4308
+ */
4309
+ function normalizeReplicationRules(config) {
4310
+ const targets = [];
4311
+ for (const rule of config.Rules ?? []) {
4312
+ const prefix = rule.Filter !== void 0 ? rule.Filter.Prefix ?? rule.Filter.And?.Prefix ?? "" : rule.Prefix ?? "";
4313
+ targets.push({
4314
+ prefix,
4315
+ destination: destinationName(rule.Destination?.Bucket, rule.Destination?.Account),
4316
+ enabled: rule.Status !== "Disabled"
4317
+ });
4318
+ }
4319
+ return targets;
4320
+ }
4321
+ async function runProbe(s3Client, bucket, requestFields) {
4322
+ try {
4323
+ const config = (await s3Client.send(new GetBucketReplicationCommand({
4324
+ Bucket: bucket,
4325
+ ...requestFields
4326
+ }))).ReplicationConfiguration;
4327
+ if (config === void 0) return { kind: "none" };
4328
+ const rules = normalizeReplicationRules(config);
4329
+ return rules.length === 0 ? { kind: "none" } : {
4330
+ kind: "rules",
4331
+ rules
4332
+ };
4333
+ } catch (error) {
4334
+ if (errorCodes(error).includes("ReplicationConfigurationNotFoundError")) return { kind: "none" };
4335
+ if (hasCode(error, TRANSIENT_CREDENTIAL_CODES)) return {
4336
+ kind: "unknown",
4337
+ reason: describe$2(error),
4338
+ retry: true
4339
+ };
4340
+ const permanent = hasCode(error, PERMANENT_DENIALS) || httpStatus(error) === 403;
4341
+ return {
4342
+ kind: "unknown",
4343
+ reason: describe$2(error),
4344
+ retry: !permanent
4345
+ };
4346
+ }
4347
+ }
4348
+ function probe(s3Client, bucket, requestFields) {
4349
+ const key = `${bucket}\u0000${requestFields.ExpectedBucketOwner ?? ""}`;
4350
+ const cached = replicationProbeCache.get(key);
4351
+ if (cached) return cached;
4352
+ const pending = runProbe(s3Client, bucket, requestFields).then((result) => {
4353
+ if (result.kind === "unknown" && result.retry) evictIfOwn(key, pending);
4354
+ return result;
4355
+ }, (error) => {
4356
+ evictIfOwn(key, pending);
4357
+ return {
4358
+ kind: "unknown",
4359
+ reason: describe$2(error),
4360
+ retry: true
4361
+ };
4362
+ });
4363
+ replicationProbeCache.set(key, pending);
4364
+ return pending;
4365
+ }
4366
+ /**
4367
+ * Remove a cache entry only when it is still the one we put there.
4368
+ *
4369
+ * The identity check is UNFENCED by a test, deliberately and stated rather
4370
+ * than left to be discovered: the only way a stale probe can settle against a
4371
+ * newer entry under the same key is for something to have cleared the cache
4372
+ * mid-flight, and the sole clearer is `clearReplicationProbeCache()`, a
4373
+ * test-only helper. Writing a test for it would be writing a test for the test
4374
+ * helper. It is kept because the alternative is an unconditional `delete` that
4375
+ * is wrong the moment a non-test clearer appears.
4038
4376
  */
4039
- const DEFAULT_OBJECT_DESCRIPTION = "the body of an object cdkd has just reported as removed";
4377
+ function evictIfOwn(key, own) {
4378
+ if (replicationProbeCache.get(key) === own) replicationProbeCache.delete(key);
4379
+ }
4380
+ /**
4381
+ * Warn if replication on `bucket` covers any of `keys`.
4382
+ *
4383
+ * `keys` are the keys whose bodies were actually removed (or could not be
4384
+ * settled) — NOT everything the caller asked about. The purge scopes them; see
4385
+ * its call site for why announcing a survival that never happened is worse
4386
+ * than saying nothing.
4387
+ *
4388
+ * NEVER THROWS and never rejects: the caller runs on a cleanup path that must
4389
+ * not abort, and this is a diagnostic bolted onto it.
4390
+ */
4391
+ async function warnIfPurgeIsReplicated(s3Client, bucket, keys, options = {}) {
4392
+ if (keys.length === 0) return;
4393
+ const emitDebug = (message) => {
4394
+ const sink = options.logger;
4395
+ if (sink?.debug) sink.debug(message);
4396
+ else getLogger().child("s3-replication-check").debug(message);
4397
+ };
4398
+ try {
4399
+ const safeBucket = displaySafe(bucket, { asciiOnly: true });
4400
+ const result = await probe(s3Client, bucket, options.requestFields ?? {});
4401
+ if (result.kind === "none") return;
4402
+ if (result.kind === "unknown") {
4403
+ emitDebug(`Could not determine whether s3://${safeBucket} is replicated (grant s3:GetReplicationConfiguration to enable the check): ${displaySafe(result.reason)}`);
4404
+ return;
4405
+ }
4406
+ const covered = result.rules.filter((rule) => keys.some((key) => key.startsWith(rule.prefix)));
4407
+ if (covered.length === 0) return;
4408
+ const destinations = [...new Set(covered.map((rule) => rule.enabled ? rule.destination : `${rule.destination} (rule currently Disabled -- it stops FUTURE replication, not what it already copied)`))].sort();
4409
+ const warnKey = [
4410
+ bucket,
4411
+ options.objectDescription ?? "",
4412
+ ...destinations
4413
+ ].join("\0");
4414
+ if (warnedWarnings.has(warnKey)) {
4415
+ emitDebug(`S3 replication on s3://${safeBucket} still covers purged keys (${options.objectDescription ?? "the body of an object cdkd has just reported as removed"}); already reported once this run for ${destinations.map((d) => displaySafe(d, { asciiOnly: true })).join(", ")}.`);
4416
+ return;
4417
+ }
4418
+ const named = destinations.slice(0, MAX_NAMED_DESTINATIONS).map((d) => displaySafe(d, { asciiOnly: true }));
4419
+ const elided = destinations.length - named.length;
4420
+ (options.logger ?? getLogger().child("s3-replication-check")).warn(`S3 replication is enabled on s3://${safeBucket} and covers the key(s) cdkd just purged. S3 NEVER replicates a version-id delete, so the purge removed those versions from THIS bucket only — the copies in the destination bucket survive and remain readable there via GetObject with a VersionId (${options.objectDescription ?? "the body of an object cdkd has just reported as removed"}). cdkd cannot delete them. Remove them in the destination bucket yourself (aws s3api list-object-versions, then delete-object --version-id), or narrow the replication rule so it excludes the prefixes cdkd purges under. Destination(s): ${named.join(", ")}` + (elided > 0 ? ` (and ${elided} more; aws s3api get-bucket-replication --bucket ${safeBucket} lists them all)` : ""));
4421
+ warnedWarnings.add(warnKey);
4422
+ } catch {}
4423
+ }
4424
+
4425
+ //#endregion
4426
+ //#region src/state/s3-noncurrent-version-purge.ts
4040
4427
  /**
4041
4428
  * `objectDescription` for the custom-resource response sidecar.
4042
4429
  *
@@ -4110,17 +4497,27 @@ async function purgeNoncurrentKeyVersions(s3Client, bucket, keys, options = {})
4110
4497
  const prefixes = options.listPrefix !== void 0 ? [options.listPrefix] : keys;
4111
4498
  const failed = /* @__PURE__ */ new Map();
4112
4499
  const unknown = { n: 0 };
4500
+ const purged = /* @__PURE__ */ new Set();
4501
+ const unsettledBodies = /* @__PURE__ */ new Set();
4113
4502
  for (const prefix of prefixes) try {
4114
- await purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown);
4503
+ await purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown, purged, unsettledBodies);
4115
4504
  } catch (error) {
4116
4505
  const affected = options.listPrefix !== void 0 ? keys : [prefix];
4117
- for (const key of affected) recordFailure(failed, key, describe$1(error));
4506
+ for (const key of affected) {
4507
+ recordFailure(failed, key, describe$1(error));
4508
+ unsettledBodies.add(key);
4509
+ }
4118
4510
  }
4119
4511
  if (failed.size > 0) {
4120
4512
  const named = [...failed.entries()].slice(0, MAX_NAMED_FAILURES).map(([key, reasons]) => displaySafe(`${key} (${reasons.join("; ")})`));
4121
4513
  const elided = failed.size - named.length;
4122
- logger.warn(`Could not purge noncurrent versions of ${failed.size} key(s) in s3://${bucket}. Their previous versions survive and remain readable via GetObject with a VersionId (${options.objectDescription ?? DEFAULT_OBJECT_DESCRIPTION}). Grant s3:ListBucketVersions and s3:DeleteObjectVersion on the state bucket, or purge the key(s) by hand. Failures: ${named.join(", ")}` + (elided > 0 ? ` (and ${elided} more)` : ""));
4514
+ logger.warn(`Could not purge noncurrent versions of ${failed.size} key(s) in s3://${displaySafe(bucket, { asciiOnly: true })}. Their previous versions survive and remain readable via GetObject with a VersionId (${options.objectDescription ?? "the body of an object cdkd has just reported as removed"}). Grant s3:ListBucketVersions and s3:DeleteObjectVersion on the state bucket, or purge the key(s) by hand. Failures: ${named.join(", ")}` + (elided > 0 ? ` (and ${elided} more)` : ""));
4123
4515
  }
4516
+ await warnIfPurgeIsReplicated(s3Client, bucket, [.../* @__PURE__ */ new Set([...purged, ...unsettledBodies])], {
4517
+ requestFields,
4518
+ logger,
4519
+ ...options.objectDescription !== void 0 && { objectDescription: options.objectDescription }
4520
+ });
4124
4521
  }
4125
4522
  /**
4126
4523
  * Paginate `ListObjectVersions` under one prefix and delete every returned
@@ -4135,7 +4532,7 @@ async function purgeNoncurrentKeyVersions(s3Client, bucket, keys, options = {})
4135
4532
  * id is NOT filtered out on its own, because a bucket whose versioning was
4136
4533
  * SUSPENDED can carry a genuine noncurrent `'null'` version holding the body.
4137
4534
  */
4138
- async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown) {
4535
+ async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown, purged, unsettledBodies) {
4139
4536
  let keyMarker;
4140
4537
  let versionIdMarker;
4141
4538
  do {
@@ -4147,15 +4544,30 @@ async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields,
4147
4544
  ...versionIdMarker !== void 0 && { VersionIdMarker: versionIdMarker }
4148
4545
  }));
4149
4546
  const stale = [];
4150
- for (const entry of [...resp.Versions ?? [], ...resp.DeleteMarkers ?? []]) {
4547
+ const entries = [...(resp.Versions ?? []).map((entry) => ({
4548
+ entry,
4549
+ hasBody: true
4550
+ })), ...(resp.DeleteMarkers ?? []).map((entry) => ({
4551
+ entry,
4552
+ hasBody: false
4553
+ }))];
4554
+ for (const { entry, hasBody } of entries) {
4151
4555
  if (entry.Key === void 0 || !wanted.has(entry.Key)) continue;
4152
- if (entry.IsLatest === void 0) recordFailure(failed, entry.Key, `version ${entry.VersionId ?? "<unknown>"}: listing omitted IsLatest, so the entry was left alone rather than risk deleting a current version`);
4556
+ if (entry.IsLatest === void 0) {
4557
+ recordFailure(failed, entry.Key, `version ${entry.VersionId ?? "<unknown>"}: listing omitted IsLatest, so the entry was left alone rather than risk deleting a current version`);
4558
+ if (hasBody) unsettledBodies.add(entry.Key);
4559
+ }
4153
4560
  if (entry.IsLatest !== false) continue;
4154
- if (!entry.VersionId) continue;
4561
+ if (!entry.VersionId) {
4562
+ recordFailure(failed, entry.Key, `listing returned a noncurrent entry with no VersionId, so it could not be deleted`);
4563
+ if (hasBody) unsettledBodies.add(entry.Key);
4564
+ continue;
4565
+ }
4155
4566
  stale.push({
4156
4567
  Key: entry.Key,
4157
4568
  VersionId: entry.VersionId
4158
4569
  });
4570
+ if (hasBody) purged.add(entry.Key);
4159
4571
  }
4160
4572
  for (let i = 0; i < stale.length; i += DELETE_BATCH_SIZE) {
4161
4573
  const batch = stale.slice(i, i + DELETE_BATCH_SIZE);
@@ -4182,7 +4594,10 @@ async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields,
4182
4594
  }
4183
4595
  }
4184
4596
  if (resp.IsTruncated === true && resp.NextKeyMarker === void 0) {
4185
- for (const key of wanted) if (key.startsWith(prefix)) recordFailure(failed, key, TRUNCATED_NO_MARKER);
4597
+ for (const key of wanted) if (key.startsWith(prefix)) {
4598
+ recordFailure(failed, key, TRUNCATED_NO_MARKER);
4599
+ unsettledBodies.add(key);
4600
+ }
4186
4601
  }
4187
4602
  keyMarker = resp.IsTruncated === true ? resp.NextKeyMarker : void 0;
4188
4603
  versionIdMarker = keyMarker !== void 0 ? resp.NextVersionIdMarker : void 0;
@@ -5670,6 +6085,49 @@ var FileAssetPublisher = class {
5670
6085
  }
5671
6086
  };
5672
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
+
5673
6131
  //#endregion
5674
6132
  //#region src/utils/docker-cmd.ts
5675
6133
  /**
@@ -6073,6 +6531,717 @@ function mergeEnv(overrides) {
6073
6531
  else merged[k] = v;
6074
6532
  return merged;
6075
6533
  }
6534
+ /** Replaces a redacted argv VALUE wherever this module masks one. */
6535
+ const REDACTED_ARGV_VALUE = "***";
6536
+ /**
6537
+ * `docker` argv flags whose NEXT token is a `KEY=VALUE` pair built from
6538
+ * user-supplied template data, and whose VALUE therefore must not reach a
6539
+ * user-visible error string:
6540
+ *
6541
+ * - `-e` / `--env` — a container's `Environment.Variables` (Lambda) or
6542
+ * `ContainerDefinition.Environment` (ECS), plus `--env-vars` overrides.
6543
+ * Values that cdkd classifies as sensitive never get here at all
6544
+ * ({@link partitionSensitiveEnv} emits those as a value-less `-e KEY`),
6545
+ * but everything else does: connection strings, endpoints, API base URLs.
6546
+ * - `--opt` — `DockerVolumeConfiguration.DriverOpts`, which for the `local`
6547
+ * driver carries mount options (`o=addr=…,username=…,password=…`).
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.
6564
+ *
6565
+ * A flag NOT in this list keeps its value. For most of the argv that is
6566
+ * because the value is cdkd-authored or infrastructure-shaped — a container
6567
+ * id, an image ref, a `--subnet` CIDR, a `--format` template — and is the
6568
+ * diagnostic.
6569
+ *
6570
+ * That is NOT true of the whole remainder, and the residual is recorded here
6571
+ * rather than quietly implied: `--health-cmd`, `--ulimit`, `--link`,
6572
+ * `--entrypoint`, `--workdir` and the positional image + container command are
6573
+ * all template-supplied and still echoed. They stay unmasked deliberately —
6574
+ * each is a COMMAND or a structural knob whose text IS what an operator reads
6575
+ * the failure for, and none is a documented place to put a secret, unlike
6576
+ * `Environment` / `Secrets` / `DriverOpts`. Revisit per flag if a real leak is
6577
+ * found through one; do not widen the set on suspicion, since every addition
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}.
6601
+ */
6602
+ const ARGV_VALUE_BEARING_FLAGS = /* @__PURE__ */ new Set([
6603
+ "-e",
6604
+ "--env",
6605
+ "--opt",
6606
+ "--label",
6607
+ "--build-arg"
6608
+ ]);
6609
+ /**
6610
+ * `docker` argv flags whose NEXT token is a COMMA-SEPARATED `key=value` param
6611
+ * list rather than one `KEY=VALUE` pair ([#2623](https://github.com/go-to-k/cdkd/issues/2623)
6612
+ * security review).
6613
+ *
6614
+ * Only the params in {@link ARGV_PARAM_LIST_LOCATOR_PARAMS} survive, and the
6615
+ * rest of the list is masked — `type=s3,region=us-east-1,bucket=b` IS the
6616
+ * diagnostic ("which backend, where"), and blanking the whole value would
6617
+ * destroy it to hide one field.
6618
+ */
6619
+ const ARGV_PARAM_LIST_FLAGS = /* @__PURE__ */ new Set(["--cache-from", "--cache-to"]);
6620
+ /**
6621
+ * Param names inside an {@link ARGV_PARAM_LIST_FLAGS} value that are LOCATORS
6622
+ * and therefore survive. **Everything else in the list is masked** — this is
6623
+ * an ALLOWLIST, and it is one deliberately.
6624
+ *
6625
+ * The first cut was a denylist of the credential params BuildKit's backends
6626
+ * spell (`secret_access_key`, `access_key_id`, `session_token`, `token`), and
6627
+ * both round-2 reviewers broke it the same way:
6628
+ * `DockerCacheOption.params` is an arbitrary user map that
6629
+ * `cacheOptionToFlag` joins with a bare `,` and no quoting, so a value
6630
+ * CONTAINING a comma (`token=aa,bb`, or BuildKit's own legal
6631
+ * `secret_access_key="ab,cd"`) split into a masked head and a bare tail that
6632
+ * printed verbatim — output that LOOKS redacted. Patching that one shape is
6633
+ * the enumerate-bad-shapes treadmill; inverting the test ends it, because a
6634
+ * CSV continuation fragment has no recognised param name and so masks by
6635
+ * construction.
6636
+ *
6637
+ * The trade is the right way round for a leak fix: a param missing from this
6638
+ * list costs one degraded diagnostic, a param missing from a denylist costs a
6639
+ * printed credential. The list covers every backend BuildKit ships —
6640
+ * `registry` (`ref`), `local` (`dest` / `src` / `digest` / `tag`), `s3`
6641
+ * (`region` / `bucket` / `name` / the prefixes / `use_path_style`), `azblob`
6642
+ * (`account_name` / `name` / `prefix`), `gha` (`scope` / `timeout`),
6643
+ * `inline` (none) — plus the shared export options, so the common case masks
6644
+ * nothing.
6645
+ *
6646
+ * The four URL-valued locators live in {@link ARGV_PARAM_LIST_URL_PARAMS}
6647
+ * instead: they survive only after their userinfo and query are stripped. An
6648
+ * earlier revision kept them HERE, whole, arguing the repo's locator policy;
6649
+ * that paragraph is gone rather than softened, because azblob makes it false.
6650
+ *
6651
+ * Matched case-INSENSITIVELY on the trimmed param name — the comparison is
6652
+ * over a user-supplied key and BuildKit's own option parsing is
6653
+ * case-insensitive, so a `Secret_Access_Key` spelling must not walk past.
6654
+ */
6655
+ const ARGV_PARAM_LIST_LOCATOR_PARAMS = /* @__PURE__ */ new Set([
6656
+ "type",
6657
+ "mode",
6658
+ "compression",
6659
+ "compression-level",
6660
+ "force-compression",
6661
+ "ignore-error",
6662
+ "image-manifest",
6663
+ "oci-mediatypes",
6664
+ "timeout",
6665
+ "ref",
6666
+ "dest",
6667
+ "src",
6668
+ "digest",
6669
+ "tag",
6670
+ "region",
6671
+ "bucket",
6672
+ "name",
6673
+ "prefix",
6674
+ "manifests_prefix",
6675
+ "blobs_prefix",
6676
+ "use_path_style",
6677
+ "upload_parallelism",
6678
+ "touch_refresh",
6679
+ "account_name",
6680
+ "scope"
6681
+ ]);
6682
+ /**
6683
+ * Locator params whose value is a URL, and which therefore survive only after
6684
+ * their USERINFO, QUERY and FRAGMENT are stripped — and only when the value
6685
+ * parses at all
6686
+ * ([#2623](https://github.com/go-to-k/cdkd/issues/2623) round-3 security
6687
+ * review).
6688
+ *
6689
+ * These were plain allowlist entries for one round, on the reasoning that a
6690
+ * credential-in-URL is an incidental hazard shared with `--build-context` and
6691
+ * the positional image ref. That reasoning does not survive contact with
6692
+ * azblob: BuildKit builds an UNAUTHENTICATED client from `account_url` when
6693
+ * `secret_access_key` is absent, so a SAS token in its query string is that
6694
+ * backend's SUPPORTED auth path, not an accident. With every other param now
6695
+ * fail-closed, leaving the whole URL was the one entry carrying credential
6696
+ * material by design.
6697
+ *
6698
+ * Scheme and host survive, which is what "which endpoint" needs — on the
6699
+ * ordinary path. FOUR things mask more than that, and every one of them also
6700
+ * masks each param after it in the same value, because they all set `masked`.
6701
+ * Three take the value WHOLE: no recognisable `//` authority; a host that is
6702
+ * really `user:password` with its `@` cut away; and a later param carrying
6703
+ * this URL's severed `@` (the caller's rule, since only it still holds the
6704
+ * parts). The fourth is milder in what it keeps, not in what it cascades — a
6705
+ * `@` in the PATH costs the host but keeps scheme and tail (`https://h/x@y`
6706
+ * -> `https://***@y`). Over-masking is the deliberate direction: see
6707
+ * {@link redactUrlLocator} for what each boundary rule leaked before this one.
6708
+ */
6709
+ const ARGV_PARAM_LIST_URL_PARAMS = /* @__PURE__ */ new Set([
6710
+ "account_url",
6711
+ "endpoint_url",
6712
+ "url",
6713
+ "url_v2"
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
+ /**
6854
+ * Structural, whitespace-delimited form of {@link ARGV_VALUE_BEARING_FLAGS}
6855
+ * for scanning a STRING that embeds a space-joined argv. Keyed on the FLAG's
6856
+ * position — never on the secret's value, so an unrelated literal that merely
6857
+ * coincides with a value is left alone.
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
+ *
6885
+ * What each piece actually buys, since one of them was mis-credited in review:
6886
+ * the `(^|\s)` lead is what stops `-e` matching inside `--env` (there is no
6887
+ * `m` flag, so `^` is start-of-INPUT), NOT the alternation order — reordering
6888
+ * the branches leaves every test green. The order is kept as belt and braces
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
+ })();
6907
+ /**
6908
+ * Return a copy of `args` with the VALUE of every
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)).
6921
+ * A value-less `-e KEY` (the form {@link partitionSensitiveEnv} emits for a
6922
+ * sensitive key) has nothing to mask and is returned unchanged. `args` is
6923
+ * never mutated: the redacted copy is for DISPLAY only, never for `spawn`.
6924
+ */
6925
+ function redactDockerArgvValues(args) {
6926
+ const out = [];
6927
+ for (let i = 0; i < args.length; i++) {
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
+ }
6942
+ const next = args[i + 1];
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);
6947
+ i++;
6948
+ continue;
6949
+ }
6950
+ }
6951
+ out.push(cur);
6952
+ }
6953
+ return out;
6954
+ }
6955
+ /**
6956
+ * Redact argv-borne values inside an error TEXT before it reaches the user.
6957
+ *
6958
+ * **Wrap EVERY `execFile`-derived docker failure text in this**, including one
6959
+ * whose argv carries no user data today — `execFile` puts the WHOLE command
6960
+ * line into `err.message` (`Command failed: <file> <args joined by ' '>\n<stderr>`,
6961
+ * measured on Node 24), so any argv that later gains a `-e` pair starts leaking
6962
+ * with no edit to the error site. That silent-gap shape is exactly what
6963
+ * [#2440](https://github.com/go-to-k/cdkd/issues/2440) reported: the debug log
6964
+ * one screen earlier redacted the same argv and the error path did not.
6965
+ *
6966
+ * Two passes, both structural (positional), never value-based — matching on a
6967
+ * secret's VALUE would also blank an unrelated string that happens to equal it:
6968
+ *
6969
+ * 1. **Exact command-line substitution** (only when `args` is given). The raw
6970
+ * `args.join(' ')` is replaced by {@link redactDockerArgvValues}' rendering
6971
+ * of the same array. This is the pass that matters, because it is the only
6972
+ * one that can mask a value CONTAINING WHITESPACE (`-e CFG={"a": 1}`), which
6973
+ * no token scan of a space-joined string can delimit.
6974
+ * 2. **Token scan** ({@link ARGV_VALUE_TOKEN_RE}) over the result. Catches an
6975
+ * argv echoed in a shape pass 1 cannot see — a future Node message format,
6976
+ * a docker stderr quoting the flag back, or a call site with no `args` to
6977
+ * hand. Deliberately fail-loud rather than fail-open: a `-e X=y` occurring
6978
+ * in unrelated stderr prose loses its value and keeps its key.
6979
+ *
6980
+ * Idempotent (`-e KEY=***` re-masks to itself), so double-wrapping is safe.
6981
+ *
6982
+ * **Call sites should use a composer** ({@link describeDockerFailure} and its
6983
+ * siblings) rather than this function: the composers take a REQUIRED `args`,
6984
+ * which is what makes the redaction impossible to forget. This is the
6985
+ * primitive they are built from, exported so the four passes can be tested
6986
+ * against Node's real message shapes directly — it has no other caller in
6987
+ * `src/`, and that is deliberate rather than an oversight.
6988
+ */
6989
+ function redactDockerArgvInText(text, args) {
6990
+ let out = text;
6991
+ if (args && args.length > 0) {
6992
+ const maskedArgs = redactDockerArgvValues(args);
6993
+ const raw = args.join(" ");
6994
+ const masked = maskedArgs.join(" ");
6995
+ if (masked !== raw) out = out.split(raw).join(masked);
6996
+ for (let i = 0; i < args.length; i++) {
6997
+ const rawArg = args[i];
6998
+ const maskedArg = maskedArgs[i];
6999
+ if (maskedArg === rawArg) continue;
7000
+ if (!isSubstitutableToken(rawArg)) continue;
7001
+ out = out.split(rawArg).join(maskedArg);
7002
+ const escapedRaw = nodeQuotedRendering(rawArg);
7003
+ const escapedMasked = nodeQuotedRendering(maskedArg);
7004
+ if (escapedRaw !== void 0 && escapedMasked !== void 0 && escapedRaw !== rawArg) out = out.split(escapedRaw).join(escapedMasked);
7005
+ }
7006
+ out = repairSpawnRefusal(out, args, maskedArgs);
7007
+ }
7008
+ return out.replace(ARGV_VALUE_TOKEN_RE, (_match, lead, flag, gap, key) => `${lead}${flag}${gap}${key}=${REDACTED_ARGV_VALUE}`);
7009
+ }
7010
+ /**
7011
+ * Shortest VALUE that pass 1b will substitute as a bare token.
7012
+ *
7013
+ * Pass 1b's needle is the whole `KEY=VALUE` element, and it is replaced
7014
+ * EVERYWHERE in the text — so a tiny needle is a liability rather than a
7015
+ * protection. The empty-key case makes that concrete: a non-sensitive
7016
+ * `{ Name: '', Value: '1' }` produces the two-character token `=1`, and
7017
+ * substituting that rewrites every `=1` in the message (`--cpus=1`,
7018
+ * `status=1`, a path segment). Below this floor the value is not worth
7019
+ * protecting by substring match, and passes 1 and 3 still cover it in the
7020
+ * message shapes that carry a `-e ` prefix or the joined command line.
7021
+ */
7022
+ const MIN_SUBSTITUTABLE_VALUE_LENGTH = 4;
7023
+ /** Is this `KEY=VALUE` element long enough to substitute as a bare token? */
7024
+ function isSubstitutableToken(arg) {
7025
+ const eqIdx = arg.indexOf("=");
7026
+ if (eqIdx < 0) return false;
7027
+ return arg.length - eqIdx - 1 >= MIN_SUBSTITUTABLE_VALUE_LENGTH;
7028
+ }
7029
+ /**
7030
+ * Render `value` the way Node renders an argv element it quotes back at you —
7031
+ * i.e. with the SAME function Node used, `util.inspect`, minus the quote
7032
+ * characters it chose.
7033
+ *
7034
+ * This started as a hand-rolled `\xNN` escaper and it was WRONG for 16 of the
7035
+ * first 128 code points. Measured against real `execFile` rejections on Node
7036
+ * 24.19.0 (this repo, 2026-09-05): hand-rolled matched 4 of 13 cases,
7037
+ * `inspect` matched 13 of 13.
7038
+ *
7039
+ * input Node emits hand-rolled built
7040
+ * LF `K=a\nb\x00S` `K=a\x0ab\x00S` (Node uses a short escape)
7041
+ * DEL `K=a\x7Fb\x00S` `K=a\x7fb\x00S` (Node uses UPPERCASE hex)
7042
+ * backslash `K=a\\b\x00S` `K=a\b\x00S` (not escaped at all)
7043
+ *
7044
+ * Every miss printed the whole secret. The trigger for this path is a NUL in
7045
+ * a value — binary-ish data, which almost always carries a second control
7046
+ * byte or a backslash — so the table missed the REALISTIC case and hit only
7047
+ * the synthetic NUL-alone one its own fixture happened to use.
7048
+ *
7049
+ * `slice(1, -1)` is safe across the quote Node picks: `inspect` switches
7050
+ * between `'`, `"` and a backtick depending on content and escapes whichever
7051
+ * it chose; because this is the same call Node made, the rendering matches
7052
+ * whatever it picked (verified for a value containing a single quote, a
7053
+ * double quote, both, and a backtick).
7054
+ *
7055
+ * The general lesson, and why this is no longer a table: do not re-implement
7056
+ * another program's formatter — call it.
7057
+ */
7058
+ function nodeQuotedRendering(value) {
7059
+ const rendered = inspect(value);
7060
+ const quote = rendered[0];
7061
+ if (quote === void 0 || !`'"\``.includes(quote)) return void 0;
7062
+ if (rendered.length < 2 || !rendered.endsWith(quote)) return void 0;
7063
+ const inner = rendered.slice(1, -1);
7064
+ return inner.includes(`${quote} +`) ? void 0 : inner;
7065
+ }
7066
+ /**
7067
+ * Node's refusal to spawn, which QUOTES ONE argv element and names its INDEX:
7068
+ *
7069
+ * The argument 'args[2]' must be a string without null bytes. Received '…'
7070
+ *
7071
+ * The one message shape where the index tells us exactly which of OUR args is
7072
+ * being echoed — so the clause can be REWRITTEN from cdkd's own copy of the
7073
+ * element rather than searched for. That matters because Node does not print
7074
+ * the element whole: it truncates near 200 characters and renders a
7075
+ * newline-bearing value as concatenated chunks, so no needle can match it.
7076
+ *
7077
+ * Global, and scanned to exhaustion rather than bailing on the first match.
7078
+ * Anchoring at `^` was an earlier attempt at forgery resistance and it was
7079
+ * WORSE: it made a refusal anywhere but position 0 unrepairable, which is a
7080
+ * LEAK, and a leak beats a truncated message every time. Pinning Node's
7081
+ * literal prefix keeps the forgery bar high without that trade.
7082
+ *
7083
+ * Pinning the wording has its own cost: if Node rewords the message the repair
7084
+ * silently stops firing. That is why `tests/unit/utils/docker-cmd.test.ts`
7085
+ * drives this path from a REAL rejection — a reword turns CI red instead of
7086
+ * turning the redaction off.
7087
+ */
7088
+ const SPAWN_REFUSAL_RE = /The argument 'args\[(\d+)\]'[^']*?Received /g;
7089
+ /**
7090
+ * The quoted element Node prints after `Received `.
7091
+ *
7092
+ * Three shapes, and the third is the one that matters. `inspect` renders a
7093
+ * newline-bearing value as chunks joined by ` +`, and Node then SLICES the
7094
+ * whole rendering at 128 characters and appends `...` — so the last chunk is
7095
+ * usually UNTERMINATED:
7096
+ *
7097
+ * Received 'DB_URL=AAA…\n' +
7098
+ * 'hunter2SECRETCCC...
7099
+ *
7100
+ * A pattern that only accepts complete chunks stops after the second `'` and
7101
+ * leaves that tail in place. Measured on a real rejection: 272 of 288 probe
7102
+ * shapes leaked the secret verbatim, and the end-of-string version this
7103
+ * replaced did not — the bound was a regression, not a hardening, until the
7104
+ * trailing open chunk was admitted.
7105
+ *
7106
+ * The separator is Node's own (` +\n `) with HORIZONTAL space only, and an
7107
+ * open chunk runs to end of LINE. Both are deliberate: `\s*\+\s*` and an
7108
+ * unbounded tail let a crafted value swallow the diagnostic lines that follow
7109
+ * the clause.
7110
+ */
7111
+ const QUOTED_CHUNK = String.raw`'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|\`(?:[^\`\\\n]|\\.)*\``;
7112
+ /** An opening quote whose closing one was truncated away; bounded to its line. */
7113
+ const OPEN_CHUNK = String.raw`['"\`](?:[^\\\n]|\\.)*`;
7114
+ /** Node's chunk join: horizontal space, `+`, at most one newline, horizontal space. */
7115
+ const CHUNK_SEPARATOR = String.raw`[^\S\n]*\+[^\S\n]*\n?[^\S\n]*`;
7116
+ const QUOTED_ELEMENT_RE = new RegExp(`^(?:(?:${QUOTED_CHUNK})(?:${CHUNK_SEPARATOR}(?:${QUOTED_CHUNK}))*(?:${CHUNK_SEPARATOR}(?:${OPEN_CHUNK}))?|(?:${OPEN_CHUNK}))`);
7117
+ /**
7118
+ * Replace the quoted argv element in a spawn-refusal message with the masked
7119
+ * rendering of the arg its OWN index names.
7120
+ *
7121
+ * Known cosmetic effect: the replacement prints the FULL key where Node had
7122
+ * truncated, so a pathologically long env-var NAME makes the message longer
7123
+ * than Node's ~200 characters. Keys are not secret, and the width is not worth
7124
+ * a second truncation rule with its own edge cases.
7125
+ */
7126
+ function repairSpawnRefusal(text, args, maskedArgs) {
7127
+ const scanner = new RegExp(SPAWN_REFUSAL_RE.source, "g");
7128
+ let out = "";
7129
+ let cursor = 0;
7130
+ for (let match = scanner.exec(text); match !== null; match = scanner.exec(text)) {
7131
+ if (match.index < cursor) continue;
7132
+ const index = Number(match[1]);
7133
+ const rawArg = args[index];
7134
+ const maskedArg = maskedArgs[index];
7135
+ if (rawArg !== void 0 && maskedArg === rawArg) continue;
7136
+ const clauseEnd = match.index + match[0].length;
7137
+ const quoted = QUOTED_ELEMENT_RE.exec(text.slice(clauseEnd));
7138
+ if (quoted === null) continue;
7139
+ const replacement = maskedArg === void 0 ? `'${REDACTED_ARGV_VALUE}'` : inspect(maskedArg);
7140
+ out += text.slice(cursor, clauseEnd) + replacement;
7141
+ cursor = clauseEnd + quoted[0].length;
7142
+ scanner.lastIndex = cursor;
7143
+ }
7144
+ return out + text.slice(cursor);
7145
+ }
7146
+ /**
7147
+ * Compose a user-visible description of a `child_process` rejection from a
7148
+ * docker call, ALREADY REDACTED against that call's own argv.
7149
+ *
7150
+ * Moved here from `src/local/invoke-agentcore-watch-loop.ts` in issue #2440's
7151
+ * review round 3, and the move is the point: `args` is REQUIRED, so a call
7152
+ * site cannot obtain the text without handing over the argv to redact it
7153
+ * with. That is a stronger guarantee than any text fence over the call sites
7154
+ * — which is what the round-2 reviewers demonstrated, by writing four
7155
+ * spellings of an unredacted read that the fence could not see.
7156
+ *
7157
+ * Shape differs from the `stderr || message` composition the other sites use,
7158
+ * and deliberately so: `err.stderr` is where docker writes its actionable
7159
+ * diagnostics, while `err.message` carries the exit status, so the AgentCore
7160
+ * soft-reload path APPENDS rather than prefers — without stderr the wrapped
7161
+ * error would only say "Command failed with exit code N".
7162
+ */
7163
+ function describeDockerExecFailure(error, args) {
7164
+ const message = thrownMessageText(error) || safeStringify(error);
7165
+ const stderrText = capturedStreamText(error, "stderr");
7166
+ return redactDockerArgvInText(stderrText ? `${message}\n${stderrText}` : message, args);
7167
+ }
7168
+ /**
7169
+ * A captured stream of a `child_process` rejection as trimmed text, or `''`.
7170
+ *
7171
+ * Takes `unknown`, not `Error`, and duck-types the field. Every composer here
7172
+ * is called from a `catch`, where the value is whatever was thrown — and the
7173
+ * shapes the call sites actually see are plain objects
7174
+ * (`{ stderr, message }`), `SpawnError`, and cross-realm `Error`s that
7175
+ * `instanceof` misses. An earlier revision narrowed this to `Error` and the
7176
+ * standard composer then wrapped non-Errors in a FRESH `Error`, which has no
7177
+ * `.stderr` at all — so the whole diagnostic silently became
7178
+ * `'[object Object]'` for exactly the shape most of the call sites throw.
7179
+ *
7180
+ * `execFile` hands back a string under the default encoding and a `Buffer`
7181
+ * under `encoding: 'buffer'`; `ArrayBuffer.isView` covers a plain
7182
+ * `Uint8Array` too.
7183
+ */
7184
+ function capturedStreamText(error, field) {
7185
+ try {
7186
+ const raw = error?.[field];
7187
+ if (typeof raw === "string") return raw.trim();
7188
+ if (ArrayBuffer.isView(raw)) return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString("utf8").trim();
7189
+ } catch {}
7190
+ return "";
7191
+ }
7192
+ /** A thrown value's `message` as text, or `''`. Duck-typed, for the same reason. */
7193
+ function thrownMessageText(error) {
7194
+ try {
7195
+ const raw = error?.["message"];
7196
+ return typeof raw === "string" ? raw : "";
7197
+ } catch {
7198
+ return "";
7199
+ }
7200
+ }
7201
+ /**
7202
+ * `String(value)` that cannot itself throw. A rejection with a null prototype
7203
+ * (or a `toString` that throws) would otherwise blow up INSIDE a `catch` — and
7204
+ * two of these call sites are in `cleanupEcsRun`, where an exception aborts
7205
+ * the remaining volume / network teardown and leaks real Docker resources.
7206
+ */
7207
+ function safeStringify(error) {
7208
+ try {
7209
+ return String(error);
7210
+ } catch {
7211
+ return "[unstringifiable rejection]";
7212
+ }
7213
+ }
7214
+ /**
7215
+ * The STANDARD composer for a docker failure text: the captured stderr if
7216
+ * there is any, else the error's own message, else its string form —
7217
+ * ALREADY REDACTED against that call's argv.
7218
+ *
7219
+ * Use this at every site that wraps a docker `execFile` / spawn rejection.
7220
+ * `args` is REQUIRED, which is the whole point: a call site cannot obtain the
7221
+ * text without handing over the argv to redact it with, so the guarantee is
7222
+ * type-checked rather than fenced. Issue #2440's review spent three rounds
7223
+ * showing that a text fence over hand-composed sites is evadable — an
7224
+ * intermediate variable, an inline cast, a destructure, a computed member, a
7225
+ * concat — while this shape has nothing to evade.
7226
+ *
7227
+ * Prefer stderr over message because docker writes its actionable diagnostic
7228
+ * there, and `execFile`'s message is mostly the command line plus an exit
7229
+ * status. {@link describeDockerExecFailure} keeps BOTH, for a caller whose
7230
+ * wrapper text needs the status as well.
7231
+ */
7232
+ function describeDockerFailure(error, args) {
7233
+ return redactDockerArgvInText(capturedStreamText(error, "stderr") || thrownMessageText(error) || safeStringify(error), args);
7234
+ }
7235
+ /**
7236
+ * Composer for a captured-output failure where the diagnostic may be on
7237
+ * STDOUT rather than stderr (`runDockerStreaming`'s non-zero-exit path, whose
7238
+ * `SpawnError` carries both). `fallback` is used when neither stream said
7239
+ * anything. Redacted, and `args` is required, for the same reason as
7240
+ * {@link describeDockerFailure}.
7241
+ */
7242
+ function describeDockerCapturedOutput(error, args, fallback) {
7243
+ return redactDockerArgvInText(capturedStreamText(error, "stderr") || capturedStreamText(error, "stdout") || fallback, args);
7244
+ }
6076
7245
 
6077
7246
  //#endregion
6078
7247
  //#region src/assets/docker-build.ts
@@ -6098,32 +7267,39 @@ async function buildDockerImage(asset, cdkOutDir, options) {
6098
7267
  const [cmd, ...args] = source.executable;
6099
7268
  if (!cmd) throw options.wrapError("asset source.executable[] is empty");
6100
7269
  const cwd = source.directory ? `${cdkOutDir}/${source.directory}` : cdkOutDir;
6101
- 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})`);
6102
7272
  let result;
6103
7273
  try {
6104
7274
  result = await spawnStreaming(cmd, args, { cwd });
6105
7275
  } catch (err) {
6106
- const e = err;
6107
- throw options.wrapError(e.stderr || e.message || String(err));
7276
+ throw options.wrapError(describeDockerFailure(err, args));
6108
7277
  }
6109
7278
  const tag = result.stdout.trim();
6110
- 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}`);
6111
7280
  return tag;
6112
7281
  }
6113
- 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
+ }
6114
7291
  if (!options.tag) throw options.wrapError("buildDockerImage(directory mode) requires options.tag");
6115
7292
  const buildArgs = buildDockerBuildCommand(source, options.tag, options.platform);
6116
7293
  const contextDir = `${cdkOutDir}/${source.directory}`;
6117
7294
  buildArgs.push(".");
6118
- logger.debug(`${getDockerCmd()} ${buildArgs.join(" ")} (cwd=${contextDir})`);
7295
+ logger.debug(`${getDockerCmd()} ${redactDockerArgvValues(buildArgs).join(" ")} (cwd=${contextDir})`);
6119
7296
  try {
6120
7297
  await runDockerStreaming(buildArgs, {
6121
7298
  cwd: contextDir,
6122
7299
  env: { BUILDX_NO_DEFAULT_ATTESTATIONS: "1" }
6123
7300
  });
6124
7301
  } catch (err) {
6125
- const e = err;
6126
- throw options.wrapError(e.stderr || e.message || String(err));
7302
+ throw options.wrapError(describeDockerFailure(err, buildArgs));
6127
7303
  }
6128
7304
  return options.tag;
6129
7305
  }
@@ -6291,8 +7467,9 @@ var DockerAssetPublisher = class {
6291
7467
  }
6292
7468
  /**
6293
7469
  * Build Docker image — delegates to the shared `buildDockerImage`
6294
- * helper so this code path stays in sync with `cdkd local invoke`'s
6295
- * 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
6296
7473
  * manifest's `source.platform` (when set); cdkd does not currently
6297
7474
  * inject a publish-side override.
6298
7475
  *
@@ -6376,33 +7553,33 @@ var DockerAssetPublisher = class {
6376
7553
  const [username, password] = Buffer.from(authData.authorizationToken, "base64").toString().split(":");
6377
7554
  if (!username || password === void 0) throw new AssetError("ECR authorization token has unexpected shape (missing username/password)");
6378
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
+ ];
6379
7563
  try {
6380
- await runDockerStreaming([
6381
- "login",
6382
- "--username",
6383
- username,
6384
- "--password-stdin",
6385
- endpoint
6386
- ], { input: password });
7564
+ await runDockerStreaming(loginArgs, { input: password });
6387
7565
  loggedInRegistries.add(registryKey);
6388
7566
  } catch (err) {
6389
- const e = err;
6390
- 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)}`);
6391
7568
  }
6392
7569
  }
6393
7570
  /**
6394
7571
  * Tag Docker image
6395
7572
  */
6396
7573
  async tagImage(source, target) {
7574
+ const tagArgs = [
7575
+ "tag",
7576
+ source,
7577
+ target
7578
+ ];
6397
7579
  try {
6398
- await runDockerStreaming([
6399
- "tag",
6400
- source,
6401
- target
6402
- ]);
7580
+ await runDockerStreaming(tagArgs);
6403
7581
  } catch (err) {
6404
- const e = err;
6405
- throw new AssetError(`Docker tag failed: ${e.stderr?.trim() || e.message || String(err)}`);
7582
+ throw new AssetError(`Docker tag failed: ${describeDockerFailure(err, tagArgs)}`);
6406
7583
  }
6407
7584
  }
6408
7585
  /**
@@ -6412,11 +7589,11 @@ var DockerAssetPublisher = class {
6412
7589
  */
6413
7590
  async pushImage(uri) {
6414
7591
  this.logger.debug(`Pushing: ${uri}`);
7592
+ const pushArgs = ["push", uri];
6415
7593
  try {
6416
- await runDockerStreaming(["push", uri]);
7594
+ await runDockerStreaming(pushArgs);
6417
7595
  } catch (err) {
6418
- const e = err;
6419
- throw new AssetError(`Docker push failed: ${e.stderr?.trim() || e.message || String(err)}`);
7596
+ throw new AssetError(`Docker push failed: ${describeDockerFailure(err, pushArgs)}`);
6420
7597
  }
6421
7598
  }
6422
7599
  /**
@@ -7154,49 +8331,6 @@ var AssetModeResolver = class {
7154
8331
  }
7155
8332
  };
7156
8333
 
7157
- //#endregion
7158
- //#region src/utils/regexp.ts
7159
- /**
7160
- * Escape a literal string for embedding in a RegExp.
7161
- *
7162
- * Lives here because the same four-line helper was spelled THREE times — in
7163
- * `src/utils/ecr-uri.ts`, `src/cli/commands/gc.ts` and
7164
- * `src/assets/asset-redirect.ts` — each interpolating a user-controlled name
7165
- * (an AWS region, a bootstrap-marker asset bucket / container repo, an ECR host
7166
- * label) into a pattern. Copies of a security-shaped helper are exactly the
7167
- * shape that drifts: the escaped set is what stops a `.` in an interpolated
7168
- * literal matching ANY character, and in `gc.ts` a widened match decides which
7169
- * live assets are treated as referenced.
7170
- *
7171
- * The character set is the union every JS engine treats as special in a
7172
- * non-`u` pattern. `-` is deliberately absent: it is only special INSIDE a
7173
- * character class, and no caller embeds into one.
7174
- */
7175
- function escapeRegExp$1(value) {
7176
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7177
- }
7178
- /**
7179
- * Remove control characters from a value about to be PRINTED.
7180
- *
7181
- * Lives beside {@link escapeRegExp} for the reason that helper's own note
7182
- * gives: this was spelled twice — in `src/cli/commands/diff-recursive.ts` and
7183
- * in `src/deployment/outputs-export-alias.ts` — and copies of a
7184
- * security-shaped helper drift. Both print the same class of string: a
7185
- * resolved CloudFormation Output / `Export.Name`, which passed no CFn
7186
- * validator, so it can carry ANSI escapes or bidi overrides straight into a
7187
- * terminal or a CI log.
7188
- *
7189
- * C0 + DEL + C1 + the bidi marks and isolates. Apply on HUMAN-render paths
7190
- * only: a `--json` payload is a machine interface where an export name is data
7191
- * a consumer may match on, and mutating it there trades a correctness
7192
- * regression for a display concern (see `stripDisplayOnlyChars`, which is the
7193
- * narrower guard for an already-serialized payload and stays local to its
7194
- * caller because its C0 exclusion is specific to that path).
7195
- */
7196
- function stripControlChars(value) {
7197
- return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
7198
- }
7199
-
7200
8334
  //#endregion
7201
8335
  //#region src/assets/asset-redirect.ts
7202
8336
  /**
@@ -8078,6 +9212,16 @@ function formatRemaining(ms) {
8078
9212
  if (minutes < 1) return "in under a minute";
8079
9213
  return `in ~${minutes}m`;
8080
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
+ */
8081
9225
  function shellQuote(value) {
8082
9226
  return /^[A-Za-z0-9._/@:+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
8083
9227
  }
@@ -8097,6 +9241,18 @@ function buildForceUnlockCommand(stackName, region, recovery) {
8097
9241
  return parts.join(" ");
8098
9242
  }
8099
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
+ /**
8100
9256
  * The force-quit banner's recovery sentence.
8101
9257
  *
8102
9258
  * Exported so the two `destroy-runner.ts` banners do not each decide what to
@@ -8107,7 +9263,7 @@ function buildForceUnlockCommand(stackName, region, recovery) {
8107
9263
  */
8108
9264
  function forceQuitRecoveryClause(stackName, region, recovery) {
8109
9265
  const command = buildForceUnlockCommand(stackName, region, recovery);
8110
- 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}`;
8111
9267
  }
8112
9268
  /**
8113
9269
  * Build the contention message, reading the holder's identity best-effort.
@@ -9355,7 +10511,7 @@ var LockManager = class {
9355
10511
  }
9356
10512
  return false;
9357
10513
  }
9358
- 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);
9359
10515
  }
9360
10516
  }
9361
10517
  /**
@@ -9579,8 +10735,11 @@ var LockManager = class {
9579
10735
  /**
9580
10736
  * Force release a lock regardless of owner or expiry status
9581
10737
  *
9582
- * This is intended for CLI usage (e.g., --force-unlock flag) when a lock
9583
- * 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.
9584
10743
  *
9585
10744
  * Pass `region: undefined` to operate on a legacy
9586
10745
  * `{prefix}/{stackName}/lock.json` file.
@@ -9923,7 +11082,7 @@ var LockManager = class {
9923
11082
  if (lockInfo) {
9924
11083
  const remainingMs = lockInfo.expiresAt - Date.now();
9925
11084
  if (attempt < maxRetries) {
9926
- 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})`);
9927
11086
  await new Promise((resolve) => setTimeout(resolve, retryDelay));
9928
11087
  continue;
9929
11088
  }
@@ -9931,7 +11090,9 @@ var LockManager = class {
9931
11090
  }
9932
11091
  const lockInfo = await this.getLockInfo(stackName, region);
9933
11092
  const expiresIn = lockInfo ? this.formatDuration(lockInfo.expiresAt - Date.now()) : "unknown";
9934
- 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}`));
9935
11096
  }
9936
11097
  };
9937
11098
 
@@ -10685,7 +11846,7 @@ function storeAssociation(associations, key, expression, plaintext) {
10685
11846
  * matters: an AWS resource type string is fixed by AWS, and a typo in either
10686
11847
  * copy makes that copy's gate simply never fire (no-op), never fire wrongly.
10687
11848
  */
10688
- const NESTED_STACK_RESOURCE_TYPE$1 = "AWS::CloudFormation::Stack";
11849
+ const NESTED_STACK_RESOURCE_TYPE$2 = "AWS::CloudFormation::Stack";
10689
11850
  /**
10690
11851
  * What a PARENT stack's resolution proved about each `Parameters` entry of an
10691
11852
  * `AWS::CloudFormation::Stack` row it is about to provision, keyed by the
@@ -10792,7 +11953,7 @@ const nestedStackParameterExpressions = /* @__PURE__ */ new WeakMap();
10792
11953
  * `secret-redaction-nested-parameter-source.test.ts`.
10793
11954
  */
10794
11955
  function recordNestedStackParameterExpressions(secrets, resourceType, resolvedProperties, sourceProperties, rules = TEMPLATE_DERIVED_RULES) {
10795
- if (resourceType !== NESTED_STACK_RESOURCE_TYPE$1) return;
11956
+ if (resourceType !== NESTED_STACK_RESOURCE_TYPE$2) return;
10796
11957
  if (secrets.size === 0) return;
10797
11958
  if (!isPlainObject$2(resolvedProperties) || !isPlainObject$2(sourceProperties)) return;
10798
11959
  if (!Object.hasOwn(resolvedProperties, "Parameters")) return;
@@ -15970,9 +17131,28 @@ function secretsManagerSecretId(inner) {
15970
17131
  * - A trailing `:<version>` / `:<label>` is part of the name AS SSM PARSES IT
15971
17132
  * (`GetParameter` accepts `name:3` / `name:prod`), so stripping it would name
15972
17133
  * a different thing in the refusal message than the one that would be read.
17134
+ *
17135
+ * A COLON-LESS body (`{{resolve:ssm}}` / `{{resolve:ssm-secure}}`) names no
17136
+ * parameter at all, and the empty string is what says so: the caller reads a
17137
+ * falsy name as `local`, which hands the reference on to the resolver's own
17138
+ * `PARAMETER_NAME is required` — thrown BEFORE any client is built, so no
17139
+ * `GetParameter` is ever issued for such a token. Without the guard
17140
+ * `indexOf(':')` is `-1` and `substring(0)` returns the SERVICE STRING as the
17141
+ * parameter name, so with a foreign producer region on record the same
17142
+ * malformed input drew the ambiguous-region refusal naming `'ssm-secure'` as
17143
+ * the secret. {@link secretsManagerSecretId} already answers `''` for its own
17144
+ * nameless body (a fixed-length `substring` past the end of the string), and a
17145
+ * sibling that answers something else is the kind of split a later reader has
17146
+ * to rediscover.
17147
+ *
17148
+ * The colon-less relaxation has a CONSUMER-VISIBLE consequence on a mixed
17149
+ * leaf, and it is recorded on {@link classifyReplaySecretRegion} rather than
17150
+ * here: this function produces a NAME, and the delta belongs beside the
17151
+ * VERDICT a reader arrives at it through.
15973
17152
  */
15974
17153
  function ssmParameterName(inner) {
15975
- return inner.substring(inner.indexOf(":") + 1);
17154
+ const serviceEnd = inner.indexOf(":");
17155
+ return serviceEnd < 0 ? "" : inner.substring(serviceEnd + 1);
15976
17156
  }
15977
17157
  /**
15978
17158
  * The region an ARN names, or `undefined` for anything that is not an ARN with
@@ -16110,6 +17290,101 @@ function producerRegionsFromState(state) {
16110
17290
  * A same-region ARN answers `local` even when a foreign producer region IS on
16111
17291
  * record: the expression settles the question itself, so the weaker evidence
16112
17292
  * never gets consulted.
17293
+ *
17294
+ * ---
17295
+ *
17296
+ * WHO CONSUMES THIS VERDICT, and what the issue #2501 colon-less fix changed
17297
+ * for them. Recorded here because a reader arrives at the blast radius through
17298
+ * the verdict, not through {@link ssmParameterName}, which merely produces a
17299
+ * name.
17300
+ *
17301
+ * Three LEAF PRE-PASSES consume it — `rollback-executor.ts`'s
17302
+ * `resolveLeafByRegion`, `cdkd drift`'s `resolveDriftLeafByRegion` and `cdkd
17303
+ * scrub`'s — and each refuses the WHOLE leaf as soon as ONE token classifies
17304
+ * `ambiguous`, before any token is fetched. There is a FOURTH consumer, and it
17305
+ * is not a pre-pass: `resolveDynamicReferences` classifies token-by-token
17306
+ * INSIDE its substitution loop, so it already fetches earlier tokens before
17307
+ * refusing a later one. That is why the pre-fetch refusal was never a designed
17308
+ * property of this verdict.
17309
+ *
17310
+ * Before the fix, a colon-less `{{resolve:ssm-secure}}` produced the SERVICE
17311
+ * STRING as its name, so with a foreign producer region on record it drew an
17312
+ * `ambiguous` verdict and the pre-pass refused the whole leaf: for
17313
+ * `local=<same-region ARN>;secure={{resolve:ssm-secure}}`, nothing was
17314
+ * resolved. It now answers `local`, so the leaf IS resolved — by the primary
17315
+ * resolver when every other token is `local`, or through the segment-rebuild
17316
+ * path when one is `named-region` — and the ARN sibling's plaintext is fetched
17317
+ * and cached before the degenerate token throws `PARAMETER_NAME is required`.
17318
+ * Pinned by `rollback-executor-cross-region-secret.test.ts`'s "the MIXED-leaf
17319
+ * delta the colon-less guard accepts", so the trade is visible rather than
17320
+ * argued.
17321
+ *
17322
+ * Accepted on three grounds, the third being a LIMIT rather than a
17323
+ * reassurance:
17324
+ *
17325
+ * 1. The lost refusal was an ACCIDENT of the mis-parse, not a designed
17326
+ * protection: with no foreign producer region on record — the
17327
+ * overwhelmingly common case — the colon-less token classified `local`
17328
+ * before the fix too, so the same sibling was already fetched.
17329
+ * 2. The sibling is fetched from the region its OWN verdict names, never a
17330
+ * guessed one (a name-form sibling would itself be `ambiguous` and the
17331
+ * pre-pass would still refuse), so nothing here weakens the issue #1957
17332
+ * rule this module exists to enforce. Nothing fetched reaches a DURABLE
17333
+ * sink — `state.json`, the rollback journal, the `deployments/` event
17334
+ * store, a `--json` payload — and the reason is NOT "the op throws before
17335
+ * any write", which is false for scrub (see 3: scrub swallows and keeps
17336
+ * writing). It is that both places a fetched plaintext is retained are
17337
+ * IN-PROCESS and neither is copied out: the resolver's own
17338
+ * `cachedDynamicReferences` (instance-scoped since issue #1933, so it dies
17339
+ * with the resolver) and `recordedSecretValues`, which every persist path
17340
+ * consults as a redaction NEEDLE set — an entry there causes a value to be
17341
+ * REPLACED BY its expression on the way out, never inserted.
17342
+ *
17343
+ * ON THIS PATH one more needle can only redact more, which is why the
17344
+ * direction is safe even though it fetches more — and the qualifier is
17345
+ * load-bearing rather than hedging. It is NOT a general property of the
17346
+ * needle machinery: `redactSecretsForState`'s own doc (see the
17347
+ * `preferPositionDecisions` ordering note) records that a needle rewriting
17348
+ * a FRAME ANCHOR can un-certify `unkeyedArrayPairsByAnchors`, refuse the
17349
+ * array, and leave a sibling MIXED leaf in plaintext — "a regression of
17350
+ * shipped redaction, in the GHSA disclosure direction". That is reachable
17351
+ * with a NON-EMPTY map on a `STATE_SOURCED_READBACK_RULES` caller, e.g.
17352
+ * `rollback-executor.ts`'s `redactRollbackRecord` ->
17353
+ * `scrubResourceRecord`. It is not what fires here: the case this
17354
+ * paragraph is about records FEWER needles, not more, so nothing new can
17355
+ * rewrite an anchor.
17356
+ * 3. WHAT THE OP DOES NEXT IS NOT UNIFORM. The replay fails the op and `cdkd
17357
+ * drift` reports the resource NOT compared — both loud. `cdkd scrub` is
17358
+ * NOT: `isRegionAmbiguousRefusal` re-raises only a
17359
+ * `DynamicReferenceRegionAmbiguousError`, and the resolver's replacement is
17360
+ * a plain `Error`, so scrub swallows it to `debug` and the run reports
17361
+ * clean. A LOUDNESS regression for that one command on this one input,
17362
+ * bounded by the identical silent miss that already happens for the same
17363
+ * leaf whenever no foreign producer region is on record.
17364
+ *
17365
+ * Issue [#2692](https://github.com/go-to-k/cdkd/issues/2692) tracks it, and
17366
+ * the remedy is NOT resolver-local. Two candidate fixes are wrong, each in
17367
+ * its own way, and scrub has TWO predicates that must not be confused:
17368
+ * `isRegionAmbiguousRefusal` decides RE-RAISE vs SWALLOW (its three call
17369
+ * sites), while `isByDesignRefusal` — matching
17370
+ * `CrossAccountSecretRefusalError` — decides FINDING vs REFUSE. They point
17371
+ * in opposite directions, so a fix aimed at one must not be argued from
17372
+ * the other's record.
17373
+ *
17374
+ * - Throwing `IntrinsicResolutionRefusalError` and widening
17375
+ * `isRegionAmbiguousRefusal` to match the BASE class would make every
17376
+ * user-fixable refusal that class carries RE-RAISE and fail the whole
17377
+ * stack — not the silent-downgrade hazard `isByDesignRefusal`'s doc
17378
+ * records, but the opposite over-refusal, on a class with throw sites
17379
+ * spread across the resolver.
17380
+ * - Throwing the region-ambiguous SUBCLASS is wrong differently: scrub
17381
+ * already re-raises it, but its message tells the user to spell the
17382
+ * reference as a full ARN, which cannot fix a reference naming no
17383
+ * parameter.
17384
+ *
17385
+ * So #2692 needs a NEW sibling subclass AND a scrub-side predicate change,
17386
+ * which makes it blocked on `src/cli/commands/scrub.ts` (held by PR
17387
+ * #2562), not merely on the resolver's `integ-broad` cost.
16113
17388
  */
16114
17389
  function classifyReplaySecretRegion(expression, consumerRegion, importedProducerRegions) {
16115
17390
  const inner = expression.startsWith("{{resolve:") ? expression.slice(10, -2) : void 0;
@@ -17803,7 +19078,7 @@ function carriesDynamicReference(value) {
17803
19078
  return false;
17804
19079
  }
17805
19080
  /** The nested-stack resource type, whose `Outputs.<Name>` attributes are re-resolved (issue #2055). */
17806
- const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
19081
+ const NESTED_STACK_RESOURCE_TYPE$1 = "AWS::CloudFormation::Stack";
17807
19082
  /** Prefix `NestedStackProvider` records a child stack output under. */
17808
19083
  const NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX = "Outputs.";
17809
19084
  /**
@@ -19294,7 +20569,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
19294
20569
  return this.noteAttributeSecrecy(logicalId, attributeName, nameServers, context);
19295
20570
  }
19296
20571
  this.logger.debug(`Resolved Fn::GetAtt from attributes: ${logicalId}.${attributeName} -> ${stringifyAttributeForLog(attributeName, flatValue)}`);
19297
- if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX) && carriesDynamicReference(flatValue)) return await this.reresolveCrossStackValue(flatValue, nestedStackChildRegionFromLocalArn(resource.physicalId), context, `nested stack ${logicalId} ${attributeName}`, crossStackSourceKey({ "Fn::GetAtt": getAtt }));
20572
+ if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE$1 && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX) && carriesDynamicReference(flatValue)) return await this.reresolveCrossStackValue(flatValue, nestedStackChildRegionFromLocalArn(resource.physicalId), context, `nested stack ${logicalId} ${attributeName}`, crossStackSourceKey({ "Fn::GetAtt": getAtt }));
19298
20573
  return this.noteAttributeSecrecy(logicalId, attributeName, flatValue, context);
19299
20574
  }
19300
20575
  if (attributeName.includes(".")) {
@@ -19311,7 +20586,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
19311
20586
  }
19312
20587
  }
19313
20588
  }
19314
- if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)) {
20589
+ if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE$1 && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)) {
19315
20590
  const declared = Object.keys(resource.attributes ?? {}).filter((k) => k.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)).map((k) => k.slice(8)).sort();
19316
20591
  throw markNonRetryable(new IntrinsicResolutionRefusalError(`Cannot resolve Fn::GetAtt [${logicalId}, ${attributeName}]: the nested stack '${logicalId}' declares no output named '${attributeName.slice(8)}'. Its outputs are ${declared.length > 0 ? declared.join(", ") : "(none)"}. Check the output name in the nested stack's template, and deploy the child stack again if you have just added it.`));
19317
20592
  }
@@ -22525,7 +23800,7 @@ var CloudControlProvider = class {
22525
23800
  const indeterminateGuard = await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
22526
23801
  if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
22527
23802
  this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
22528
- const { ASGProvider } = await import("./asg-provider-CSEPihOt.js").then((n) => n.n);
23803
+ const { ASGProvider } = await import("./asg-provider-dBSdt3nB.js").then((n) => n.n);
22529
23804
  return withIndeterminateGuard(await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context), indeterminateGuard);
22530
23805
  }
22531
23806
  const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
@@ -24710,7 +25985,7 @@ var CustomResourceProvider = class CustomResourceProvider {
24710
25985
  reuseClientCredentials: true,
24711
25986
  tolerateNonStandardClient: true,
24712
25987
  onRebuild: ({ bucketRegion, currentRegion }) => {
24713
- this.logger.debug(`Custom resource response bucket '${bucket}' is in '${bucketRegion}' (client was '${String(currentRegion)}'); building a region-corrected S3 client for response operations.`);
25988
+ this.logger.debug(`Custom resource response bucket '${displaySafe(bucket, { asciiOnly: true }) || "<unrenderable>"}' is in '${displaySafe(bucketRegion, { asciiOnly: true }) || "<unrenderable>"}' (client was '${displaySafe(currentRegion, { asciiOnly: true }) || "<unrenderable>"}'); building a region-corrected S3 client for response operations.`);
24714
25989
  }
24715
25990
  });
24716
25991
  if (generation !== this.responseClientGeneration) {
@@ -25387,7 +26662,7 @@ var CustomResourceProvider = class CustomResourceProvider {
25387
26662
  Key: responseKey
25388
26663
  });
25389
26664
  const presignedUrl = await getSignedUrl(this.s3Client, command, { expiresIn: 7200 });
25390
- this.logger.debug(`Generated pre-signed URL for response: s3://${this.responseBucket}/${responseKey}`);
26665
+ this.logger.debug(`Generated pre-signed URL for response: s3://${displaySafe(this.responseBucket, { asciiOnly: true }) || "<unrenderable>"}/${displaySafe(responseKey)}`);
25391
26666
  return presignedUrl;
25392
26667
  }
25393
26668
  /**
@@ -25491,7 +26766,7 @@ var CustomResourceProvider = class CustomResourceProvider {
25491
26766
  Key: responseKey
25492
26767
  }));
25493
26768
  } catch (error) {
25494
- this.logger.debug(`Failed to delete custom-resource response object s3://${bucket}/${responseKey}; it remains as a current object. Underlying error: ${error instanceof Error ? error.message : String(error)}`);
26769
+ this.logger.debug(`Failed to delete custom-resource response object s3://${displaySafe(bucket, { asciiOnly: true }) || "<unrenderable>"}/${displaySafe(responseKey)}; it remains as a current object. Underlying error: ${displaySafe(error instanceof Error ? error.message : String(error)) || "<unrenderable>"}`);
25495
26770
  }
25496
26771
  await purgeNoncurrentKeyVersions(this.s3Client, bucket, [responseKey], {
25497
26772
  logger: this.logger,
@@ -28988,6 +30263,70 @@ function getCurrentResourceSecrets() {
28988
30263
  return currentResourceSecretsStore.getStore();
28989
30264
  }
28990
30265
 
30266
+ //#endregion
30267
+ //#region src/deployment/type-change-guard.ts
30268
+ /**
30269
+ * The CFn type of a nested stack's row in its PARENT's template.
30270
+ *
30271
+ * Spelled locally rather than imported, matching
30272
+ * `src/deployment/recreate-targets.ts`: the only exported copy lives in
30273
+ * `src/cli/commands/retire-cfn-stack.ts`, and importing a CLI command module
30274
+ * from the deployment layer would invert the dependency direction.
30275
+ */
30276
+ const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
30277
+ /**
30278
+ * Find every planned change whose recorded type and template type differ with
30279
+ * `AWS::CloudFormation::Stack` on one side.
30280
+ *
30281
+ * Reads exactly the two values the defect is made of: `change.resourceType`
30282
+ * (what `provisionResource` binds and routes BOTH replacement halves on) and
30283
+ * the state record's `resourceType` (the resource that actually exists). That
30284
+ * is deliberate — deriving the "desired" type from the template again would be
30285
+ * a second implementation of the diff's own Type-change rule (metadata skip,
30286
+ * condition pruning) which could drift away from the routing decision this
30287
+ * guards.
30288
+ *
30289
+ * `changeType` is not filtered on. A Type change surfaces as an `UPDATE`, but a
30290
+ * DELETE / NO_CHANGE row cannot diverge in the first place (the diff builds
30291
+ * both from the state record's own type), so filtering would only add a way for
30292
+ * a future change-shape to slip past.
30293
+ */
30294
+ function findNestedStackTypeChanges(input) {
30295
+ const found = [];
30296
+ for (const [logicalId, change] of input.changes) {
30297
+ if (!Object.hasOwn(input.stateResources, logicalId)) continue;
30298
+ const currentResource = input.stateResources[logicalId];
30299
+ if (!currentResource) continue;
30300
+ const currentType = currentResource.resourceType;
30301
+ const desiredType = change.resourceType;
30302
+ if (currentType === desiredType) continue;
30303
+ const intoNested = desiredType === NESTED_STACK_RESOURCE_TYPE;
30304
+ if (!intoNested && !(currentType === NESTED_STACK_RESOURCE_TYPE)) continue;
30305
+ found.push({
30306
+ logicalId,
30307
+ currentType,
30308
+ desiredType,
30309
+ physicalId: currentResource.physicalId,
30310
+ direction: intoNested ? "into-nested-stack" : "out-of-nested-stack"
30311
+ });
30312
+ }
30313
+ return found;
30314
+ }
30315
+ /**
30316
+ * Render the refusal. Names the logical id, BOTH types, the resource the
30317
+ * mis-routed delete would be aimed at, and what to do instead.
30318
+ *
30319
+ * `stackName` is the stack being deployed, so the into-nested arm can print the
30320
+ * child stack name `NestedStackProvider.delete` would derive and destroy — the
30321
+ * one piece of the damage the user cannot read off their own template.
30322
+ */
30323
+ function renderNestedStackTypeChangeRefusal(typeChanges, stackName) {
30324
+ const rows = typeChanges.map((tc) => {
30325
+ return `${` - ${tc.logicalId}: Type changes from ${tc.currentType} to ${tc.desiredType} (the existing ${tc.currentType} is ${tc.physicalId}).`}\n${tc.direction === "into-nested-stack" ? ` Both halves of the replacement would route on the TEMPLATE's type, so the existing resource's delete would be dispatched at the ${NESTED_STACK_RESOURCE_TYPE} provider — which ignores the physical id it is handed and instead destroys the nested child stack "${stackName}~${tc.logicalId}" and every resource that child owns. Where no such child exists the delete is a no-op and ${tc.physicalId} is silently leaked instead.` : ` Both halves of the replacement would route on the TEMPLATE's type, so the existing nested stack's delete would be dispatched at the ${tc.desiredType} provider, which cannot delete a nested stack — the child stack "${stackName}~${tc.logicalId}" and every resource it owns would be left behind, untracked.`}`;
30326
+ });
30327
+ return `Refusing to deploy ${stackName}: ` + (typeChanges.length === 1 ? `a resource changes its Type ` : `${typeChanges.length} resources change their Type `) + `into or out of ${NESTED_STACK_RESOURCE_TYPE}, which cdkd cannot replace safely (issue #2668).\n${rows.join("\n")}\n Deploy this as two changes instead: give the new resource a DIFFERENT logical id (in CDK, rename the construct) so the existing row is deleted through its own type's provider and the new one is created under its own — or remove the resource in one deploy and add its replacement in the next. There is no flag that overrides this refusal: the delete's TARGET would be wrong, not merely its consequences.`;
30328
+ }
30329
+
28991
30330
  //#endregion
28992
30331
  //#region src/deployment/outputs-export-alias.ts
28993
30332
  /**
@@ -29311,6 +30650,218 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
29311
30650
  }
29312
30651
  }
29313
30652
 
30653
+ //#endregion
30654
+ //#region src/provisioning/dynamodb-warm-throughput.ts
30655
+ /**
30656
+ * The two DynamoDB `WarmThroughput` rules that `AWS::DynamoDB::Table` and
30657
+ * `AWS::DynamoDB::GlobalTable` both need, in ONE spelling.
30658
+ *
30659
+ * `WarmThroughput` is the same CloudFormation block on both types, and both
30660
+ * providers have to answer the same two questions about it before an
30661
+ * `UpdateTable` / `CreateTable` goes out:
30662
+ *
30663
+ * 1. **Is this block SENDABLE, and as what numbers?** CloudFormation is
30664
+ * stringly typed, so `{ReadUnitsPerSecond: '12000'}` — or anything that
30665
+ * came back from an `Fn::Sub` — arrives as a STRING and must not be
30666
+ * forwarded verbatim into a numeric `Long` field.
30667
+ * 2. **Would sending it LOWER what AWS already reports?** Warm throughput
30668
+ * only ever rises with a table's traffic and AWS REJECTS a call that
30669
+ * lowers it (`decreasing WarmThroughput is not supported`, measured live
30670
+ * us-east-1 2026-08-13 for issue #1768).
30671
+ *
30672
+ * **Provenance, stated precisely because it is easy to over-claim.** Exactly
30673
+ * ONE of these rules ever shipped: `AWS::DynamoDB::Table` got them in PR #1808
30674
+ * (issues #1760 / #1768), and the `AWS::DynamoDB::GlobalTable` side never
30675
+ * existed outside the change that created this file (issue #1857). So this is
30676
+ * the Table rule LIFTED — not two shipped rules reconciled, and no deployed
30677
+ * behaviour changed for either type when it moved here.
30678
+ *
30679
+ * What WAS compared is the lifted rule against the GlobalTable spelling
30680
+ * drafted alongside it, over the shapes both had to answer (a quoted numeric
30681
+ * string, a partially usable block, a mixed decrease/increase, an absent live
30682
+ * value, a zero, a negative, a non-numeric string, a whitespace string, an
30683
+ * explicit `undefined` / `null`, a boolean, an empty block, a scalar, an
30684
+ * array). {@link isWarmThroughputDecrease} agreed on every one; the coercion
30685
+ * agreed on all but a whitespace-only string, where a bare `Number(' ')` is
30686
+ * `0` rather than `NaN`. This module keeps the REFUSING answer for that shape,
30687
+ * since `' '` is not a capacity anyone declared and a warm throughput of 0
30688
+ * is not a request AWS can honour. That is a DRAFT reconciled against a
30689
+ * shipped rule, which is worth less than two shipped rules agreeing — the
30690
+ * reason to read the probe list as a design note rather than as field
30691
+ * evidence.
30692
+ *
30693
+ * Living here rather than in either provider is the point. A decrease guard is
30694
+ * three clauses that all FAIL OPEN for different reasons, and two files
30695
+ * spelling it independently is two chances for a later "fix" to change one of
30696
+ * them — at which point the sibling type silently keeps the old answer, and
30697
+ * nothing in the tree says which is intended. Same class as
30698
+ * `emr-configuration.ts`, and the same reason.
30699
+ *
30700
+ * Deliberately NOT here: everything only ONE provider has. The `Table` side's
30701
+ * `isSendableWarmThroughput` / `isRefusedWarmThroughput` /
30702
+ * `declaresWarmThroughput` / `warmThroughputAlreadyMatches` are its drift-side
30703
+ * and already-matches gates, which `GlobalTable` has no counterpart to (issue
30704
+ * #1742 strips the per-index `WarmThroughput` from BOTH of its drift
30705
+ * comparison sides unconditionally, so drift never asks the question there);
30706
+ * the `GlobalTable` side's `warmThroughputDiagnostic` builds a
30707
+ * `ThroughputDiagnostic` that only that provider's collector understands.
30708
+ * Moving a helper with one caller here would buy nothing and cost a hop.
30709
+ *
30710
+ * Issues: #1760 / #1768 (Table, PR #1808), #1857 (GlobalTable).
30711
+ */
30712
+ /**
30713
+ * The two `WarmThroughput` members, in the ONE order every message, every
30714
+ * comparison and every emitted block uses. A shared order is what makes two
30715
+ * blocks carrying the same numbers compare equal by `deepEqual` and serialize
30716
+ * to the same wire bytes regardless of the order the template wrote them in.
30717
+ */
30718
+ const WARM_THROUGHPUT_MEMBERS = ["ReadUnitsPerSecond", "WriteUnitsPerSecond"];
30719
+ /**
30720
+ * A CloudFormation-borne numeric property, or `undefined` when the value is
30721
+ * not a usable number.
30722
+ *
30723
+ * Plain `Number()` coercion is NOT good enough and the tree learned it three
30724
+ * separate times: `Number(null)`, `Number('')`, `Number([])`, `Number(false)`
30725
+ * and `Number(' ')` are all **0**, not `NaN` — so a live `0` would compare
30726
+ * EQUAL to a desired `null` / `''` / `[]` / `false`, and a whitespace-only
30727
+ * string would be forwarded as a request for zero units.
30728
+ *
30729
+ * A YAML-borne numeric STRING is still accepted, because that is a real
30730
+ * template shape and `'12000'` genuinely means 12000.
30731
+ *
30732
+ * The accepted STRING set is `Number()`'s, which is WIDER than a decimal
30733
+ * integer: `'0x1e'`, `'0o36'`, `'1e3'`, `'30.5'` and `' 30 '` all coerce.
30734
+ * Whether CloudFormation accepts those for an `Integer`-typed property is
30735
+ * unmeasured, and issue [#2698](https://github.com/go-to-k/cdkd/issues/2698)
30736
+ * holds the live A/B that would settle it. It matters at the CALLERS that
30737
+ * FORWARD the result to AWS rather than merely compare it — narrowing this
30738
+ * shared helper would change the DynamoDB capacity readers too, so read that
30739
+ * issue before tightening anything here.
30740
+ *
30741
+ * EXPORTED, and not because warm throughput needs it exported. This exact rule
30742
+ * was hand-written three times — here, as `dynamodb-table-provider.ts`'s
30743
+ * `capacityNumber` (byte-identical), and as `dynamodb-globaltable-provider.ts`'s
30744
+ * `toFiniteNumber` (a different spelling of the same total function) — which is
30745
+ * precisely the divergence this module exists to stop: the next "fix" to one of
30746
+ * them would have left the other two answering differently, with nothing in the
30747
+ * tree saying which was intended. It reads `ReadCapacityUnits` /
30748
+ * `MaxReadRequestUnits` / `MinCapacity` as readily as it reads
30749
+ * `ReadUnitsPerSecond`; there is only ever one right answer for "is this
30750
+ * stringly-typed CFn value a number I can send?".
30751
+ */
30752
+ function toFiniteNumber(value) {
30753
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
30754
+ if (typeof value === "string" && value.trim() !== "") {
30755
+ const n = Number(value);
30756
+ return Number.isFinite(n) ? n : void 0;
30757
+ }
30758
+ }
30759
+ /**
30760
+ * Coerce a CFn `WarmThroughput` block to the numeric shape the SDK's `Long`
30761
+ * fields accept, PER MEMBER.
30762
+ *
30763
+ * PER MEMBER, not whole-block, and that distinction is the point: a block
30764
+ * whose write half is an unresolved intrinsic still has a perfectly good read
30765
+ * half, and dropping both would silently discard a value the template really
30766
+ * did ask for. The dropped member is NAMED (`droppedMembers`) so the caller's
30767
+ * warning can say which half went missing instead of reporting the whole
30768
+ * property.
30769
+ *
30770
+ * A block with NO usable member yields `spec: undefined` — refused rather than
30771
+ * forwarded, because forwarding a malformed block surfaces as an opaque AWS
30772
+ * validation error naming neither cdkd nor the property. `droppedMembers` is
30773
+ * still populated in that case, so a REFUSAL message can name what it refused;
30774
+ * a block that is not an object at all (an unresolved `Fn::If`, a scalar, an
30775
+ * array) has no member to name and reports none, which is what lets a caller
30776
+ * word "one half went missing" differently from "the block is unusable".
30777
+ *
30778
+ * Pure: takes the raw bag, returns numbers, logs nothing. Each caller owns its
30779
+ * own message, so the wording stays consistent across that provider's send
30780
+ * sites without forcing one wording across both providers.
30781
+ */
30782
+ function coerceWarmThroughput(raw) {
30783
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { droppedMembers: [] };
30784
+ const bag = raw;
30785
+ const spec = {};
30786
+ const droppedMembers = [];
30787
+ for (const member of WARM_THROUGHPUT_MEMBERS) {
30788
+ if (bag[member] === void 0) continue;
30789
+ const coerced = toFiniteNumber(bag[member]);
30790
+ if (coerced === void 0) {
30791
+ droppedMembers.push(member);
30792
+ continue;
30793
+ }
30794
+ spec[member] = coerced;
30795
+ }
30796
+ if (Object.keys(spec).length === 0) return { droppedMembers };
30797
+ return {
30798
+ spec,
30799
+ droppedMembers
30800
+ };
30801
+ }
30802
+ /**
30803
+ * Whether the COERCED desired `WarmThroughput` would LOWER what AWS already
30804
+ * reports.
30805
+ *
30806
+ * Measured live (us-east-1, 2026-08-13, issue #1768) against a table AWS
30807
+ * reports `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
30808
+ *
30809
+ * ```
30810
+ * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
30811
+ * ValidationException: One or more parameter values were invalid: Requested
30812
+ * ReadUnitsPerSecond for WarmThroughput for table is lower than current
30813
+ * WarmThroughput, decreasing WarmThroughput is not supported
30814
+ * UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
30815
+ * UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
30816
+ * UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
30817
+ * ```
30818
+ *
30819
+ * So the value is one AWS raises with the table's traffic and never lowers,
30820
+ * and a decrease is REJECTED rather than accepted-and-ignored — which is what
30821
+ * had to be measured before choosing, because the two produce different
30822
+ * correct answers. The caller SKIPS the call on a true here.
30823
+ *
30824
+ * Evaluated on the COERCED spec, never on the raw bag: analysing the raw bag
30825
+ * makes the verdict describe a request that is not the one being sent (a
30826
+ * dropped member is not part of the call and must not be part of the
30827
+ * comparison).
30828
+ *
30829
+ * Semantics, all three chosen to FAIL OPEN — i.e. to let the call through and
30830
+ * leave AWS as the authority — because a false positive here silently drops a
30831
+ * legitimate INCREASE, which is a real capacity change the user asked for,
30832
+ * while a false negative merely reproduces the pre-fix behaviour of an
30833
+ * AWS-side rejection that names the property:
30834
+ * - DECLARED members only. An absent member is not a request to lower
30835
+ * anything, so it takes no part in the verdict.
30836
+ * - MIXED is not a decrease. One member below live and the other above means
30837
+ * the call carries a genuine increase; AWS decides.
30838
+ * - An absent or unusable LIVE counterpart is not a decrease. Without a
30839
+ * number to compare against there is no evidence of one. Only the LIVE side
30840
+ * can reach that arm: the desired side is a COERCED spec, so a malformed
30841
+ * template value has already been dropped by {@link coerceWarmThroughput}.
30842
+ *
30843
+ * A decrease therefore requires every declared member to be at-or-below live
30844
+ * AND at least one to be strictly below.
30845
+ *
30846
+ * The skip this drives is right for EVERY `update()` caller — the deploy
30847
+ * engine, `cdkd drift --revert`, and the rollback executor's two revert arms —
30848
+ * because none of them can make AWS lower the value, so none loses anything a
30849
+ * doomed call would have achieved.
30850
+ */
30851
+ function isWarmThroughputDecrease(desired, live) {
30852
+ if (desired === void 0 || live === void 0) return false;
30853
+ let sawDecrease = false;
30854
+ for (const member of WARM_THROUGHPUT_MEMBERS) {
30855
+ if (desired[member] === void 0) continue;
30856
+ const wanted = toFiniteNumber(desired[member]);
30857
+ const current = toFiniteNumber(live[member]);
30858
+ if (wanted === void 0 || current === void 0) return false;
30859
+ if (wanted > current) return false;
30860
+ if (wanted < current) sawDecrease = true;
30861
+ }
30862
+ return sawDecrease;
30863
+ }
30864
+
29314
30865
  //#endregion
29315
30866
  //#region src/provisioning/stateful-types.ts
29316
30867
  /**
@@ -29357,9 +30908,12 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
29357
30908
  * "never expire" — the most data-bearing configuration the type
29358
30909
  * has, and what `LogsLogGroupProvider` records `0` for — so reading
29359
30910
  * it as "holds nothing" destroyed years of events on a plain
29360
- * `cdkd deploy`. A recorded `RetentionInDays > 0` still answers
29361
- * `has-retention` from the bag alone (a cheap positive, never
29362
- * probed away); every other bag DEFERS, exactly as the bucket does.
30911
+ * `cdkd deploy`. A `RetentionInDays > 0` recorded in EITHER of the
30912
+ * state record's property bags still answers `has-retention` from
30913
+ * the bags alone (a cheap positive, never probed away); every other
30914
+ * bag DEFERS, exactly as the bucket does. Which bags, and why the
30915
+ * value is coerced rather than type-tested, is issue [#2521] —
30916
+ * see {@link logGroupHasPositiveRetention}.
29363
30917
  * The pre-flight resolves the deferral with a live
29364
30918
  * `logs:DescribeLogStreams` probe (a log group with no stream can
29365
30919
  * hold no event, since every event belongs to a stream); mid-deploy,
@@ -29544,10 +31098,66 @@ const STATEFUL_TYPES = /* @__PURE__ */ new Set([
29544
31098
  */
29545
31099
  const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::DynamoDB::GlobalTable"]);
29546
31100
  /**
29547
- * Cheap, synchronous read of the resource's recorded properties only.
31101
+ * Does either recorded bag prove this log group carries a POSITIVE
31102
+ * `RetentionInDays`? (issue [#2521])
31103
+ *
31104
+ * Two decisions, and both were defects before this function existed.
31105
+ *
31106
+ * **The value is COERCED, not type-tested.** The predicate used to gate on
31107
+ * `typeof retention === 'number' && retention > 0`, and CloudFormation is
31108
+ * stringly typed: a hand-written L1, an `Fn::Sub`-produced value, a
31109
+ * `Type: String` parameter's default, or a record imported by
31110
+ * `cdkd import --migrate-from-cloudformation` all put `'30'` into the bag,
31111
+ * where the type test answers "not a number". {@link toFiniteNumber} is the
31112
+ * repo's single answer to "is this stringly-typed CFn value a number?" —
31113
+ * imported rather than re-spelled here for the reason its own doc gives, that
31114
+ * the same rule hand-written twice diverges on the next fix to one copy. It
31115
+ * rejects `''`, `' '`, `null`, `[]` and `false`, each of which a bare
31116
+ * `Number()` turns into a `0` that would read as never-expire.
31117
+ *
31118
+ * **BOTH bags are consulted, and a positive in EITHER wins.** The old read
31119
+ * saw `properties` alone, so a retention set OUT OF BAND (the console,
31120
+ * `aws logs put-retention-policy`, another tool) or one recorded only in
31121
+ * `observedProperties` — an imported record whose template never declared
31122
+ * the property — never produced `has-retention`.
31123
+ *
31124
+ * The rule is a plain OR, and the loop order below is INERT — a review round
31125
+ * caught an earlier version of this paragraph claiming `observedProperties`
31126
+ * is "read FIRST", a precedence the code does not implement and must not.
31127
+ * A strict "observed when present, else properties" rule — which is what
31128
+ * issue [#2521] literally prescribed — was tried and REJECTED:
31129
+ * `LogsLogGroupProvider.readCurrentState` writes `RetentionInDays: 0` for a
31130
+ * group with no retention policy, so the observed bag almost always CARRIES
31131
+ * the key, and precedence would make the recorded bag dead for every record
31132
+ * that has ever been captured and would DROP the `has-retention` verdict this
31133
+ * guard already produced. A test pins that rejection
31134
+ * (`tests/unit/provisioning/stateful-types.test.ts`, "reads a retention that
31135
+ * lives ONLY in properties, even against a ZERO observed one"). The OR is
31136
+ * also the strictly safer direction: it can only ADD refusals, never remove
31137
+ * one.
31138
+ *
31139
+ * What a `false` here means is DEFER, not "holds nothing" — see the callers.
31140
+ */
31141
+ function logGroupHasPositiveRetention(recordedProperties, observedProperties) {
31142
+ for (const bag of [observedProperties, recordedProperties]) {
31143
+ const retention = toFiniteNumber(bag?.["RetentionInDays"]);
31144
+ if (retention !== void 0 && retention > 0) return true;
31145
+ }
31146
+ return false;
31147
+ }
31148
+ /**
31149
+ * Cheap, synchronous read of the state record's own property bags only —
31150
+ * no AWS call. Both bags are parameters rather than one: `properties` is
31151
+ * what the last deploy applied, `observedProperties` what it read back,
31152
+ * and the log group's arm consults BOTH (issue [#2521]). Passing the
31153
+ * observed bag is REQUIRED, not optional, so a new call site has to
31154
+ * decide what it holds instead of silently repeating the omission that
31155
+ * issue records; `undefined` is the right answer where no record is in
31156
+ * hand (`recreate-confirm-prompt.ts`).
31157
+ *
29548
31158
  * TWO types return `null` meaning DEFER rather than "not stateful":
29549
- * `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever the
29550
- * recorded bag does not already prove `has-retention`. The live probes
31159
+ * `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever neither
31160
+ * bag already proves `has-retention`. The live probes
29551
31161
  * that resolve both deferrals (`ListObjectVersions` for the bucket,
29552
31162
  * `DescribeLogStreams` for the log group) live in
29553
31163
  * `src/deployment/recreate-targets.ts#probeStatefulRecreateTargetsAsync`
@@ -29560,11 +31170,10 @@ const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::Dynam
29560
31170
  * Returns the {@link StatefulReason} when the type is stateful (or
29561
31171
  * `null` for non-stateful types).
29562
31172
  */
29563
- function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
31173
+ function isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties) {
29564
31174
  if (!STATEFUL_TYPES.has(resourceType)) return null;
29565
31175
  if (resourceType === "AWS::Logs::LogGroup") {
29566
- const retention = recordedProperties?.["RetentionInDays"];
29567
- if (typeof retention === "number" && retention > 0) return "has-retention";
31176
+ if (logGroupHasPositiveRetention(recordedProperties, observedProperties)) return "has-retention";
29568
31177
  return null;
29569
31178
  }
29570
31179
  if (resourceType === "AWS::S3::Bucket") return null;
@@ -29591,16 +31200,38 @@ function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
29591
31200
  * — be DELETE + CREATEd (data loss) without `--force-stateful-recreation`. To
29592
31201
  * stay fail-safe, both deferrals resolve to stateful here: the user must pass
29593
31202
  * `--force-stateful-recreation` to replace ANY S3 bucket, and any log group
29594
- * whose recorded bag does not already prove `has-retention`, on any of those
29595
- * paths — empty or not.
31203
+ * neither of whose recorded property bags already proves `has-retention`, on
31204
+ * any of those paths — empty or not.
31205
+ *
31206
+ * `UpdateReplacePolicy: Retain` is the standing EXEMPTION, and it covers all
31207
+ * three (issue [#2604]): the engine never consults this predicate under it —
31208
+ * the property-driven guard tests `updateReplacePolicy !== 'Retain'`
31209
+ * directly, and the fallback's two triggers short-circuit through
31210
+ * `retainOldOnReplace`, which issue [#2518] added. The old resource survives
31211
+ * the replacement, so there is no data loss to confirm, and the refusal's own
31212
+ * remedy would have destroyed exactly what the user asked to keep.
31213
+ * `Snapshot` is NOT exempt on any of them — a snapshot is a copy, not a
31214
+ * surviving resource. So read the paragraph above as scoped to a template
31215
+ * that is not retaining.
31216
+ *
31217
+ * The two engine sites are not the only callers: a THIRD sits outside those
31218
+ * paths and outside that exemption — `recreate-confirm-prompt.ts` re-derives
31219
+ * a `null` verdict for the `--recreate-via-*` pre-flight, where
31220
+ * `--force-stateful-recreation` is what skipped the probe.
31221
+ * `stateful-replace-message-doc-sync` pins the whole guard's reader list by
31222
+ * file, but the residuals comment above its own walk enumerates what that
31223
+ * cannot see — among them an ALIASED import, a `.mts` / `.cts` reader, and,
31224
+ * the one that bit, a NEW PATH routed through an existing call site, which
31225
+ * adds no file and reds nothing (issue [#2514]'s shape). So this enumeration
31226
+ * is maintained by hand.
29596
31227
  *
29597
31228
  * The log group's arm is the one issue [#2558] added, and the reason it is
29598
31229
  * needed is that the old predicate treated "no retention recorded" as "holds
29599
31230
  * nothing" when it is CloudWatch Logs' never-expire. Every other type matches
29600
31231
  * {@link isStatefulRecreateTargetSync} exactly.
29601
31232
  */
29602
- function isStatefulRecreateTargetForReplace(resourceType, recordedProperties) {
29603
- const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties);
31233
+ function isStatefulRecreateTargetForReplace(resourceType, recordedProperties, observedProperties) {
31234
+ const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties);
29604
31235
  if (sync) return sync;
29605
31236
  if (resourceType === "AWS::S3::Bucket") return "has-objects";
29606
31237
  if (resourceType === "AWS::Logs::LogGroup") return "has-log-events";
@@ -29614,7 +31245,7 @@ function isStatefulRecreateTargetForReplace(resourceType, recordedProperties) {
29614
31245
  function renderStatefulReason(reason) {
29615
31246
  switch (reason) {
29616
31247
  case "always": return "destroy loses all data in the resource";
29617
- case "has-objects": return "S3 bucket is non-empty";
31248
+ case "has-objects": return "S3 bucket is not provably empty";
29618
31249
  case "has-retention": return "log group retains data (RetentionInDays > 0)";
29619
31250
  case "has-log-events": return "log group is not provably empty";
29620
31251
  case null: return "(not stateful)";
@@ -30762,6 +32393,84 @@ function rollbackFinalSnapshotId(resourceType, record, fallbackProvisionedBy) {
30762
32393
  return buildFinalSnapshotIdentifier(record.physicalId, resourceType);
30763
32394
  }
30764
32395
  /**
32396
+ * `UpdateReplacePolicy: Retain` on the resource a replacement CREATED — the
32397
+ * copy a rollback would otherwise destroy (issue
32398
+ * [#2598](https://github.com/go-to-k/cdkd/issues/2598)).
32399
+ *
32400
+ * Reads the CURRENT record, i.e. the one the replacing deploy wrote from the
32401
+ * template it was applying (`extractTemplateAttributes`), so the attribute
32402
+ * consulted is the one that was in force when the new copy was created. Its
32403
+ * `Snapshot` sibling, {@link rollbackFinalSnapshotId}, reads the same field of
32404
+ * the same record — `Retain` and `Snapshot` are alternative values of ONE
32405
+ * attribute, so the two can never both apply.
32406
+ *
32407
+ * **`UpdateReplacePolicy`, NOT `DeletionPolicy`, and that is measured, not
32408
+ * reasoned.** The repo refuses a CloudFormation-parity claim taken on
32409
+ * folklore, and the AWS documentation answers nothing here: every sentence on
32410
+ * both attribute pages, in the API reference and in the release notes
32411
+ * describes the OLD resource, never the new copy's fate during a rollback. A
32412
+ * live four-variant A/B (2026-09-05, us-east-1: a forced `AWS::SSM::Parameter`
32413
+ * replacement plus a deterministically failing sibling, rolled back) settled
32414
+ * it:
32415
+ *
32416
+ * | DeletionPolicy | UpdateReplacePolicy | new copy | decisive event |
32417
+ * | -------------- | ------------------- | --------- | ---------------- |
32418
+ * | (none) | (none) | DELETED | `DELETE_COMPLETE` |
32419
+ * | Retain | (none) | DELETED | `DELETE_COMPLETE` |
32420
+ * | (none) | Retain | SURVIVED | `DELETE_SKIPPED` |
32421
+ * | Retain | Retain | SURVIVED | `DELETE_SKIPPED` |
32422
+ *
32423
+ * Row 2 alone refutes "`DeletionPolicy` governs it"; row 3 alone refutes
32424
+ * "neither — always deleted". The old copy was restored intact in all four,
32425
+ * and both outcomes land in `UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS`.
32426
+ *
32427
+ * A retained new copy is ORPHANED OUT of the stack, not kept as a managed
32428
+ * resource — the A/B proved it by deleting the whole stack afterwards and
32429
+ * finding the retained parameter still alive. So every caller below leaves NO
32430
+ * state record naming the survivor: the two arms that can complete point state
32431
+ * at the old resource exactly as they already did, and the survivor becomes
32432
+ * untracked. That is the same disposition the deploy engine gives a
32433
+ * `Retain`-orphaned OLD resource, so the two directions agree.
32434
+ *
32435
+ * LIMIT OF THAT EVIDENCE, stated so a later reader does not over-read it: all
32436
+ * four variants carried the SAME policy in both template versions, so the A/B
32437
+ * pinned WHICH ATTRIBUTE wins and did NOT discriminate which template's copy
32438
+ * of it is read. This function reads the current record for the reasons above
32439
+ * (it is the one the new copy was created under, and it matches the
32440
+ * `Snapshot` sibling and both CREATE-rollback arms), not because the A/B
32441
+ * settled that question.
32442
+ */
32443
+ function rollbackRetainsNewResource(record) {
32444
+ return record?.updateReplacePolicy === "Retain";
32445
+ }
32446
+ /**
32447
+ * The two sentences a `UpdateReplacePolicy: Retain` survivor needs: the `⚠`
32448
+ * terminal warning and the compact `reason` that rides on the durable
32449
+ * `ROLLBACK_RESOURCE_SUCCEEDED` event.
32450
+ *
32451
+ * ONE function because the two must not drift apart. Both replacement-rollback
32452
+ * retain arms produced these by hand, four near-identical copies, and the
32453
+ * failure mode a reviewer named is precise: the warn and the DURABLE record
32454
+ * disagreeing about which id survived. Deriving both from one set of inputs
32455
+ * makes that unrepresentable. The shapes stay deliberately different -- the
32456
+ * warn carries the cost/`cdkd destroy` guidance a human reads once, the reason
32457
+ * stays compact for a `--json` consumer -- so this is one input set, not one
32458
+ * string.
32459
+ *
32460
+ * `stateClause` is the only thing that differs between the two arms (the
32461
+ * readopt arm restores the old id; the create-first arm records a re-created
32462
+ * one), so it is a parameter rather than a branch in here.
32463
+ *
32464
+ * NOT used by the delete-failed survivor a few lines down: that one is an
32465
+ * orphan by OUTCOME rather than by policy, and says so.
32466
+ */
32467
+ function retainedSurvivorMessages(logicalId, resourceType, survivorPhysicalId, stateClause) {
32468
+ return {
32469
+ warn: ` ⚠ ${logicalId} (${resourceType}) has UpdateReplacePolicy: Retain — the replacement's new physical resource (${survivorPhysicalId}) is RETAINED by this rollback and is no longer tracked by cdkd: it keeps running and incurring cost, and \`cdkd destroy\` will not remove it. Delete it yourself once you no longer need it. ${stateClause}`,
32470
+ reason: `UpdateReplacePolicy: Retain kept the replacement's new ${resourceType} (${survivorPhysicalId}); it is live, still billing, and no longer tracked by cdkd. ${stateClause}`
32471
+ };
32472
+ }
32473
+ /**
30765
32474
  * `DeletionPolicy: Snapshot` on a rolled-back CREATE (issue #1358) — the
30766
32475
  * executor's copy of the deploy engine's `prepareFinalSnapshotForDelete`
30767
32476
  * mechanism matrix, run BEFORE the delete. Shared with the FAILED in-flight
@@ -30860,7 +32569,7 @@ function classifyRollbackOp(op, stateResources, orphanLogicalIds) {
30860
32569
  if (deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
30861
32570
  return "skip-mismatch";
30862
32571
  }
30863
- return op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
32572
+ return op.oldResourceRetained ?? op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
30864
32573
  }
30865
32574
  if (op.previousState && deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
30866
32575
  return "revert";
@@ -30900,12 +32609,16 @@ function planFailedOps(failedOps, stateResources) {
30900
32609
  */
30901
32610
  function planRollback(operations, stateResources, orphanLogicalIds = /* @__PURE__ */ new Set()) {
30902
32611
  const { createOps, otherOps } = partitionOps(operations);
30903
- return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) => ({
30904
- op,
30905
- action: classifyRollbackOp(op, stateResources, orphanLogicalIds),
30906
- replacement: isReplacementOp(op),
30907
- effectiveProvisionedBy: effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy)
30908
- }));
32612
+ return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) => {
32613
+ const action = classifyRollbackOp(op, stateResources, orphanLogicalIds);
32614
+ return {
32615
+ op,
32616
+ action,
32617
+ replacement: isReplacementOp(op),
32618
+ effectiveProvisionedBy: effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy),
32619
+ retainsNewResource: (action === "reverse-replacement" || action === "reverse-replacement-readopt") && rollbackRetainsNewResource(stateResources[op.logicalId])
32620
+ };
32621
+ });
30909
32622
  }
30910
32623
  function partitionOps(operations) {
30911
32624
  const createOps = [];
@@ -31263,11 +32976,32 @@ async function resolveLeafByRegion(leaf, propertyPath, logicalId, execCtx, resol
31263
32976
  * writer is one of only two that can honestly claim it.
31264
32977
  *
31265
32978
  * Using `STATE_SOURCED_READBACK_RULES` here reads plausible and is wrong in the
31266
- * quiet direction: it turns positional array descent off. Since issue #1915 a
31267
- * `Tags[]` / ECS `Environment[]` element is reached by KEYED descent either
31268
- * way, so the concrete loss is narrower than it was it is a list whose
31269
- * elements carry no `Name` / `Key` identity, which only positional descent can
31270
- * walk.
32979
+ * quiet direction: it turns BLIND positional array descent off. BLIND is
32980
+ * load-bearing, and an earlier revision of this paragraph omitted it — the
32981
+ * concrete loss is narrower than "positional descent is off" makes it sound,
32982
+ * by TWO mechanisms rather than one:
32983
+ *
32984
+ * - Since issue #1915 a `Tags[]` / ECS `Environment[]` element is reached by
32985
+ * the order-independent KEYED descent either way.
32986
+ * - Since issue #2012 an UNKEYED list is reached too, under corroboration.
32987
+ * That is not a general relaxation: swapping this constant in would satisfy
32988
+ * all three conjuncts of `isReadbackProjectedFromState`
32989
+ * (`trustAnyExpression && !descendArrays && sourceIsSameGeneration`), which
32990
+ * ARMS `refuseUncertifiedReadbackPositions`, and its unkeyed arm walks
32991
+ * element i against element i whenever `unkeyedArrayPairsByAnchors`
32992
+ * corroborates the alignment (index counts match; every position whose
32993
+ * SOURCE subtree carries no dynamic reference is deep-equal on both sides;
32994
+ * every reference-bearing element carries a distinguishing anchor of its own
32995
+ * or, being a bare reference leaf, leans on the array's literal frame; and
32996
+ * no two reference-bearing elements share an order-insensitive anchor
32997
+ * signature).
32998
+ *
32999
+ * So the residual loss is narrower again: an unkeyed list whose positions ALSO
33000
+ * fail to corroborate. The CONCLUSION is unchanged — `STATE_DERIVED_RULES` is
33001
+ * still right here, for the reason one paragraph up (the bag was produced by
33002
+ * resolving the source, so the two correspond positionally by construction and
33003
+ * need no corroboration to say so). What changes is only how much a reader
33004
+ * should think the alternative costs (issue #2691).
31271
33005
  *
31272
33006
  * No-op when the op resolved no secret.
31273
33007
  */
@@ -31477,7 +33211,8 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
31477
33211
  return;
31478
33212
  case "orphan-flag":
31479
33213
  if (op.changeType === "CREATE") {
31480
- const orphanFlagProvisionedBy = effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy);
33214
+ const record = stateResources[op.logicalId];
33215
+ const orphanFlagProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
31481
33216
  createRollbackRoute = orphanFlagProvisionedBy;
31482
33217
  delete stateResources[op.logicalId];
31483
33218
  logger.info(` Rollback: Orphaning created resource ${op.logicalId} (--orphan)`);
@@ -31488,12 +33223,17 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
31488
33223
  operation: "CREATE",
31489
33224
  logicalId: op.logicalId,
31490
33225
  resourceType: op.resourceType,
31491
- ...orphanFlagProvisionedBy && { provisionedBy: orphanFlagProvisionedBy }
33226
+ ...orphanFlagProvisionedBy && { provisionedBy: orphanFlagProvisionedBy },
33227
+ ...record?.physicalId && {
33228
+ physicalId: record.physicalId,
33229
+ reason: `--orphan left ${op.logicalId} (${op.resourceType}) in AWS as ${record.physicalId} and dropped it from state; it is live, still billing, and no longer tracked by cdkd.`
33230
+ }
31492
33231
  });
31493
33232
  } else logger.info(` Rollback: Leaving ${op.logicalId} at its new state (--orphan)`);
31494
33233
  return;
31495
33234
  case "orphan-retain": {
31496
- const orphanProvisionedBy = effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy);
33235
+ const record = stateResources[op.logicalId];
33236
+ const orphanProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
31497
33237
  createRollbackRoute = orphanProvisionedBy;
31498
33238
  delete stateResources[op.logicalId];
31499
33239
  logger.info(` Rollback: Leaving ${op.logicalId} (${op.resourceType}) in AWS (DeletionPolicy: Retain) — removed from state`);
@@ -31504,7 +33244,11 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
31504
33244
  operation: "CREATE",
31505
33245
  logicalId: op.logicalId,
31506
33246
  resourceType: op.resourceType,
31507
- ...orphanProvisionedBy && { provisionedBy: orphanProvisionedBy }
33247
+ ...orphanProvisionedBy && { provisionedBy: orphanProvisionedBy },
33248
+ ...record?.physicalId && {
33249
+ physicalId: record.physicalId,
33250
+ reason: `DeletionPolicy: Retain left ${op.logicalId} (${op.resourceType}) in AWS as ${record.physicalId} and dropped it from state; it is live, still billing, and no longer tracked by cdkd.`
33251
+ }
31508
33252
  });
31509
33253
  return;
31510
33254
  }
@@ -31547,11 +33291,32 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
31547
33291
  const current = stateResources[op.logicalId];
31548
33292
  const prev = op.previousState;
31549
33293
  logger.info(` Rollback: Reversing replacement of ${op.logicalId} (${op.resourceType}) — deleting the new resource and re-adopting the retained old one (${prev.physicalId})`);
31550
- const { provider: newDeleteProvider } = ctx.providerRegistry.getProviderFor({
31551
- resourceType: op.resourceType,
31552
- provisionedBy: current.provisionedBy ?? op.provisionedBy
31553
- });
31554
- {
33294
+ /**
33295
+ * Set when this arm ORPHANS the replacement's new copy. Read at the
33296
+ * `ROLLBACK_RESOURCE_SUCCEEDED` event below, which is the only channel
33297
+ * that OUTLIVES the terminal (security review of issue #2598): a
33298
+ * rollback runs during an already-failing deploy, often non-TTY with
33299
+ * the log truncated or discarded, so a `logger.warn` is the least
33300
+ * likely thing the user still has. Without this the survivor's id dies
33301
+ * with the terminal -- `cdkd events` shows a clean success and state
33302
+ * names only the OLD resource, while a live, billing, untracked copy
33303
+ * remains. `Retain` is precisely the marker users put on data-bearing
33304
+ * resources, so that is the worst population to lose the id for.
33305
+ *
33306
+ * Same shape as the `rollbackPartial` survivor record ~700 lines down
33307
+ * and as the deploy engine's `RESOURCE_SKIPPED` twin.
33308
+ */
33309
+ let survivorReason;
33310
+ if (rollbackRetainsNewResource(current)) {
33311
+ const survivorMessages = retainedSurvivorMessages(op.logicalId, op.resourceType, current.physicalId, `State is restored to the old resource (${prev.physicalId}).`);
33312
+ logger.warn(survivorMessages.warn);
33313
+ survivorReason = survivorMessages.reason;
33314
+ result.warnings++;
33315
+ } else {
33316
+ const { provider: newDeleteProvider } = ctx.providerRegistry.getProviderFor({
33317
+ resourceType: op.resourceType,
33318
+ provisionedBy: current.provisionedBy ?? op.provisionedBy
33319
+ });
31555
33320
  const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
31556
33321
  throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
31557
33322
  expectedRegion: ctx.region,
@@ -31561,13 +33326,19 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
31561
33326
  stateResources[op.logicalId] = prev;
31562
33327
  logger.info(` Rollback: ${op.logicalId} restored to the retained old resource`);
31563
33328
  await afterOp?.(op.logicalId);
33329
+ const survivorProvisionedBy = current.provisionedBy;
31564
33330
  ctx.recordEvent?.({
31565
33331
  eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
31566
33332
  stackName,
31567
33333
  operation: "UPDATE",
31568
33334
  logicalId: op.logicalId,
31569
33335
  resourceType: op.resourceType,
31570
- ...op.provisionedBy && { provisionedBy: op.provisionedBy }
33336
+ ...op.provisionedBy && { provisionedBy: op.provisionedBy },
33337
+ ...survivorReason !== void 0 && {
33338
+ physicalId: current.physicalId,
33339
+ reason: maskSecretsInText(survivorReason, secrets),
33340
+ ...survivorProvisionedBy && { provisionedBy: survivorProvisionedBy }
33341
+ }
31571
33342
  });
31572
33343
  return;
31573
33344
  }
@@ -31583,10 +33354,10 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
31583
33354
  resourceType: op.resourceType,
31584
33355
  provisionedBy: prev.provisionedBy
31585
33356
  });
31586
- const { provider: newDeleteProvider } = ctx.providerRegistry.getProviderFor({
33357
+ const resolveNewDeleteProvider = () => ctx.providerRegistry.getProviderFor({
31587
33358
  resourceType: op.resourceType,
31588
33359
  provisionedBy: current.provisionedBy ?? op.provisionedBy
31589
- });
33360
+ }).provider;
31590
33361
  let deletedNewFirst = false;
31591
33362
  let createResult;
31592
33363
  try {
@@ -31595,11 +33366,13 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
31595
33366
  interruptedMessage: "Rollback interrupted while waiting out the name cooldown"
31596
33367
  });
31597
33368
  } catch (createError) {
31598
- if (!isNameCollisionError(createError instanceof Error ? createError.message : String(createError))) throw createError;
33369
+ const msg = createError instanceof Error ? createError.message : String(createError);
33370
+ if (!isNameCollisionError(msg)) throw createError;
33371
+ if (rollbackRetainsNewResource(current)) throw markNonRetryable(new CdkdError(maskSecretsInText(`Cannot reverse the replacement of ${op.logicalId} (${op.resourceType}): the re-create of the old resource (${prev.physicalId}) collided with the name still held by the new one (${current.physicalId}), and UpdateReplacePolicy: Retain pins that new resource in place, so cdkd will not delete it to free the name. Delete the new resource yourself, or remove UpdateReplacePolicy: Retain, then re-run \`cdkd rollback\` — the journal is kept, so the revert resumes from here. To leave THIS resource alone and let the rest of the rollback proceed, re-run with \`cdkd rollback --orphan ${op.logicalId}\`: one op failure stops the segment loop, so a single pinned resource otherwise halts every OLDER segment too. Underlying collision: ${msg}`, secrets), "NAMED_REPLACEMENT_COLLISION", maskSecretsInError(createError instanceof Error ? createError : void 0, secrets)));
31599
33372
  logger.info(` Rollback: re-create collided with the new resource's name — deleting the new resource (${current.physicalId}) first...`);
31600
33373
  {
31601
33374
  const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
31602
- throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
33375
+ throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
31603
33376
  expectedRegion: ctx.region,
31604
33377
  ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
31605
33378
  }), op.logicalId, current.physicalId, "while clearing the new resource so the old one could be re-created");
@@ -31613,7 +33386,7 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
31613
33386
  interruptedMessage: "Rollback interrupted while waiting for the old name to release"
31614
33387
  });
31615
33388
  } catch (recreateError) {
31616
- throw new Error(maskSecretsInText(`Failed to re-create the old ${op.logicalId} after the new resource (${current.physicalId}) was already deleted: ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. The resource is now absent — fix forward with 'cdkd deploy'.`, secrets));
33389
+ throw new Error(maskSecretsInText(`Failed to re-create the old ${op.logicalId} after the new resource (${current.physicalId}) was already deleted: ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. The resource is now absent — fix forward with 'cdkd deploy'.`, secrets), { cause: maskSecretsInError(recreateError instanceof Error ? recreateError : void 0, secrets) });
31617
33390
  }
31618
33391
  }
31619
33392
  const adoptedLiveNewResource = !deletedNewFirst && createResult.physicalId === current.physicalId;
@@ -31629,24 +33402,37 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
31629
33402
  properties: recordedPropertiesAfterReplayCreate(prevRecord, createResult)
31630
33403
  }, secrets, prevRecord.properties);
31631
33404
  await afterOp?.(op.logicalId);
31632
- if (!deletedNewFirst && !adoptedLiveNewResource) try {
33405
+ let survivorReason;
33406
+ if (!deletedNewFirst && !adoptedLiveNewResource && rollbackRetainsNewResource(current)) {
33407
+ const survivorMessages = retainedSurvivorMessages(op.logicalId, op.resourceType, current.physicalId, `State records the re-created old resource (${stateResources[op.logicalId]?.physicalId ?? prev.physicalId}).`);
33408
+ logger.warn(survivorMessages.warn);
33409
+ survivorReason = survivorMessages.reason;
33410
+ result.warnings++;
33411
+ } else if (!deletedNewFirst && !adoptedLiveNewResource) try {
31633
33412
  const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
31634
- throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
33413
+ throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
31635
33414
  expectedRegion: ctx.region,
31636
33415
  ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
31637
33416
  }), op.logicalId, current.physicalId, "while deleting the new resource after re-creating the old one");
31638
33417
  } catch (deleteError) {
31639
33418
  logger.warn(maskSecretsInText(` Rollback: old ${op.logicalId} re-created, but deleting the new resource (${current.physicalId}) failed: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}. Delete it manually — it is no longer tracked in state.`, secrets));
33419
+ survivorReason = `The replacement's new ${op.resourceType} (${current.physicalId}) could not be deleted after the old resource was re-created: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}. It is live, still billing, and no longer tracked by cdkd — delete it yourself.`;
31640
33420
  result.warnings++;
31641
33421
  }
31642
33422
  logger.info(adoptedLiveNewResource ? ` Rollback: ${op.logicalId} adopted the live resource (${createResult.physicalId}) — replacement NOT fully reversed (name-idempotent Create API)` : ` Rollback: ${op.logicalId} replacement reversed (old resource re-created as ${createResult.physicalId})`);
33423
+ const survivorProvisionedBy = current.provisionedBy;
31643
33424
  ctx.recordEvent?.({
31644
33425
  eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
31645
33426
  stackName,
31646
33427
  operation: "UPDATE",
31647
33428
  logicalId: op.logicalId,
31648
33429
  resourceType: op.resourceType,
31649
- ...op.provisionedBy && { provisionedBy: op.provisionedBy }
33430
+ ...op.provisionedBy && { provisionedBy: op.provisionedBy },
33431
+ ...survivorReason !== void 0 && {
33432
+ physicalId: current.physicalId,
33433
+ reason: maskSecretsInText(survivorReason, secrets),
33434
+ ...survivorProvisionedBy && { provisionedBy: survivorProvisionedBy }
33435
+ }
31650
33436
  });
31651
33437
  return;
31652
33438
  }
@@ -32335,7 +34121,10 @@ var DeploymentEventsReader = class {
32335
34121
  * window and rewrites (or removes) `index.json` to match.
32336
34122
  *
32337
34123
  * Retention semantics (see {@link DeploymentEventsPruneOptions}):
32338
- * - `all` — delete every run + the index (full purge).
34124
+ * - `all` — delete every run + the index. Clears the LISTING only:
34125
+ * this deletes by key with no `VersionId`, so on a
34126
+ * versioned bucket every earlier version survives
34127
+ * (issue #2624).
32339
34128
  * - `keep N` — retain the newest N runs, delete the rest.
32340
34129
  * - `olderThanMs`— delete runs whose run-id timestamp is older than the
32341
34130
  * cutoff; a run id with no parseable timestamp is kept.
@@ -32750,6 +34539,39 @@ var DeployEngine = class {
32750
34539
  */
32751
34540
  attemptedResolvedProps = /* @__PURE__ */ new Map();
32752
34541
  /**
34542
+ * Logical ids whose replacement this deploy DELIBERATELY left the old
34543
+ * physical resource alive for — `UpdateReplacePolicy: Retain` (issue
34544
+ * [#2603](https://github.com/go-to-k/cdkd/issues/2603)).
34545
+ *
34546
+ * Written by every engine path that skips the post-replacement delete, read
34547
+ * once at the `completedOperations.push` site to stamp
34548
+ * {@link CompletedOperation.oldResourceRetained}. It exists because the
34549
+ * rollback classifier used to re-derive the verdict from
34550
+ * `previousState.updateReplacePolicy` — a DIFFERENT source than the
34551
+ * template read the engine decides from — so the two disagreed on exactly
34552
+ * the deploy that changes the attribute, in both directions:
34553
+ *
34554
+ * - ADDING `Retain`: the deploy orphans the old resource while the
34555
+ * previous state record carries no policy, so the rollback re-CREATED a
34556
+ * resource that is still alive (a duplicate for an auto-named type, an
34557
+ * `AlreadyExists` failure for a user-named one).
34558
+ * - DROPPING `Retain`: state still carries the stale `Retain`, the
34559
+ * template omits it so the deploy correctly DELETES the old resource,
34560
+ * and the rollback then re-adopted a physical id that no longer exists,
34561
+ * leaving state naming a deleted resource.
34562
+ *
34563
+ * Records only the DELIBERATE retention. A best-effort cleanup delete that
34564
+ * FAILED, was SKIPPED, or was blocked by a failed final snapshot also leaves
34565
+ * the old resource alive, but the deploy does not KNOW it survived — those
34566
+ * stay `false` and keep today's `reverse-replacement` behaviour rather than
34567
+ * having the rollback re-adopt an id it cannot vouch for (issue
34568
+ * [#2631](https://github.com/go-to-k/cdkd/issues/2631)).
34569
+ *
34570
+ * Cleared per `deploy()` alongside the other per-run maps: a `false` here
34571
+ * must mean "this deploy deleted it", never "a previous run said so".
34572
+ */
34573
+ retainedOldOnReplacement = /* @__PURE__ */ new Set();
34574
+ /**
32753
34575
  * Target region for this stack. Required — load-bearing for the
32754
34576
  * region-prefixed S3 state key and recorded in state.json for
32755
34577
  * cross-region destroy.
@@ -32788,6 +34610,7 @@ var DeployEngine = class {
32788
34610
  this.outputSecrets = /* @__PURE__ */ new Map();
32789
34611
  this.outputsTemplateSource = {};
32790
34612
  this.outputsSourceUsable = true;
34613
+ this.retainedOldOnReplacement = /* @__PURE__ */ new Set();
32791
34614
  this.resolver.resetPhysicalIdFallbackCount();
32792
34615
  return withStackName(stackName, () => this.doDeploy(stackName, template));
32793
34616
  }
@@ -33209,7 +35032,10 @@ var DeployEngine = class {
33209
35032
  for (const { logicalId, resource } of candidates) {
33210
35033
  let provider;
33211
35034
  try {
33212
- provider = this.providerRegistry.getProvider(resource.resourceType);
35035
+ provider = this.providerRegistry.getProviderFor({
35036
+ resourceType: resource.resourceType,
35037
+ provisionedBy: resource.provisionedBy
35038
+ }).provider;
33213
35039
  } catch {
33214
35040
  continue;
33215
35041
  }
@@ -33301,6 +35127,11 @@ var DeployEngine = class {
33301
35127
  diffResolverContext.skipDynamicReferences = true;
33302
35128
  const diffResolveFn = (value) => this.resolver.resolve(value, diffResolverContext);
33303
35129
  const changes = await this.diffCalculator.calculateDiff(currentState, effectiveTemplate, diffResolveFn, makeCanonicalizePropertiesFn(this.providerRegistry));
35130
+ const nestedStackTypeChanges = findNestedStackTypeChanges({
35131
+ changes,
35132
+ stateResources: currentState.resources
35133
+ });
35134
+ if (nestedStackTypeChanges.length > 0) throw markNonRetryable(new CdkdError(renderNestedStackTypeChangeRefusal(nestedStackTypeChanges, stackName), "TYPE_CHANGE_NESTED_STACK"));
33304
35135
  if (!this.diffCalculator.hasChanges(changes)) {
33305
35136
  this.logger.info("No changes detected. Stack is up to date.");
33306
35137
  let persistedOutputs = currentState.outputs ?? {};
@@ -33510,7 +35341,8 @@ var DeployEngine = class {
33510
35341
  provisionedBy: newResources[logicalId]?.provisionedBy ?? previousState?.provisionedBy,
33511
35342
  previousState,
33512
35343
  physicalId: newResources[logicalId]?.physicalId,
33513
- properties: newResources[logicalId]?.properties
35344
+ properties: newResources[logicalId]?.properties,
35345
+ ...change.changeType === "UPDATE" && { oldResourceRetained: this.retainedOldOnReplacement.has(logicalId) }
33514
35346
  });
33515
35347
  saveStateAfterResource(logicalId);
33516
35348
  }, () => this.interrupted);
@@ -34037,7 +35869,7 @@ var DeployEngine = class {
34037
35869
  isRetryable: isRecreateRetryableError
34038
35870
  });
34039
35871
  } catch (recreateError) {
34040
- throw new Error(maskSecretsInText(`Failed to re-create ${logicalId} after the --replace delete-first fallback already deleted the old resource (${currentResource.physicalId}): ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. Re-run the deploy to create it fresh.`, secrets));
35872
+ throw new Error(maskSecretsInText(`Failed to re-create ${logicalId} after the --replace delete-first fallback already deleted the old resource (${currentResource.physicalId}): ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. Re-run the deploy to create it fresh.`, secrets), { cause: recreateError instanceof Error ? recreateError : void 0 });
34041
35873
  }
34042
35874
  }
34043
35875
  /**
@@ -34134,15 +35966,16 @@ var DeployEngine = class {
34134
35966
  break;
34135
35967
  }
34136
35968
  const propertyDrivenReplacement = change.propertyChanges?.some((pc) => pc.requiresReplacement);
34137
- const recreateViaCcApi = this.options.recreateViaCcApiTargets?.has(logicalId) ?? false;
34138
- const recreateViaSdkProvider = this.options.recreateViaSdkProviderTargets?.has(logicalId) ?? false;
35969
+ const recreateTargets = this.options.recreateTargets?.stackName === stackName ? this.options.recreateTargets : void 0;
35970
+ const recreateViaCcApi = recreateTargets?.viaCcApi.has(logicalId) ?? false;
35971
+ const recreateViaSdkProvider = recreateTargets?.viaSdkProvider.has(logicalId) ?? false;
34139
35972
  const recreateFlagged = recreateViaCcApi || recreateViaSdkProvider;
34140
35973
  const needsReplacement = propertyDrivenReplacement || recreateFlagged;
34141
35974
  const dependencies = this.extractAllDependencies(template, logicalId);
34142
35975
  const updateReplacePolicy = template?.Resources?.[logicalId]?.UpdateReplacePolicy;
34143
35976
  if (needsReplacement) {
34144
35977
  if (propertyDrivenReplacement && !recreateFlagged && updateReplacePolicy !== "Retain") {
34145
- const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps);
35978
+ const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
34146
35979
  if (statefulReason && this.options.forceStatefulRecreation !== true) {
34147
35980
  const immutableProps = change.propertyChanges?.filter((pc) => pc.requiresReplacement).map((pc) => pc.path).join(", ");
34148
35981
  throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement (immutable property changed: ${immutableProps}) but it is a stateful resource — ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the immutable-property change.`, "STATEFUL_REPLACE_BLOCKED"));
@@ -34168,8 +36001,10 @@ var DeployEngine = class {
34168
36001
  let createResult;
34169
36002
  if (recreateFlagged) {
34170
36003
  const recreateFlagName = recreateViaCcApi ? "--recreate-via-cc-api" : "--recreate-via-sdk-provider";
34171
- 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.`);
34172
- else {
36004
+ if (updateReplacePolicy === "Retain") {
36005
+ this.retainedOldOnReplacement.add(logicalId);
36006
+ this.logger.warn(` ⚠ ${logicalId} has UpdateReplacePolicy: Retain — ${recreateFlagName} will leak the old physical resource (${currentResource.physicalId}). The new resource shares the same name where applicable; if the type has user-supplied names (e.g. functionName, bucketName), the create will deterministically collide with the retained orphan.`);
36007
+ } else {
34173
36008
  this.logger.info(` Destroying old ${logicalId} (${currentResource.physicalId}) before recreate...`);
34174
36009
  const recreateFinalSnapshotId = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, updateReplacePolicy);
34175
36010
  let recreateDeleteResult;
@@ -34220,8 +36055,10 @@ var DeployEngine = class {
34220
36055
  deletedOldFirst = true;
34221
36056
  createResult = await this.replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps, updateSecrets, updateReplacePolicy);
34222
36057
  }
34223
- if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain") this.logger.info(` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`);
34224
- else {
36058
+ if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain") {
36059
+ this.retainedOldOnReplacement.add(logicalId);
36060
+ this.logger.info(` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`);
36061
+ } else {
34225
36062
  this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
34226
36063
  let cleanupFinalSnapshotId;
34227
36064
  let snapshotBlockedDelete = false;
@@ -34278,6 +36115,7 @@ var DeployEngine = class {
34278
36115
  const updateProps = updateDecision.provisionedBy === "cc-api" ? this.preparePropertiesForCcApi(resourceType, resolvedProps, logicalId) : resolvedProps;
34279
36116
  let result;
34280
36117
  let resultProvisionedBy = updateDecision.provisionedBy;
36118
+ let captureProvider = updateProvider;
34281
36119
  try {
34282
36120
  result = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => updateProvider.update(logicalId, currentResource.physicalId, resourceType, updateProps, currentProps, {
34283
36121
  maskSecrets: createSecretMasker(updateSecrets),
@@ -34288,7 +36126,7 @@ var DeployEngine = class {
34288
36126
  const replaceOptIn = updateError instanceof ResourceUpdateNotSupportedError && this.options.replace === true;
34289
36127
  if (ccUnsupported || replaceOptIn) {
34290
36128
  const retainOldOnReplace = updateReplacePolicy === "Retain";
34291
- const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps);
36129
+ const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
34292
36130
  if (statefulReason && this.options.forceStatefulRecreation !== true) throw markNonRetryable(new CdkdError(replaceOptIn ? `--replace would DELETE + CREATE the stateful resource ${logicalId} (${resourceType}) — ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the immutable-property change.` : `${logicalId} (${resourceType}) cannot be updated in place by the provisioning layer it routes through, so applying this change would DELETE + CREATE it — but it is a stateful resource: ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the update.`, "STATEFUL_REPLACE_BLOCKED", updateError instanceof Error ? updateError : void 0));
34293
36131
  this.logger.info(retainOldOnReplace ? `UPDATE not supported for ${logicalId} (${resourceType}), replacing (CREATE only — UpdateReplacePolicy: Retain keeps the old resource)` : `UPDATE not supported for ${logicalId} (${resourceType}), replacing (DELETE → CREATE)`);
34294
36132
  if (!retainOldOnReplace) {
@@ -34319,7 +36157,7 @@ var DeployEngine = class {
34319
36157
  try {
34320
36158
  createResult = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => replProvider.create(logicalId, resourceType, replProps, { maskSecrets: createSecretMasker(updateSecrets) })), logicalId, void 0, void 0, replProvider);
34321
36159
  } catch (createError) {
34322
- if (!retainOldOnReplace) throw createError;
36160
+ if (!retainOldOnReplace) throw new Error(maskSecretsInText(`Failed to create ${logicalId} after the UPDATE-not-supported replacement: the old resource (${currentResource.physicalId}) is now gone. Cause: ${createError instanceof Error ? createError.message : String(createError)}. Re-run the deploy to create it fresh.`, updateSecrets), { cause: createError instanceof Error ? createError : void 0 });
34323
36161
  if (!isNameCollisionError(createError instanceof Error ? createError.message : String(createError))) throw createError;
34324
36162
  const nameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
34325
36163
  throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement because the provisioning layer cannot update it in place — but its physical name is still held by the existing resource AND UpdateReplacePolicy: Retain pins that resource in place. ${nameOrigin.descriptor}. ${nameOrigin.remedy} — with Retain, the old resource keeps the name, so a same-name replacement can never proceed. Removing UpdateReplacePolicy: Retain lets cdkd delete the old resource first, which destroys it and any data it holds.`, "NAMED_REPLACEMENT_COLLISION", createError instanceof Error ? createError : void 0));
@@ -34329,6 +36167,7 @@ var DeployEngine = class {
34329
36167
  const idempotentNameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
34330
36168
  throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement, but its Create API is name-idempotent: the create returned the existing resource (${currentResource.physicalId}) instead of creating a new one, and UpdateReplacePolicy: Retain pins that resource in place, so the new properties were not applied. ${idempotentNameOrigin.descriptor}. ${idempotentNameOrigin.remedy} — with Retain, the old resource keeps the name, so a same-name replacement can never proceed.`, "NAMED_REPLACEMENT_IDEMPOTENT_CREATE"));
34331
36169
  }
36170
+ this.retainedOldOnReplacement.add(logicalId);
34332
36171
  this.logger.warn(` ⚠ ${logicalId} has UpdateReplacePolicy: Retain — the old physical resource (${currentResource.physicalId}) is RETAINED and is no longer tracked by cdkd: it keeps running and incurring cost, and \`cdkd destroy\` will not remove it. Delete it yourself once you no longer need its data.`);
34333
36172
  retainedSurvivorReason = `UpdateReplacePolicy: Retain kept the old ${resourceType} (${currentResource.physicalId}), now untracked by cdkd`;
34334
36173
  }
@@ -34346,6 +36185,7 @@ var DeployEngine = class {
34346
36185
  if (createResult.effectiveProperties) replacementResult.effectiveProperties = createResult.effectiveProperties;
34347
36186
  result = replacementResult;
34348
36187
  resultProvisionedBy = replDecision.provisionedBy;
36188
+ captureProvider = replProvider;
34349
36189
  } else throw updateError;
34350
36190
  }
34351
36191
  if (result.wasReplaced) this.logger.info(`Resource ${logicalId} was replaced: ${currentResource.physicalId} -> ${result.physicalId}`);
@@ -34365,7 +36205,7 @@ var DeployEngine = class {
34365
36205
  provisionedBy: resultProvisionedBy
34366
36206
  };
34367
36207
  const updateCaptureSiblings = await this.buildObservedCaptureSiblings(resourceType, logicalId, result.physicalId, template, stateResources, stackName, parameterValues, conditions);
34368
- this.kickOffObservedCapture(updateProvider, logicalId, result.physicalId, resourceType, resolvedProps, updateCaptureSiblings);
36208
+ this.kickOffObservedCapture(captureProvider, logicalId, result.physicalId, resourceType, resolvedProps, updateCaptureSiblings);
34369
36209
  const updatePartial = updatePartialReason(result);
34370
36210
  if (counts) if (updatePartial !== void 0) counts.updatePartial++;
34371
36211
  else counts.updated++;
@@ -34818,5 +36658,5 @@ var DeployEngine = class {
34818
36658
  };
34819
36659
 
34820
36660
  //#endregion
34821
- export { beginCommandInterruptScope as $, resolveAutoAssetStorage as $n, isTransientServerError as $r, maskSecretsInError as $t, formatResourceLine as A, ensureAssetStorage as An, DependencyError as Ar, requireConfigString as At, isExportAliasCollision as B, dockerSpawnEnvWithSensitive as Bn, ResourceTimeoutError as Br, INTRINSIC_KEYS as Bt, unsupportedFinalSnapshotError as C, loadPublishableAssetManifest as Cn, getAwsClients as Cr, coerceCfnBoolean as Ct, isStatefulRecreateTargetForReplace as D, AssetModeResolver as Dn, CdkdError as Dr, replayWarn as Dt, MULTI_REGION_RECREATE_BLOCKED_TYPES as E, stripControlChars as En, AssetError as Er, readConfigString as Et, red as F, validateAssetBucketName as Fn, LocalStartServiceError as Fr, s3BucketDualStackDomainName as Ft, collectInlinePolicyNamesManagedBySiblings as G, runDockerStreaming as Gn, SynthesisError as Gr, STATE_SOURCED_CROSS_GENERATION_RULES as Gt, stateKeySecretExposure as H, getDockerCmd as Hn, StackHasActiveImportsError as Hr, withRetry as Ht, yellow as I, validateContainerRepoName as In, LockError as Ir, s3BucketRegionalDomainName as It, findActionableSilentDrops as J, Synthesizer as Jn, normalizeAwsError as Jr, carriesSecretMask as Jt, clearOnUpdateRemoval as K, AssetManifestLoader as Kn, formatError as Kr, STATE_SOURCED_READBACK_RULES as Kt, collectDeclaredOutputNames as L, buildDenyExternalAccessPolicy as Ln, NestedStackChildDirectDestroyError as Lr, s3BucketWebsiteUrl as Lt, cyan as M, isCrossRegionRedirect as Mn, DynamicReferenceRegionAmbiguousError as Mr, producerRegionsFromState as Mt, gray as N, parseBootstrapMarker as Nn, IntrinsicResolutionRefusalError as Nr, s3BucketArn as Nt, isStatefulRecreateTargetSync as O, BOOTSTRAP_MARKER_PREFIX as On, ConfigError as Or, requireConfigArray as Ot, green as P, readBootstrapMarkerBody as Pn, LocalInvokeBuildError as Pr, s3BucketDomainName as Pt, maskerOrIdentity as Q, resolveApp as Qn, isThrottlingError as Qr, isSingleDynamicReferenceToken as Qt, collectPublishedOutputNames as R, describeAwsFailure as Rn, PartialFailureError as Rr, applyRoleArnIfSet as Rt, refusesFinalSnapshot as S, createAssetRedirectResolver as Sn, AwsClients as Sr, assertRegionMatch as St, extractDeploymentEventError as T, escapeRegExp$1 as Tn, setAwsClients as Tr, configStringRefusal as Tt, getCurrentResourceSecrets as U, partitionSensitiveEnv as Un, StackTerminationProtectionError as Ur, DagBuilder as Ut, secretBearingStateKeyWarning as V, formatDockerLoginError as Vn, ResourceUpdateNotSupportedError as Vr, describeTypeWithThrottleRetry as Vt, IAMRoleProvider as W, runDockerForeground as Wn, StateError as Wr, TemplateParser as Wt, createMaskedRetryLogger as X, getDefaultStateBucketName as Xn, isMarkedNonRetryable as Xr, dynamicReferenceTokens as Xt, findSilentDropProperties as Y, synthesisStatusMessage as Yn, withErrorHandling as Yr, createSecretMasker as Yt, maskDeep as Z, getLegacyStateBucketName as Zn, isRetryableTransientError as Zr, errorCauseChain as Zt, PRE_DELETE_SNAPSHOT_TYPES as _, shouldRetainResource as _n, derivePartitionAndUrlSuffix as _r, parameterTypeMayLoseSecretIdentity as _t, DeploymentEventsStore as a, LockManager as an, stateBucketExistenceConfirmed as ar, slowCcOperationTimeoutMs as at, createPreDeleteFinalSnapshot as b, WorkGraph as bn, clearBucketRegionCache as br, normalizeAwsTagsToCfn as bt, replayFailedOperations as c, UNRENDERABLE as cn, CFN_TEMPLATE_URL_LIMIT as cr, deleteSkipReason as ct, updatePartialReason as d, forceQuitRecoveryClause as dn, uploadCfnTemplate as dr, IntrinsicFunctionResolver as dt, markNonRetryable as ei, maskSecretsInText as en, resolveCaptureObservedState as er, endCommandInterruptScope as et, withResourceDeadline as f, CUSTOM_RESOURCE_RESPONSE_PREFIX as fn, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as fr, carriesDynamicReference as ft, ATOMIC_FINAL_SNAPSHOT_TYPES as g, importableOutputs as gn, canonicalizeRegion as gr, isUnboundTemplateParameter as gt, computeImplicitDeleteEdges as h, importableOutputKeys as hn, PARTITION_TABLE as hr, getAccountInfo as ht, DeploymentEventsReader as i, scrubResourceRecord as in, resolveUseCdkBootstrapAssets as ir, CloudControlProvider as it, bold as j, getBootstrapMarkerKey as jn, DeployCancelledError as jr, classifyReplaySecretRegion as jt, renderStatefulReason as k, assertAssetBucketRegion as kn, CrossAccountSecretRefusalError as kr, requireConfigObject as kt, replayRollback as l, buildForceUnlockCommand as ln, MIGRATE_TMP_PREFIX as lr, disableInstanceApiTermination as lt, IMPLICIT_DELETE_DEPENDENCIES as m, exportNamesCarriedFrom as mn, expectedOwnerParam as mr, coerceParameterTypedValue as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, retryClassificationText as ni, recoverMaskedOutput as nn, resolveStateBucketWithDefault as nr, isInterruptedWaitError as nt, planFailedOps as o, S3StateBackend as on, warnDeprecatedNoPrefixCliFlag as or, UNSPECIFIED_SKIP_REASON as ot, maskingRetryLogger as p, DEFAULT_STATE_PREFIX as pn, displaySafe as pr, cfnRefValueFromPhysicalId as pt, ProviderRegistry as q, getDockerImageBySourceHash as qn, isCdkdError as qr, TEMPLATE_SOURCED_RULES as qt, DeployEngine as r, __exportAll as ri, redactSecretsForState as rn, resolveStateBucketWithDefaultAndSource as rr, startInterruptWatch as rt, planRollback as s, rebuildClientForBucketRegion as sn, CFN_TEMPLATE_BODY_LIMIT as sr, deleteIndeterminateGuards as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, markRedactedCause as ti, recordMaskOnlyValue as tn, resolveSkipPrefix as tr, interruptWatchListenerCount as tt, updatePartialMessage as u, buildLockContentionMessage as un, findLargeInlineResources as ur, isTerminationProtectionPropagationError as ut, buildFinalSnapshotIdentifier as v, AssetPublisher as vn, AssemblyReader as vr, refStateLookupFromResource as vt, makeCanonicalizePropertiesFn as w, rewriteTemplateAssetReferences as wn, resetAwsClients as wr, configBooleanRefusal as wt, isFinalSnapshotError as x, buildAssetRedirectMap as xn, resolveBucketRegion as xr, resolveExplicitPhysicalId as xt, ccRoutedFinalSnapshotError as y, stringifyValue as yn, processStackMessages as yr, WAFv2WebACLProvider as yt, exportAliasCollisionScrubWarning as z, buildDockerImage as zn, ProvisioningError as zr, DiffCalculator as zt };
34822
- //# sourceMappingURL=deploy-engine-BoSlW08T.js.map
36661
+ export { findSilentDropProperties as $, escapeRegExp$1 as $n, StateError as $r, createSecretMasker as $t, WARM_THROUGHPUT_MEMBERS as A, AssetModeResolver as An, resolveBucketRegion as Ar, readConfigString as At, yellow as B, buildDenyExternalAccessPolicy as Bn, DeployCancelledError as Br, s3BucketRegionalDomainName as Bt, unsupportedFinalSnapshotError as C, AssetPublisher as Cn, expectedOwnerParam as Cr, WAFv2WebACLProvider as Ct, isStatefulRecreateTargetForReplace as D, createAssetRedirectResolver as Dn, AssemblyReader as Dr, coerceCfnBoolean as Dt, MULTI_REGION_RECREATE_BLOCKED_TYPES as E, buildAssetRedirectMap as En, derivePartitionAndUrlSuffix as Er, assertRegionMatch as Et, bold as F, isCrossRegionRedirect as Fn, AssetError as Fr, classifyReplaySecretRegion as Ft, secretBearingStateKeyWarning as G, describeDockerFailure as Gn, LockError as Gr, describeTypeWithThrottleRetry as Gt, collectPublishedOutputNames as H, buildDockerImage as Hn, IntrinsicResolutionRefusalError as Hr, applyRoleArnIfSet as Ht, cyan as I, parseBootstrapMarker as In, CdkdError as Ir, producerRegionsFromState as It, IAMRoleProvider as J, getDockerCmd as Jn, ProvisioningError as Jr, TemplateParser as Jt, stateKeySecretExposure as K, dockerSpawnEnvWithSensitive as Kn, NestedStackChildDirectDestroyError as Kr, withRetry as Kt, gray as L, readBootstrapMarkerBody as Ln, ConfigError as Lr, s3BucketArn as Lt, isWarmThroughputDecrease as M, assertAssetBucketRegion as Mn, getAwsClients as Mr, requireConfigArray as Mt, toFiniteNumber as N, ensureAssetStorage as Nn, resetAwsClients as Nr, requireConfigObject as Nt, isStatefulRecreateTargetSync as O, loadPublishableAssetManifest as On, processStackMessages as Or, configBooleanRefusal as Ot, formatResourceLine as P, getBootstrapMarkerKey as Pn, setAwsClients as Pr, requireConfigString as Pt, findActionableSilentDrops as Q, runDockerStreaming as Qn, StackTerminationProtectionError as Qr, carriesSecretMask as Qt, green as R, validateAssetBucketName as Rn, CrossAccountSecretRefusalError as Rr, s3BucketDomainName as Rt, refusesFinalSnapshot as S, shouldRetainResource as Sn, displaySafe as Sr, refStateLookupFromResource as St, extractDeploymentEventError as T, WorkGraph as Tn, canonicalizeRegion as Tr, resolveExplicitPhysicalId as Tt, exportAliasCollisionScrubWarning as U, describeDockerCapturedOutput as Un, LocalInvokeBuildError as Ur, DiffCalculator as Ut, collectDeclaredOutputNames as V, describeAwsFailure as Vn, DynamicReferenceRegionAmbiguousError as Vr, s3BucketWebsiteUrl as Vt, isExportAliasCollision as W, describeDockerExecFailure as Wn, LocalStartServiceError as Wr, INTRINSIC_KEYS as Wt, clearOnUpdateRemoval as X, redactDockerArgvValues as Xn, ResourceUpdateNotSupportedError as Xr, STATE_SOURCED_READBACK_RULES as Xt, collectInlinePolicyNamesManagedBySiblings as Y, partitionSensitiveEnv as Yn, ResourceTimeoutError as Yr, STATE_SOURCED_CROSS_GENERATION_RULES as Yt, ProviderRegistry as Z, runDockerForeground as Zn, StackHasActiveImportsError as Zr, TEMPLATE_SOURCED_RULES as Zt, PRE_DELETE_SNAPSHOT_TYPES as _, CUSTOM_RESOURCE_RESPONSE_PREFIX as _n, CFN_TEMPLATE_URL_LIMIT as _r, cfnRefValueFromPhysicalId as _t, DeploymentEventsStore as a, isMarkedNonRetryable as ai, recordMaskOnlyValue as an, getDefaultStateBucketName as ar, interruptWatchListenerCount as at, createPreDeleteFinalSnapshot as b, importableOutputKeys as bn, uploadCfnTemplate as br, isUnboundTemplateParameter as bt, replayFailedOperations as c, isTransientServerError as ci, scrubResourceRecord as cn, resolveAutoAssetStorage as cr, CloudControlProvider as ct, updatePartialReason as d, retryClassificationText as di, rebuildClientForBucketRegion as dn, resolveStateBucketWithDefault as dr, deleteIndeterminateGuards as dt, SynthesisError as ei, dynamicReferenceTokens as en, stripControlChars as er, createMaskedRetryLogger as et, withResourceDeadline as f, __exportAll as fi, UNRENDERABLE as fn, resolveStateBucketWithDefaultAndSource as fr, deleteSkipReason as ft, ATOMIC_FINAL_SNAPSHOT_TYPES as g, shellQuote as gn, CFN_TEMPLATE_BODY_LIMIT as gr, carriesDynamicReference as gt, computeImplicitDeleteEdges as h, forceQuitRecoveryClause as hn, warnDeprecatedNoPrefixCliFlag as hr, IntrinsicFunctionResolver as ht, DeploymentEventsReader as i, withErrorHandling as ii, maskSecretsInText as in, synthesisStatusMessage as ir, endCommandInterruptScope as it, coerceWarmThroughput as j, BOOTSTRAP_MARKER_PREFIX as jn, AwsClients as jr, replayWarn as jt, renderStatefulReason as k, rewriteTemplateAssetReferences as kn, clearBucketRegionCache as kr, configStringRefusal as kt, replayRollback as l, markNonRetryable as li, LockManager as ln, resolveCaptureObservedState as lr, slowCcOperationTimeoutMs as lt, IMPLICIT_DELETE_DEPENDENCIES as m, buildLockContentionMessage as mn, stateBucketExistenceConfirmed as mr, isTerminationProtectionPropagationError as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, isCdkdError as ni, isSingleDynamicReferenceToken as nn, getDockerImageBySourceHash as nr, maskerOrIdentity as nt, planFailedOps as o, isRetryableTransientError as oi, recoverMaskedOutput as on, getLegacyStateBucketName as or, isInterruptedWaitError as ot, maskingRetryLogger as p, buildForceUnlockCommand as pn, resolveUseCdkBootstrapAssets as pr, disableInstanceApiTermination as pt, getCurrentResourceSecrets as q, formatDockerLoginError as qn, PartialFailureError as qr, DagBuilder as qt, DeployEngine as r, normalizeAwsError as ri, maskSecretsInError as rn, Synthesizer as rr, beginCommandInterruptScope as rt, planRollback as s, isThrottlingError as si, redactSecretsForState as sn, resolveApp as sr, startInterruptWatch as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, formatError as ti, errorCauseChain as tn, AssetManifestLoader as tr, maskDeep as tt, updatePartialMessage as u, markRedactedCause as ui, S3StateBackend as un, resolveSkipPrefix as ur, UNSPECIFIED_SKIP_REASON as ut, buildFinalSnapshotIdentifier as v, DEFAULT_STATE_PREFIX as vn, MIGRATE_TMP_PREFIX as vr, coerceParameterTypedValue as vt, makeCanonicalizePropertiesFn as w, stringifyValue as wn, PARTITION_TABLE as wr, normalizeAwsTagsToCfn as wt, isFinalSnapshotError as x, importableOutputs as xn, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as xr, parameterTypeMayLoseSecretIdentity as xt, ccRoutedFinalSnapshotError as y, exportNamesCarriedFrom as yn, findLargeInlineResources as yr, getAccountInfo as yt, red as z, validateContainerRepoName as zn, DependencyError as zr, s3BucketDualStackDomainName as zt };
36662
+ //# sourceMappingURL=deploy-engine-DylsbAdu.js.map