@go-to-k/cdkd 0.284.21 → 0.284.23

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.
@@ -11106,6 +11106,29 @@ function dynamicReferenceTokens(value) {
11106
11106
  return value.match(DYNAMIC_REFERENCE_TOKEN_SCAN) ?? [];
11107
11107
  }
11108
11108
  /**
11109
+ * Where each complete `{{resolve:...}}` token sits in the string, as
11110
+ * `[start, end)` offsets. The OFFSETS are what {@link dynamicReferenceTokens}
11111
+ * cannot give, and the value scan needs them to decide whether a needle match
11112
+ * lies inside a reference or merely beside one.
11113
+ *
11114
+ * `lastIndex` is reset before `matchAll`, and that is load-bearing rather than
11115
+ * defensive. `String.prototype.matchAll` does not MUTATE the pattern's
11116
+ * `lastIndex` — it clones — but it SEEDS the clone from it, so a caller that
11117
+ * left the shared constant dirty (the constant's own doc forbids `.exec` /
11118
+ * `.test` on it for exactly this reason) would make this function skip every
11119
+ * span before that offset, silently restoring the splice this offsets are used
11120
+ * to prevent. Measured, not assumed.
11121
+ */
11122
+ function dynamicReferenceSpans(value) {
11123
+ DYNAMIC_REFERENCE_TOKEN_SCAN.lastIndex = 0;
11124
+ const spans = [];
11125
+ for (const match of value.matchAll(DYNAMIC_REFERENCE_TOKEN_SCAN)) spans.push({
11126
+ start: match.index,
11127
+ end: match.index + match[0].length
11128
+ });
11129
+ return spans;
11130
+ }
11131
+ /**
11109
11132
  * Does this MIXED leaf embed a reference that may be PUBLIC config?
11110
11133
  *
11111
11134
  * A plain `{{resolve:ssm:...}}` is classified by the parameter's TYPE, not by
@@ -11283,6 +11306,92 @@ function redactSecretsForState(bag, secrets, source, rules = TEMPLATE_DERIVED_RU
11283
11306
  }
11284
11307
  const regex = buildNeedleRegex(secrets.keys());
11285
11308
  const wholeValueExpr = (s) => s === "" ? void 0 : secrets.get(s);
11309
+ /**
11310
+ * The SUBSTRING arm for a leaf the resolver substituted INTO rather than
11311
+ * replaced. ONE rule over the WHOLE leaf (issue
11312
+ * [#1935](https://github.com/go-to-k/cdkd/issues/1935)):
11313
+ *
11314
+ * > replace every recorded-plaintext match EXCEPT one that lies STRICTLY
11315
+ * > INSIDE a complete `{{resolve:...}}` span.
11316
+ *
11317
+ * "Strictly inside" means contained by a span and SHORTER than it. The four
11318
+ * positions a match can take, and why each lands where it does:
11319
+ *
11320
+ * - **strictly inside a span** -> KEPT. This is the defect: a plaintext that
11321
+ * happens to occur inside a token's own TEXT was spliced into the
11322
+ * reference. Deploy 1 persists
11323
+ * `jdbc://appdb:{{resolve:secretsmanager:appdb/creds:SecretString:password}}@host`;
11324
+ * deploy 2 records an ssm SecureString whose plaintext is `appdb`; the walk
11325
+ * wrote `{{resolve:secretsmanager:{{resolve:ssm:/app/dbname}}/creds:...}}`.
11326
+ * `resolveReplayProps` scans with `([^}]+)`, which stops at the FIRST `}`,
11327
+ * so the replay asks Secrets Manager for the secret id
11328
+ * `{{resolve:ssm:/app/dbname` — rollback blocked, or garbage applied to a
11329
+ * live resource. `cdkd scrub` writes the same wreckage into `properties`
11330
+ * and `observedProperties`.
11331
+ * - **coextensive with a span** -> REPLACED. A secret whose resolved
11332
+ * PLAINTEXT is itself a `{{resolve:...}}` string (issue #1917), embedded in
11333
+ * a larger leaf. This is why "mask only OUTSIDE the spans" is wrong on its
11334
+ * own: that plaintext IS a span, so span-skipping would stop redacting it
11335
+ * and trade a mangling bug for a disclosure.
11336
+ * - **containing or straddling a span** -> REPLACED. A recorded plaintext
11337
+ * that embeds a whole reference plus surrounding text. Nothing is spliced,
11338
+ * because the whole reference is consumed by the replacement. An earlier
11339
+ * revision of this fix expressed the rule as TWO rules — replace a span
11340
+ * that is a recorded plaintext, value-scan the text between spans — and
11341
+ * that form DROPPED this case at both ends (it is neither a whole span nor
11342
+ * contained in the text between spans), persisting the plaintext in the
11343
+ * clear where the pre-fix code had redacted it. A REGRESSION, caught by the
11344
+ * security review, and the reason the rule is one predicate over the whole
11345
+ * leaf rather than a split.
11346
+ * - **disjoint from every span** -> REPLACED. The ordinary embedded secret.
11347
+ *
11348
+ * Scanning the WHOLE leaf in ONE pass is also what preserves what needle
11349
+ * PRECEDENCE there is. {@link buildNeedleRegex} sorts alternatives
11350
+ * longest-first, which decides only between alternatives matching at the SAME
11351
+ * offset; the scan itself is LEFTMOST-first, so a shorter secret starting
11352
+ * EARLIER still wins and the tail of the longer one survives in the clear
11353
+ * (`zzABCDEFzz` with needles `ABCD` / `BCDEF` leaves `EF`). That is regex
11354
+ * semantics, identical before and after this change, and is not something
11355
+ * this rule claims to fix.
11356
+ *
11357
+ * What the single pass DOES restore is the same-offset ordering across a span
11358
+ * boundary. The two-rule form scanned each BETWEEN-span stretch separately,
11359
+ * so a long straddling needle was never even a candidate and a short one
11360
+ * starting later in the tail won by default — the leaf took the WRONG
11361
+ * expression, which the replay then re-resolves and applies (the issue #1910
11362
+ * class).
11363
+ *
11364
+ * TWO KNOWN RESIDUALS around a STRAY `{{resolve:` opener, both pinned by
11365
+ * tests rather than left as prose, and they fail in OPPOSITE directions
11366
+ * because the span grammar is greedy `[^}]+` (deliberately — it is the
11367
+ * resolver's own spelling, unified by issue #1936):
11368
+ *
11369
+ * - **no later `}}` in the leaf** -> no span, so a needle after the opener is
11370
+ * REPLACED and the result reads as a reference to a bogus secret id.
11371
+ * Identical to the pre-fix code. Refusing to redact there would leave
11372
+ * PLAINTEXT behind two characters any string can contain.
11373
+ * - **a later `}}` anywhere in the leaf** -> the opener and that `}}` form
11374
+ * ONE span swallowing everything between them, so a needle in that region
11375
+ * is KEPT — the only shape where this rule redacts LESS than the code it
11376
+ * replaced. Narrow but real, and the reachable carrier is named rather than
11377
+ * waved at: the resolver shares this grammar, so such a leaf could not have
11378
+ * resolved on the deploy path, which leaves an `observedProperties`
11379
+ * READBACK (arbitrary text from AWS) as the way one arrives.
11380
+ *
11381
+ * Narrowing the span pattern here would close the second and open two worse
11382
+ * holes: it would re-fork the one grammar issue #1936 unified, and it would
11383
+ * make an ALREADY-MANGLED legacy leaf parse differently and be spliced again,
11384
+ * contradicting this change's own "not repaired, not made worse" property.
11385
+ * So the residual is documented, not fixed.
11386
+ */
11387
+ const scanLeaf = (value, needles) => {
11388
+ const spans = isDynamicReferenceString(value) ? dynamicReferenceSpans(value) : [];
11389
+ return value.replace(needles, (match, offset) => {
11390
+ const end = offset + match.length;
11391
+ if (spans.some((span) => span.start <= offset && end <= span.end && (span.start !== offset || span.end !== end))) return match;
11392
+ return secrets.get(match) ?? "***";
11393
+ });
11394
+ };
11286
11395
  const walk = (value) => {
11287
11396
  if (typeof value === "string") {
11288
11397
  const whole = wholeValueExpr(value);
@@ -11291,8 +11400,7 @@ function redactSecretsForState(bag, secrets, source, rules = TEMPLATE_DERIVED_RU
11291
11400
  if (!regex) return value;
11292
11401
  regex.lastIndex = 0;
11293
11402
  if (!regex.test(value)) return value;
11294
- regex.lastIndex = 0;
11295
- return value.replace(regex, (m) => secrets.get(m) ?? "***");
11403
+ return scanLeaf(value, regex);
11296
11404
  }
11297
11405
  if (Array.isArray(value)) return value.map(walk);
11298
11406
  if (value !== null && typeof value === "object") {
@@ -16752,7 +16860,7 @@ var CloudControlProvider = class {
16752
16860
  if (context?.finalSnapshotIdentifier !== void 0) throw new ProvisioningError(`${logicalId} (${resourceType}) requires a final snapshot (DeletionPolicy: Snapshot), but the Cloud Control API delete route has no final-snapshot parameter. Re-run with --skip-final-snapshot after snapshotting manually, or retain the resource.`, resourceType, logicalId, physicalId);
16753
16861
  if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
16754
16862
  this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
16755
- const { ASGProvider } = await import("./asg-provider-D5G53CUp.js").then((n) => n.n);
16863
+ const { ASGProvider } = await import("./asg-provider-Cucl2K22.js").then((n) => n.n);
16756
16864
  return await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context);
16757
16865
  }
16758
16866
  const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
@@ -24119,7 +24227,7 @@ async function replayRollback(operations, stateResources, stackName, ctx, option
24119
24227
  eventType: "ROLLBACK_STARTED",
24120
24228
  stackName
24121
24229
  });
24122
- const resolver = new IntrinsicFunctionResolver(ctx.region);
24230
+ const resolver = new ReplayResolvers(ctx.region);
24123
24231
  const { createOps, otherOps } = partitionOps(operations);
24124
24232
  for (let i = otherOps.length - 1; i >= 0; i--) {
24125
24233
  if (options.isInterrupted?.()) {
@@ -24184,6 +24292,21 @@ async function replayRollback(operations, stateResources, stackName, ctx, option
24184
24292
  * Cognito `client_secret`). Rollback is synth-free, so re-resolve straight from
24185
24293
  * the expression string here.
24186
24294
  *
24295
+ * BOTH SIDES OF A DIFF ARE CLASSIFIED, not just the bag that is written, and
24296
+ * that is deliberate (issue #2057 review). The `revert` / `--revert-failed`
24297
+ * arms call this twice — once for the desired bag and once for the CURRENT /
24298
+ * ATTEMPTED one, which only becomes the provider's `previousProperties`. Two
24299
+ * things make a wrong-region value there consequential rather than cosmetic:
24300
+ * a patch-based provider computes its patch previous-vs-desired, so a wrong
24301
+ * previous side can emit a wrong patch or, when both sides carry the same
24302
+ * expression and resolve to the same wrong value, silently compute a NO-OP and
24303
+ * skip the revert entirely; and every resolved plaintext lands in the SHARED
24304
+ * per-op `secrets` map, which is the redaction needle for the state record this
24305
+ * op persists, so a foreign-region plaintext mis-redacts that record. In
24306
+ * practice both bags carry the SAME expression (state redacts them identically),
24307
+ * so scoping the refusal to the written bag would buy a rare case at the cost of
24308
+ * a rule nobody could apply by reading one call site.
24309
+ *
24187
24310
  * Records each `plaintext -> expression` into `secrets` so the caller can redact
24188
24311
  * the persisted state record back to the expression — the same
24189
24312
  * resolve-for-provider + redact-for-state split the deploy engine applies at its
@@ -24197,28 +24320,365 @@ async function replayRollback(operations, stateResources, stackName, ctx, option
24197
24320
  * `StringList` parameter is public config, stored resolved, and never appears
24198
24321
  * as an expression in the journal.
24199
24322
  */
24200
- async function resolveReplayProps(props, resolver, secrets) {
24323
+ async function resolveReplayProps(props, resolvers, secrets, execCtx, logicalId) {
24201
24324
  if (props === void 0) return void 0;
24202
- const ctx = {
24325
+ const resolverContext = {
24203
24326
  template: { Resources: {} },
24204
24327
  resources: {},
24205
24328
  recordedSecretValues: secrets
24206
24329
  };
24207
- const walk = async (v) => {
24208
- if (typeof v === "string") return v.includes("{{resolve:") ? await resolver.resolveDynamicReferences(v, ctx) : v;
24330
+ const walk = async (v, path) => {
24331
+ if (typeof v === "string") {
24332
+ if (!v.includes("{{resolve:")) return v;
24333
+ return await resolveLeafByRegion(v, path, logicalId, execCtx, resolvers, resolverContext);
24334
+ }
24209
24335
  if (Array.isArray(v)) {
24210
24336
  const out = new Array(v.length);
24211
- for (let i = 0; i < v.length; i++) out[i] = await walk(v[i]);
24337
+ for (let i = 0; i < v.length; i++) out[i] = await walk(v[i], `${path}[${i}]`);
24212
24338
  return out;
24213
24339
  }
24214
24340
  if (v !== null && typeof v === "object") {
24215
24341
  const out = {};
24216
- for (const [k, val] of Object.entries(v)) out[k] = await walk(val);
24342
+ for (const [k, val] of Object.entries(v)) out[k] = await walk(val, path === "" ? k : `${path}.${k}`);
24217
24343
  return out;
24218
24344
  }
24219
24345
  return v;
24220
24346
  };
24221
- return await walk(props);
24347
+ return await walk(props, "");
24348
+ }
24349
+ /**
24350
+ * The `{{resolve:<service>:...}}` families whose value can be a SECRET, and
24351
+ * therefore the only ones the region question below is asked about: every
24352
+ * `secretsmanager` reference by spelling, and every `ssm` one, which is secret
24353
+ * exactly when its parameter is a `SecureString` (issue #1901).
24354
+ *
24355
+ * Every OTHER service is `local` because cdkd cannot resolve it at all, NOT
24356
+ * because it is public. `ssm-secure` is the live example and is emphatically
24357
+ * not public: `resolveDynamicReferences` has no arm for it, so the literal
24358
+ * token is passed through to AWS and CloudFormation resolves it SERVER-side.
24359
+ * cdkd never holds its value, so there is no region for cdkd to get wrong —
24360
+ * which is the only reason it can be waved through here.
24361
+ */
24362
+ const REPLAY_SECRET_SERVICES = /* @__PURE__ */ new Set(["secretsmanager", "ssm"]);
24363
+ /**
24364
+ * Split a `{{resolve:secretsmanager:...}}` inner body into its SECRET_ID.
24365
+ *
24366
+ * Mirrors `IntrinsicFunctionResolver.resolveSecretsManagerReference`'s own
24367
+ * split — including the END-ANCHORED whole-secret form — because a secret ID
24368
+ * may legitimately contain colons (an ARN always does), so `split(':')[1]` is
24369
+ * wrong for exactly the shape this file cares about most.
24370
+ */
24371
+ function secretsManagerSecretId(inner) {
24372
+ const afterService = inner.substring(15);
24373
+ let stringIdx = afterService.indexOf(":SecretString:");
24374
+ let binaryIdx = afterService.indexOf(":SecretBinary:");
24375
+ if (stringIdx < 0 && afterService.endsWith(":SecretString")) stringIdx = afterService.length - 13;
24376
+ if (binaryIdx < 0 && afterService.endsWith(":SecretBinary")) binaryIdx = afterService.length - 13;
24377
+ const delimiterIdx = stringIdx >= 0 && binaryIdx >= 0 ? Math.min(stringIdx, binaryIdx) : stringIdx >= 0 ? stringIdx : binaryIdx;
24378
+ return delimiterIdx >= 0 ? afterService.substring(0, delimiterIdx) : afterService;
24379
+ }
24380
+ /**
24381
+ * The parameter name an `{{resolve:ssm:...}}` reference asks for — byte-for-byte
24382
+ * what `IntrinsicFunctionResolver.resolveSSMReference` passes as `GetParameter`'s
24383
+ * `Name`, which is `parts.slice(1).join(':')` on the colon-split inner body.
24384
+ *
24385
+ * The whole remainder, deliberately, with NOTHING stripped:
24386
+ *
24387
+ * - An SSM dynamic reference CAN name a full ARN. The resolver joins the tail
24388
+ * back together, so `{{resolve:ssm:arn:aws:ssm:us-west-2:111122223333:parameter/db/pw}}`
24389
+ * reaches AWS as that ARN. A `split(':')[1]` here would yield the literal
24390
+ * `'arn'` — a parameter that does not exist — and then report the reference
24391
+ * as region-LESS and refuse it, which is the guess-in-the-other-direction the
24392
+ * `named-region` arm exists to prevent.
24393
+ * - A trailing `:<version>` / `:<label>` is part of the name AS SSM PARSES IT
24394
+ * (`GetParameter` accepts `name:3` / `name:prod`), so stripping it would name
24395
+ * a different thing in the refusal message than the one that would be read.
24396
+ */
24397
+ function ssmParameterName(inner) {
24398
+ return inner.substring(4);
24399
+ }
24400
+ /**
24401
+ * The region an ARN names, or `undefined` for anything that is not an ARN with
24402
+ * a populated region field (`arn:<partition>:<service>:<region>:...`).
24403
+ */
24404
+ function arnRegion(secretId) {
24405
+ if (!secretId.startsWith("arn:")) return void 0;
24406
+ const region = secretId.split(":")[3];
24407
+ return region ? region : void 0;
24408
+ }
24409
+ /**
24410
+ * The producer regions a stack's persisted cross-stack reads name, for
24411
+ * {@link RollbackExecutorContext.importedProducerRegions} (issue #2057).
24412
+ *
24413
+ * Both record kinds count, and for the same reason: each one is a value this
24414
+ * stack read out of ANOTHER region's state, so each one is a way a
24415
+ * foreign-region `{{resolve:...}}` expression can have reached this stack's own
24416
+ * record. `imports` is the strong `Fn::ImportValue` edge; `outputReads` is the
24417
+ * weak `Fn::GetStackOutput` one (schema v8), which is the EASIER of the two to
24418
+ * point across a region boundary because the reference carries its own
24419
+ * `Region` argument.
24420
+ *
24421
+ * Deduplicated case-insensitively, keeping each region's first-recorded
24422
+ * spelling so the refusal message echoes what the user will see in
24423
+ * `state.json`. The consumer's own region is deliberately NOT filtered here —
24424
+ * {@link classifyReplaySecretRegion} does that, because it is the one that
24425
+ * knows which region is asking.
24426
+ *
24427
+ * Exported so the two `RollbackExecutorContext` construction sites derive the
24428
+ * list identically — `cdkd rollback` from the state it loaded, and
24429
+ * `DeployEngine.rollbackExecutorContext` from `crossStackReadsForPartialSave`,
24430
+ * which unions that snapshot with the reads the failing deploy itself made.
24431
+ */
24432
+ function producerRegionsFromState(state) {
24433
+ const seen = /* @__PURE__ */ new Set();
24434
+ const regions = [];
24435
+ for (const entry of [...state.imports ?? [], ...state.outputReads ?? []]) {
24436
+ const canonical = canonicalizeRegion(entry.sourceRegion);
24437
+ if (!canonical || seen.has(canonical)) continue;
24438
+ seen.add(canonical);
24439
+ regions.push(entry.sourceRegion);
24440
+ }
24441
+ return regions;
24442
+ }
24443
+ /**
24444
+ * Decide which region must answer for a single `{{resolve:...}}` expression a
24445
+ * rollback replay is about to re-resolve — issue
24446
+ * [#2057](https://github.com/go-to-k/cdkd/issues/2057).
24447
+ *
24448
+ * WHY A REPLAY CAN BE HOLDING A FOREIGN REGION'S EXPRESSION AT ALL. Since
24449
+ * issue #1934 a cross-stack consumer re-resolves a redacted producer value in
24450
+ * the PRODUCER's region (`reresolveCrossStackValue` /
24451
+ * `resolverForProducerRegion`) — correct, because a Secrets Manager secret or
24452
+ * an SSM `SecureString` of the same NAME in two regions is two independent
24453
+ * values. The plaintext is then recorded into the CONSUMER's
24454
+ * `recordedSecretValues`, so the consumer's `state.json` (and from there the
24455
+ * rollback journal) persists the PRODUCER's spelling of the expression. That is
24456
+ * the right thing to persist, and it is region-less: the reader cannot tell
24457
+ * from the string which region produced it.
24458
+ *
24459
+ * The replay rebuilds its resolver from the CONSUMER's region alone, so
24460
+ * re-resolving that expression locally answers from a same-named secret in the
24461
+ * wrong region and writes it to a LIVE resource. Silent, and on the recovery
24462
+ * path. The rule applied here is the family's, from issue #1957: A NAMED REGION
24463
+ * BINDS; NEVER SUBSTITUTE A GUESS. The three verdicts are that one sentence:
24464
+ *
24465
+ * - **`named-region`** — the expression's SECRET_ID is an ARN, which names its
24466
+ * own region. The region is ESTABLISHED, so it binds: the caller resolves
24467
+ * through a resolver pinned to it ({@link ReplayResolvers.forRegion}) rather
24468
+ * than refusing. Refusing here would be the guess in the other direction.
24469
+ *
24470
+ * cdkd would otherwise get this wrong, which is why the arm exists at all:
24471
+ * `resolveSecretsManagerReference` builds its client from
24472
+ * `this.explicitRegion` and passes the ARN through as an opaque `SecretId`,
24473
+ * and `@aws-sdk/client-secrets-manager`'s endpoint ruleset has NO
24474
+ * ARN-derived endpoint rule (unlike, say, S3 access points), so a
24475
+ * foreign-region ARN is sent to the stack's own regional endpoint. What the
24476
+ * SERVICE then does with it is not something this repo can settle offline —
24477
+ * see the fixture note in
24478
+ * `tests/integration/rollback-cross-region-secret/README.md`. Pinning the
24479
+ * client to the ARN's region is correct either way: if Secrets Manager would
24480
+ * have refused the foreign ARN, this turns a hard failure into a correct
24481
+ * resolution; if it would have honoured it, this reaches the same value by
24482
+ * the documented route. Neither outcome is a regression.
24483
+ *
24484
+ * - **`ambiguous`** — the expression names no region (the plain name form) AND
24485
+ * this stack has a foreign producer region on record
24486
+ * ({@link RollbackExecutorContext.importedProducerRegions}). Nothing on hand
24487
+ * can establish the origin, so the replay refuses instead of guessing.
24488
+ *
24489
+ * KNOWN OVER-REFUSAL, accepted deliberately, and WIDER THAN THE SSM CASE
24490
+ * ALONE — state both, because the second one is the common shape:
24491
+ *
24492
+ * (a) Any NAME-FORM `secretsmanager` reference in a stack that has ANY
24493
+ * foreign producer region on record is refused, even when that secret is
24494
+ * the stack's own purely-local one and has nothing to do with the
24495
+ * cross-region read. The evidence is per-STACK, not per-reference, so one
24496
+ * cross-region export plus one ordinary
24497
+ * `{{resolve:secretsmanager:mysecret:SecretString:pw}}` is enough — and CDK's
24498
+ * `secretValueFromJson` emits exactly that name form, so this is the shape
24499
+ * most people will meet. It also persists: with the union the producer
24500
+ * region stays on record until the next SUCCESSFUL deploy. Per-reference
24501
+ * evidence is what would narrow it, and that needs the region recorded
24502
+ * ALONGSIDE the expression — the persisted-shape change issue #2057
24503
+ * deliberately deferred (its options 1 and 2). Until then the refusal is
24504
+ * loud, names the ARN spelling as the remedy, and is the fail-closed side
24505
+ * of a trade whose other side is a silent wrong-secret write.
24506
+ *
24507
+ * (b) An `ssm` reference is secret only when its parameter is a
24508
+ * `SecureString`, and this arm cannot tell. So a `{{resolve:ssm:/app/env}}`
24509
+ * naming a PUBLIC `String` that reached a persisted bag (issue #2036's
24510
+ * acknowledged over-redaction) is refused too. Narrowing it by
24511
+ * `isRecordedSecretExpression` was considered and REJECTED, and not because
24512
+ * the store is unreachable — it is imported by this very file. It is
24513
+ * unusable: `recordedSecretExpressions` is populated BY resolution, and in
24514
+ * the standalone `cdkd rollback` process nothing has resolved anything when
24515
+ * the first op is classified, so the store is empty and every `ssm` verdict
24516
+ * would come back "not secret" — turning the protection off for exactly the
24517
+ * SecureString case it exists for. Worse, once one op DID resolve a
24518
+ * reference the store would be warm for the next, so the verdict would
24519
+ * depend on OP ORDER. A resolve-the-type-first probe is unsound for the
24520
+ * same reason the whole issue exists: the TYPE is region-dependent (#1957),
24521
+ * so probing locally can report `String` for a name that is `SecureString`
24522
+ * in the producer's region and wave through the very write this refuses.
24523
+ * The residual is therefore a loud, actionable error on a narrow
24524
+ * intersection (an over-redacted public ssm reference AND a cross-region
24525
+ * read on record), which is the fail-closed side of the trade.
24526
+ *
24527
+ * - **`local`** — everything else, which is the overwhelmingly common case:
24528
+ * every non-secret service, every same-region ARN (the ordinary CDK
24529
+ * `secretValueFromJson` shape), and every name-form expression in a stack
24530
+ * with no foreign producer region recorded. Resolved exactly as before this
24531
+ * change.
24532
+ *
24533
+ * A same-region ARN answers `local` even when a foreign producer region IS on
24534
+ * record: the expression settles the question itself, so the weaker evidence
24535
+ * never gets consulted.
24536
+ */
24537
+ function classifyReplaySecretRegion(expression, consumerRegion, importedProducerRegions) {
24538
+ const inner = expression.startsWith("{{resolve:") ? expression.slice(10, -2) : void 0;
24539
+ if (inner === void 0) return { kind: "local" };
24540
+ const service = inner.split(":")[0];
24541
+ if (service === void 0 || !REPLAY_SECRET_SERVICES.has(service)) return { kind: "local" };
24542
+ const secretName = service === "secretsmanager" ? secretsManagerSecretId(inner) : ssmParameterName(inner);
24543
+ if (!secretName) return { kind: "local" };
24544
+ const named = arnRegion(secretName);
24545
+ if (named !== void 0) return canonicalizeRegion(named) === canonicalizeRegion(consumerRegion) ? { kind: "local" } : {
24546
+ kind: "named-region",
24547
+ secretName,
24548
+ region: named
24549
+ };
24550
+ const seen = /* @__PURE__ */ new Set();
24551
+ const foreignProducerRegions = [];
24552
+ for (const candidate of importedProducerRegions ?? []) {
24553
+ const canonical = canonicalizeRegion(candidate);
24554
+ if (!canonical || canonical === canonicalizeRegion(consumerRegion)) continue;
24555
+ if (seen.has(canonical)) continue;
24556
+ seen.add(canonical);
24557
+ foreignProducerRegions.push(candidate);
24558
+ }
24559
+ if (foreignProducerRegions.length === 0) return { kind: "local" };
24560
+ return {
24561
+ kind: "ambiguous",
24562
+ secretName,
24563
+ foreignProducerRegions
24564
+ };
24565
+ }
24566
+ /**
24567
+ * The replay's resolvers: the stack's own, plus one pinned sibling per FOREIGN
24568
+ * region an ARN-named reference asks for (issue #2057).
24569
+ *
24570
+ * One instance per replay, not per op — the resolved-value cache lives on the
24571
+ * resolver INSTANCE since issue #1933, so a resolver per op would re-fetch every
24572
+ * referenced secret once per op. The pinned siblings are cached here for the
24573
+ * same reason: a 100-op replay of a bag carrying one foreign ARN must pay one
24574
+ * `GetSecretValue`, not a hundred.
24575
+ *
24576
+ * A pinned sibling is a PLAIN resolver, deliberately NOT the resolver class's
24577
+ * own `producerRegionGuest` (which the class sets on the siblings
24578
+ * `resolverForProducerRegion` builds, to stop a foreign region pinning a verdict
24579
+ * in the process-global `recordedSecretExpressions` store — the issue #1933
24580
+ * shape, where an `ssm` parameter whose TYPE differs by region has one region's
24581
+ * verdict decide the other's redaction).
24582
+ *
24583
+ * WHY A GUEST FLAG IS NOT NEEDED HERE, and the argument has to be this one
24584
+ * rather than "only `secretsmanager` routes to a sibling" (that earlier claim
24585
+ * was FALSE — `resolveSSMReference` joins its colon-split tail back together, so
24586
+ * an `ssm` reference CAN name a full ARN and CAN therefore route here):
24587
+ *
24588
+ * {@link ReplayResolvers.forRegion} is reached ONLY from a `named-region`
24589
+ * verdict, which `classifyReplaySecretRegion` returns only when the
24590
+ * SECRET_ID / parameter name starts with `arn:` and carries a region. So a
24591
+ * pinned sibling only ever resolves an expression whose KEY EMBEDS THE
24592
+ * REGION IT IS BEING RESOLVED IN.
24593
+ *
24594
+ * The store is keyed by the expression string alone, and that is exactly what
24595
+ * makes #1933 possible: two regions sharing one key. An ARN-form key cannot be
24596
+ * shared by two regions, so a verdict pinned from a sibling can never contradict
24597
+ * another region's for the same key. If a future change ever routes a
24598
+ * region-LESS expression to `forRegion`, this argument dies with it and the
24599
+ * sibling needs the guest flag.
24600
+ */
24601
+ var ReplayResolvers = class {
24602
+ /** The stack's own resolver — every `local` verdict resolves through this. */
24603
+ primary;
24604
+ pinned = /* @__PURE__ */ new Map();
24605
+ stackRegion;
24606
+ constructor(stackRegion) {
24607
+ this.stackRegion = stackRegion;
24608
+ this.primary = new IntrinsicFunctionResolver(stackRegion);
24609
+ }
24610
+ /** The resolver that must answer for `region` — `primary` when it is the stack's own. */
24611
+ forRegion(region) {
24612
+ const target = canonicalizeRegion(region);
24613
+ if (target === canonicalizeRegion(this.stackRegion)) return this.primary;
24614
+ const cached = this.pinned.get(target);
24615
+ if (cached) return cached;
24616
+ const scoped = new IntrinsicFunctionResolver(target);
24617
+ this.pinned.set(target, scoped);
24618
+ return scoped;
24619
+ }
24620
+ };
24621
+ /**
24622
+ * The refusal an `ambiguous` replay reference throws (issue #2057).
24623
+ *
24624
+ * A plain throw, like the final-snapshot refusals above and for the same
24625
+ * reason: the per-op catch in {@link replaySingle} /
24626
+ * {@link replayFailedOperations} counts it as a failure, which keeps the
24627
+ * journal segment and lets the user re-run once the reference is disambiguated.
24628
+ * Refusing is strictly better than the alternative it replaces — resolving a
24629
+ * producer-region reference against the consumer's region does not fail, it
24630
+ * succeeds with the WRONG credential and writes it to a resource that is live.
24631
+ *
24632
+ * Names the reference, the regions, and the remedy. Never the resolved value:
24633
+ * nothing here has resolved anything yet, and the expression is the same string
24634
+ * `state.json` already stores in the clear.
24635
+ */
24636
+ function regionAmbiguousReplaySecretError(logicalId, propertyPath, secretName, foreignProducerRegions, consumerRegion) {
24637
+ return new CdkdError(`Rollback of ${logicalId}${propertyPath === "" ? "" : ` property '${propertyPath}'`} cannot re-resolve the secret reference '${secretName}': the reference carries no region of its own, and this stack read across a region boundary (producer region(s) on record: ${foreignProducerRegions.join(", ")}), so it may have been resolved in one of those rather than in '${consumerRegion}'. A secret of the same name in two regions is two independent values, so replaying this would write the WRONG secret to a live resource. Refusing instead. Resolve the reference in its own region and set the property directly (or spell it as a full ARN, which names its region and is resolved there), then re-run 'cdkd rollback'.`, "ROLLBACK_SECRET_REGION_AMBIGUOUS");
24638
+ }
24639
+ /**
24640
+ * Re-resolve one LEAF string, sending each `{{resolve:...}}` reference in it to
24641
+ * the region {@link classifyReplaySecretRegion} says must answer (issue #2057).
24642
+ *
24643
+ * Refuses FIRST, over the whole leaf, before any reference is fetched: a leaf
24644
+ * can splice several references together, and resolving the safe ones first
24645
+ * would leave half a credential fetched (and cached, and recorded as a
24646
+ * redaction needle) for an op that is about to be refused anyway.
24647
+ *
24648
+ * Then TWO paths, and the split is deliberate rather than an optimisation:
24649
+ *
24650
+ * - With no foreign-region reference — every leaf on every existing code path
24651
+ * — the leaf goes to `resolveDynamicReferences` WHOLE, exactly as before this
24652
+ * change. That method has its own well-tested substitution semantics (it
24653
+ * collects matches from the ORIGINAL string, so a resolved plaintext that is
24654
+ * itself token-shaped is never re-resolved — issue #1917), and this change
24655
+ * does not want to relitigate any of it.
24656
+ * - With one, the leaf is rebuilt segment by segment so each reference can be
24657
+ * resolved by its OWN region's resolver. `resolveDynamicReferences` resolves
24658
+ * every token in the string it is handed with the one resolver it is called
24659
+ * on, so a mixed leaf cannot be served by a single call. Each token is
24660
+ * resolved ALONE and its result concatenated, which means no resolved value
24661
+ * is ever re-scanned for tokens either.
24662
+ *
24663
+ * `dynamicReferenceTokens` returns the tokens in order and non-overlapping, so
24664
+ * walking the leaf with a moving `indexOf` cursor reproduces their positions
24665
+ * exactly, duplicates included.
24666
+ */
24667
+ async function resolveLeafByRegion(leaf, propertyPath, logicalId, execCtx, resolvers, resolverContext) {
24668
+ const verdicts = dynamicReferenceTokens(leaf).map((token) => [token, classifyReplaySecretRegion(token, execCtx.region, execCtx.importedProducerRegions)]);
24669
+ for (const [, verdict] of verdicts) if (verdict.kind === "ambiguous") throw regionAmbiguousReplaySecretError(logicalId, propertyPath, verdict.secretName, verdict.foreignProducerRegions, execCtx.region);
24670
+ if (!verdicts.some(([, verdict]) => verdict.kind === "named-region")) return await resolvers.primary.resolveDynamicReferences(leaf, resolverContext);
24671
+ let out = "";
24672
+ let cursor = 0;
24673
+ for (const [token, verdict] of verdicts) {
24674
+ const at = leaf.indexOf(token, cursor);
24675
+ if (at < 0) throw new CdkdError(`Rollback of ${logicalId}${propertyPath === "" ? "" : ` property '${propertyPath}'`} could not locate a scanned dynamic reference in the value it was scanned from. Refusing rather than resolving it in '${execCtx.region}', which would be the wrong region for a reference that names another one. This is an internal invariant failure — please report it with the resource type and property path.`, "ROLLBACK_SECRET_TOKEN_SCAN_MISMATCH");
24676
+ out += leaf.slice(cursor, at);
24677
+ const resolver = verdict.kind === "named-region" ? resolvers.forRegion(verdict.region) : resolvers.primary;
24678
+ out += await resolver.resolveDynamicReferences(token, resolverContext);
24679
+ cursor = at + token.length;
24680
+ }
24681
+ return out + leaf.slice(cursor);
24222
24682
  }
24223
24683
  /**
24224
24684
  * Redact resolved secret plaintext back out of a post-rollback state record
@@ -24542,7 +25002,7 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
24542
25002
  case "reverse-replacement": {
24543
25003
  const current = stateResources[op.logicalId];
24544
25004
  const prev = op.previousState;
24545
- const resolvedPrevProps = await resolveReplayProps(prev.properties, resolver, secrets) ?? {};
25005
+ const resolvedPrevProps = await resolveReplayProps(prev.properties, resolver, secrets, ctx, op.logicalId) ?? {};
24546
25006
  logger.info(` Rollback: Reversing replacement of ${op.logicalId} (${op.resourceType}) — re-creating the old resource and deleting the new one`);
24547
25007
  if (STATEFUL_TYPES.has(op.resourceType)) logger.warn(` ⚠ ${op.logicalId} (${op.resourceType}) is a stateful type — the old physical resource's data was destroyed by the replacement and CANNOT be recovered; the re-created resource starts empty.`);
24548
25008
  const { provider: createProvider } = ctx.providerRegistry.getProviderFor({
@@ -24634,8 +25094,8 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
24634
25094
  resourceType: op.resourceType,
24635
25095
  provisionedBy: op.provisionedBy
24636
25096
  });
24637
- const desiredProps = await resolveReplayProps(previousState.properties, resolver, secrets);
24638
- const currentProps = await resolveReplayProps(current.properties, resolver, secrets);
25097
+ const desiredProps = await resolveReplayProps(previousState.properties, resolver, secrets, ctx, op.logicalId);
25098
+ const currentProps = await resolveReplayProps(current.properties, resolver, secrets, ctx, op.logicalId);
24639
25099
  const revertResult = await updateWithRollbackRetry(provider, [
24640
25100
  op.logicalId,
24641
25101
  current.physicalId,
@@ -24695,7 +25155,7 @@ async function replayFailedOperations(failedOps, stateResources, stackName, ctx,
24695
25155
  remainingFailedOps: []
24696
25156
  };
24697
25157
  const { logger } = ctx;
24698
- const resolver = new IntrinsicFunctionResolver(ctx.region);
25158
+ const resolver = new ReplayResolvers(ctx.region);
24699
25159
  const emitEnvelope = options.emitEnvelope === true && failedOps.length > 0;
24700
25160
  if (emitEnvelope) ctx.recordEvent?.({
24701
25161
  eventType: "ROLLBACK_STARTED",
@@ -24786,8 +25246,8 @@ async function replayFailedOperations(failedOps, stateResources, stackName, ctx,
24786
25246
  resourceType: op.resourceType,
24787
25247
  provisionedBy: op.provisionedBy ?? current.provisionedBy
24788
25248
  });
24789
- const desiredProps = await resolveReplayProps(prev.properties, resolver, secrets);
24790
- const attemptedProps = await resolveReplayProps(op.attemptedProperties ?? current.properties, resolver, secrets);
25249
+ const desiredProps = await resolveReplayProps(prev.properties, resolver, secrets, ctx, op.logicalId);
25250
+ const attemptedProps = await resolveReplayProps(op.attemptedProperties ?? current.properties, resolver, secrets, ctx, op.logicalId);
24791
25251
  const revertFailedResult = await updateWithRollbackRetry(provider, [
24792
25252
  op.logicalId,
24793
25253
  current.physicalId,
@@ -24925,7 +25385,7 @@ const FLUSH_INTERVAL_MS = 2e3;
24925
25385
  const FLUSH_EVENT_THRESHOLD = 50;
24926
25386
  /** Build-time cdkd version, with a dev fallback for non-built contexts. */
24927
25387
  function getCdkdVersion() {
24928
- return "0.284.21";
25388
+ return "0.284.23";
24929
25389
  }
24930
25390
  /**
24931
25391
  * Generate a time-sortable unique run id, e.g.
@@ -25475,6 +25935,92 @@ function deepEqualValue(a, b) {
25475
25935
  }
25476
25936
  return true;
25477
25937
  }
25938
+ /**
25939
+ * The `imports` / `outputReads` records to persist on a save that is NOT the
25940
+ * final success save — the UNION of the pre-deploy snapshot and what THIS
25941
+ * session resolved (issue
25942
+ * [#2057](https://github.com/go-to-k/cdkd/issues/2057) review).
25943
+ *
25944
+ * Every non-success save used to write `currentState.imports` /
25945
+ * `currentState.outputReads` verbatim, i.e. the PRE-DEPLOY snapshot, while
25946
+ * writing the POST-deploy `newResources` beside it. So a deploy that
25947
+ * introduced a cross-stack read and then failed persisted resources built FROM
25948
+ * that read next to a record that does not mention it. Two consequences, and
25949
+ * only the first is about #2057:
25950
+ *
25951
+ * 1. A rollback journal exists only after a FAILED deploy, so
25952
+ * {@link producerRegionsFromState} saw an empty list on exactly the deploy
25953
+ * that introduces a cross-region secret read — and
25954
+ * `classifyReplaySecretRegion` answered `local`, resolving the producer's
25955
+ * region-less expression in the consumer's region. The refusal was inert
25956
+ * where it mattered most.
25957
+ * 2. INDEPENDENT PRE-EXISTING BUG. `state.imports[]` is what
25958
+ * `findActiveImportConsumers` (`src/cli/commands/destroy-runner.ts`) scans
25959
+ * to refuse destroying a producer while a consumer still imports from it,
25960
+ * and `state.outputReads[]` is what `findDownstreamConsumers`
25961
+ * (`src/cli/commands/recreate-downstream-consumers.ts`) enumerates. A
25962
+ * failed deploy therefore silently DOWNGRADED a fresh strong reference to
25963
+ * no reference: the consumer's resource is live and recorded, its import is
25964
+ * not, and `cdkd destroy` on the producer sails through the strong-ref
25965
+ * pre-flight. This exists on main today, with or without #2057.
25966
+ *
25967
+ * DIRECTION OF THE RESIDUAL, stated rather than left to be discovered: a union
25968
+ * never drops a record, so a stack that STOPS reading across a region keeps the
25969
+ * stale entry until its next SUCCESSFUL deploy, whose save replaces the list
25970
+ * wholesale (`imports: [...this.recordedImports]`). Until then a purely-local
25971
+ * rollback can be refused on the strength of a read the template no longer has.
25972
+ * That is the fail-closed side — a clear error naming the region to reconcile,
25973
+ * versus a silent wrong-secret write — and the same asymmetry already justifies
25974
+ * preserving the snapshot at all (dropping it would strip a live strong-ref
25975
+ * record on every diff-clean deploy).
25976
+ *
25977
+ * THE RULE IS "EVERY SAVE EXCEPT THE TERMINAL SUCCESS ONE", and it is stated
25978
+ * that way rather than as "every non-success save" because the latter is loose
25979
+ * in both directions: the diff-clean no-change save in `doDeploy` is a SUCCESS
25980
+ * outcome and unions anyway (nothing was re-resolved, so the union is an
25981
+ * identity there and one rule beats an exception), while
25982
+ * `persistStateAfterOutputFailure` looks like a success save — provisioning
25983
+ * was clean — and is not one.
25984
+ *
25985
+ * THE ENUMERATION IS NOT KEPT HERE, DELIBERATELY. Two prose counts in this
25986
+ * lane were measured wrong (an "ALL FIVE" that missed
25987
+ * `persistStateAfterOutputFailure`, and a "three post-rollback saves" that is
25988
+ * two), and each wrong count is worse than none: it is the sentence a reader
25989
+ * uses to conclude the rule is already applied everywhere.
25990
+ * `tests/unit/deployment/deploy-engine-cross-stack-read-writers.test.ts`
25991
+ * derives the set instead — it SCANS this file for every `imports:` /
25992
+ * `outputReads:` object key that writes a VALUE and fails on any that is not
25993
+ * the one allow-listed success-path write, with a positive control proving the
25994
+ * scan can see a violation. A save site added here fails that test rather than
25995
+ * escaping silently, so the authority on "where is this applied" is a grep the
25996
+ * test performs, not a number anybody has to maintain.
25997
+ */
25998
+ function crossStackReadsForPartialSave(previous, recordedImports, recordedOutputReads) {
25999
+ const imports = unionCrossStackReads(previous.imports, recordedImports, (e) => `${e.sourceStack}\u0000${canonicalizeRegion(e.sourceRegion)}\u0000${e.exportName}`);
26000
+ const outputReads = unionCrossStackReads(previous.outputReads, recordedOutputReads, (e) => `${e.sourceStack}\u0000${canonicalizeRegion(e.sourceRegion)}\u0000${e.outputName}`);
26001
+ return {
26002
+ ...imports.length > 0 && { imports },
26003
+ ...outputReads.length > 0 && { outputReads }
26004
+ };
26005
+ }
26006
+ /**
26007
+ * Concatenate two cross-stack-read lists, dropping a later duplicate of an
26008
+ * identity an earlier entry already carries. First-seen wins, so the PRE-DEPLOY
26009
+ * spelling of a region survives — entries are COMPARED on a canonicalized
26010
+ * region but STORED verbatim, mirroring `producerRegionsFromState`.
26011
+ */
26012
+ function unionCrossStackReads(previous, recorded, identity) {
26013
+ const seen = /* @__PURE__ */ new Set();
26014
+ const out = [];
26015
+ for (const entry of [...previous ?? [], ...recorded]) {
26016
+ if (entry === null || typeof entry !== "object") continue;
26017
+ const key = identity(entry);
26018
+ if (seen.has(key)) continue;
26019
+ seen.add(key);
26020
+ out.push(entry);
26021
+ }
26022
+ return out;
26023
+ }
25478
26024
  var DeployEngine = class {
25479
26025
  logger = getLogger().child("DeployEngine");
25480
26026
  resolver;
@@ -25670,7 +26216,7 @@ var DeployEngine = class {
25670
26216
  */
25671
26217
  redactOutputs(outputs) {
25672
26218
  if (this.outputSecrets.size === 0) return outputs;
25673
- return redactSecretsForState(outputs, this.outputSecrets, this.outputsSourceUsable ? this.outputsTemplateSource : void 0);
26219
+ return redactSecretsForState(outputs, this.outputSecrets, this.outputsSourceUsable ? this.outputsTemplateSource : void 0, TEMPLATE_SOURCED_RULES);
25674
26220
  }
25675
26221
  /**
25676
26222
  * Redact resolved secret plaintext out of rollback-journal operations (GHSA
@@ -26020,8 +26566,7 @@ var DeployEngine = class {
26020
26566
  stackName: currentState.stackName,
26021
26567
  resources: currentState.resources,
26022
26568
  outputs: outputsChanged ? resolvedOutputs : persistedOutputs,
26023
- ...currentState.imports && currentState.imports.length > 0 && { imports: currentState.imports },
26024
- ...currentState.outputReads && currentState.outputReads.length > 0 && { outputReads: currentState.outputReads },
26569
+ ...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
26025
26570
  lastModified: Date.now()
26026
26571
  };
26027
26572
  const saveOptions = {};
@@ -26139,8 +26684,7 @@ var DeployEngine = class {
26139
26684
  stackName: currentState.stackName,
26140
26685
  resources: newResources,
26141
26686
  outputs: currentState.outputs,
26142
- ...currentState.imports && currentState.imports.length > 0 && { imports: currentState.imports },
26143
- ...currentState.outputReads && currentState.outputReads.length > 0 && { outputReads: currentState.outputReads },
26687
+ ...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
26144
26688
  lastModified: Date.now()
26145
26689
  };
26146
26690
  const migrate = pendingMigration;
@@ -26270,8 +26814,7 @@ var DeployEngine = class {
26270
26814
  stackName: currentState.stackName,
26271
26815
  resources: newResources,
26272
26816
  outputs: currentState.outputs,
26273
- ...currentState.imports && currentState.imports.length > 0 && { imports: currentState.imports },
26274
- ...currentState.outputReads && currentState.outputReads.length > 0 && { outputReads: currentState.outputReads },
26817
+ ...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
26275
26818
  lastModified: Date.now()
26276
26819
  };
26277
26820
  const migrate = pendingMigration;
@@ -26297,7 +26840,7 @@ var DeployEngine = class {
26297
26840
  this.logger.warn("Partial state has been saved. Run 'cdkd deploy' to resume, 'cdkd rollback' to revert, or destroy to clean up.");
26298
26841
  } else {
26299
26842
  await this.writeRollbackJournalSegment(stackName, completedOperations, failedOperations, "auto-rollback-started", initialDeploy);
26300
- autoRollbackClean = (await this.performRollback(completedOperations, newResources, stackName)).failures === 0;
26843
+ autoRollbackClean = (await this.performRollback(completedOperations, newResources, stackName, currentState)).failures === 0;
26301
26844
  }
26302
26845
  try {
26303
26846
  const postRollbackState = {
@@ -26306,8 +26849,7 @@ var DeployEngine = class {
26306
26849
  stackName: currentState.stackName,
26307
26850
  resources: newResources,
26308
26851
  outputs: currentState.outputs,
26309
- ...currentState.imports && currentState.imports.length > 0 && { imports: currentState.imports },
26310
- ...currentState.outputReads && currentState.outputReads.length > 0 && { outputReads: currentState.outputReads },
26852
+ ...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
26311
26853
  lastModified: Date.now()
26312
26854
  };
26313
26855
  await this.stateBackend.saveState(stackName, this.stackRegion, this.withParentInfo(postRollbackState), { ...currentEtag !== void 0 && { expectedEtag: currentEtag } });
@@ -26323,8 +26865,7 @@ var DeployEngine = class {
26323
26865
  stackName: currentState.stackName,
26324
26866
  resources: newResources,
26325
26867
  outputs: currentState.outputs,
26326
- ...currentState.imports && currentState.imports.length > 0 && { imports: currentState.imports },
26327
- ...currentState.outputReads && currentState.outputReads.length > 0 && { outputReads: currentState.outputReads },
26868
+ ...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
26328
26869
  lastModified: Date.now()
26329
26870
  };
26330
26871
  await this.stateBackend.saveState(stackName, this.stackRegion, this.withParentInfo(postRollbackState), { ...freshEtag !== void 0 && { expectedEtag: freshEtag } });
@@ -26389,8 +26930,7 @@ var DeployEngine = class {
26389
26930
  stackName: currentState.stackName,
26390
26931
  resources: newResources,
26391
26932
  outputs: currentState.outputs,
26392
- ...this.recordedImports.length > 0 && { imports: [...this.recordedImports] },
26393
- ...this.recordedOutputReads.length > 0 && { outputReads: [...this.recordedOutputReads] },
26933
+ ...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
26394
26934
  lastModified: Date.now()
26395
26935
  });
26396
26936
  try {
@@ -26417,8 +26957,8 @@ var DeployEngine = class {
26417
26957
  * command drives identical semantics). Thin wrapper that builds the
26418
26958
  * executor context from the engine's collaborators and delegates.
26419
26959
  */
26420
- async performRollback(completedOperations, stateResources, stackName) {
26421
- const result = await replayRollback(completedOperations, stateResources, stackName, this.rollbackExecutorContext());
26960
+ async performRollback(completedOperations, stateResources, stackName, previousState) {
26961
+ const result = await replayRollback(completedOperations, stateResources, stackName, this.rollbackExecutorContext(previousState));
26422
26962
  return {
26423
26963
  failures: result.failures,
26424
26964
  warnings: result.warnings
@@ -26477,14 +27017,15 @@ var DeployEngine = class {
26477
27017
  this.logger.info(`The automatic rollback restored the pre-deploy state. The failed resource's pre-failure record was kept — if it was left partially applied, run 'cdkd rollback ${stackName} --revert-failed' to revert it.`);
26478
27018
  }
26479
27019
  /** Build the {@link RollbackExecutorContext} from the engine's fields. */
26480
- rollbackExecutorContext() {
27020
+ rollbackExecutorContext(previousState) {
26481
27021
  return {
26482
27022
  providerRegistry: this.providerRegistry,
26483
27023
  region: this.stackRegion,
26484
27024
  logger: this.logger,
26485
27025
  recordEvent: (event) => this.recordEvent(event),
26486
27026
  finalSnapshotClients: this.options.finalSnapshotClients,
26487
- skipFinalSnapshot: this.options.skipFinalSnapshot
27027
+ skipFinalSnapshot: this.options.skipFinalSnapshot,
27028
+ importedProducerRegions: producerRegionsFromState(crossStackReadsForPartialSave(previousState, this.recordedImports, this.recordedOutputReads))
26488
27029
  };
26489
27030
  }
26490
27031
  /**
@@ -27464,5 +28005,5 @@ var DeployEngine = class {
27464
28005
  };
27465
28006
 
27466
28007
  //#endregion
27467
- export { isTerminationProtectionPropagationError as $, PartialFailureError as $n, getBootstrapMarkerKey as $t, renderStatefulReason as A, expectedOwnerParam as An, applyRoleArnIfSet as At, exportAliasCollisionScrubWarning as B, resetAwsClients as Bn, shouldRetainResource as Bt, isFinalSnapshotError as C, stateBucketExistenceConfirmed as Cn, redactSecretsForState as Ct, extractDeploymentEventError as D, MIGRATE_TMP_PREFIX as Dn, s3BucketDualStackDomainName as Dt, makeCanonicalizePropertiesFn as E, CFN_TEMPLATE_URL_LIMIT as En, s3BucketDomainName as Et, green as F, processStackMessages as Fn, DagBuilder as Ft, collectInlinePolicyNamesManagedBySiblings as G, DependencyError as Gn, createAssetRedirectResolver as Gt, secretBearingStateKeyWarning as H, AssetError as Hn, stringifyValue as Ht, red as I, clearBucketRegionCache as In, TemplateParser as It, findActionableSilentDrops as J, LocalMigrateError as Jn, escapeRegExp$1 as Jt, clearOnUpdateRemoval as K, DeployCancelledError as Kn, loadPublishableAssetManifest as Kt, yellow as L, resolveBucketRegion as Ln, LockManager as Lt, bold as M, canonicalizeRegion as Mn, INTRINSIC_KEYS as Mt, cyan as N, derivePartitionAndUrlSuffix as Nn, describeTypeWithThrottleRetry as Nt, MULTI_REGION_RECREATE_BLOCKED_TYPES as O, findLargeInlineResources as On, s3BucketRegionalDomainName as Ot, gray as P, AssemblyReader as Pn, withRetry as Pt, disableInstanceApiTermination as Q, NestedStackChildDirectDestroyError as Qn, ensureAssetStorage as Qt, collectDeclaredOutputNames as R, AwsClients as Rn, S3StateBackend as Rt, createPreDeleteFinalSnapshot as S, resolveUseCdkBootstrapAssets as Sn, maskSecretsInText as St, unsupportedFinalSnapshotError as T, CFN_TEMPLATE_BODY_LIMIT as Tn, s3BucketArn as Tt, stateKeySecretExposure as U, CdkdError as Un, WorkGraph as Ut, isExportAliasCollision as V, setAwsClients as Vn, AssetPublisher as Vt, IAMRoleProvider as W, ConfigError as Wn, buildAssetRedirectMap as Wt, CloudControlProvider as X, LockError as Xn, AssetModeResolver as Xt, findSilentDropProperties as Y, LocalStartServiceError as Yn, stripControlChars as Yt, slowCcOperationTimeoutMs as Z, MissingCdkCliError as Zn, BOOTSTRAP_MARKER_PREFIX as Zt, computeImplicitDeleteEdges as _, resolveAutoAssetStorage as _n, STATE_SOURCED_READBACK_RULES as _t, DeploymentEventsStore as a, buildDockerImage as an, StateError as ar, normalizeAwsTagsToCfn as at, buildFinalSnapshotIdentifier as b, resolveStateBucketWithDefault as bn, dynamicReferenceTokens as bt, replayFailedOperations as c, runDockerForeground as cn, isCdkdError as cr, coerceCfnBoolean as ct, updatePartialReason as d, getDockerImageBySourceHash as dn, isMarkedNonRetryable as dr, readConfigString as dt, parseBootstrapMarker as en, ProvisioningError as er, IntrinsicFunctionResolver as et, UNSPECIFIED_SKIP_REASON as f, Synthesizer as fn, isRetryableTransientError as fr, replayWarn as ft, IMPLICIT_DELETE_DEPENDENCIES as g, resolveApp as gn, STATE_SOURCED_CROSS_GENERATION_RULES as gt, maskingRetryLogger as h, getLegacyStateBucketName as hn, __exportAll as hr, requireConfigString as ht, DeploymentEventsReader as i, buildDenyExternalAccessPolicy as in, StackTerminationProtectionError as ir, WAFv2WebACLProvider as it, formatResourceLine as j, PARTITION_TABLE as jn, DiffCalculator as jt, isStatefulRecreateTargetSync as k, uploadCfnTemplate as kn, s3BucketWebsiteUrl as kt, replayRollback as l, runDockerStreaming as ln, normalizeAwsError as lr, configBooleanRefusal as lt, withResourceDeadline as m, getDefaultStateBucketName as mn, markNonRetryable as mr, requireConfigObject as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, validateAssetBucketName as nn, ResourceUpdateNotSupportedError as nr, getAccountInfo as nt, planFailedOps as o, formatDockerLoginError as on, SynthesisError as or, resolveExplicitPhysicalId as ot, deleteSkipReason as p, synthesisStatusMessage as pn, isThrottlingError as pr, requireConfigArray as pt, ProviderRegistry as q, LocalInvokeBuildError as qn, rewriteTemplateAssetReferences as qt, DeployEngine as r, validateContainerRepoName as rn, StackHasActiveImportsError as rr, refStateLookupFromResource as rt, planRollback as s, getDockerCmd as sn, formatError as sr, assertRegionMatch as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, readBootstrapMarkerBody as tn, ResourceTimeoutError as tr, cfnRefValueFromPhysicalId as tt, updatePartialMessage as u, AssetManifestLoader as un, withErrorHandling as ur, configStringRefusal as ut, ATOMIC_FINAL_SNAPSHOT_TYPES as v, resolveCaptureObservedState as vn, TEMPLATE_SOURCED_RULES as vt, refusesFinalSnapshot as w, warnDeprecatedNoPrefixCliFlag as wn, scrubResourceRecord as wt, ccRoutedFinalSnapshotError as x, resolveStateBucketWithDefaultAndSource as xn, isSingleDynamicReferenceToken as xt, PRE_DELETE_SNAPSHOT_TYPES as y, resolveSkipPrefix as yn, createSecretMasker as yt, collectPublishedOutputNames as z, getAwsClients as zn, rebuildClientForBucketRegion as zt };
27468
- //# sourceMappingURL=deploy-engine--N_xnvjI.js.map
28008
+ export { disableInstanceApiTermination as $, NestedStackChildDirectDestroyError as $n, ensureAssetStorage as $t, isStatefulRecreateTargetSync as A, uploadCfnTemplate as An, s3BucketWebsiteUrl as At, collectPublishedOutputNames as B, getAwsClients as Bn, rebuildClientForBucketRegion as Bt, createPreDeleteFinalSnapshot as C, resolveUseCdkBootstrapAssets as Cn, maskSecretsInText as Ct, makeCanonicalizePropertiesFn as D, CFN_TEMPLATE_URL_LIMIT as Dn, s3BucketDomainName as Dt, unsupportedFinalSnapshotError as E, CFN_TEMPLATE_BODY_LIMIT as En, s3BucketArn as Et, gray as F, AssemblyReader as Fn, withRetry as Ft, IAMRoleProvider as G, ConfigError as Gn, buildAssetRedirectMap as Gt, isExportAliasCollision as H, setAwsClients as Hn, AssetPublisher as Ht, green as I, processStackMessages as In, DagBuilder as It, ProviderRegistry as J, LocalInvokeBuildError as Jn, rewriteTemplateAssetReferences as Jt, collectInlinePolicyNamesManagedBySiblings as K, DependencyError as Kn, createAssetRedirectResolver as Kt, red as L, clearBucketRegionCache as Ln, TemplateParser as Lt, formatResourceLine as M, PARTITION_TABLE as Mn, DiffCalculator as Mt, bold as N, canonicalizeRegion as Nn, INTRINSIC_KEYS as Nt, extractDeploymentEventError as O, MIGRATE_TMP_PREFIX as On, s3BucketDualStackDomainName as Ot, cyan as P, derivePartitionAndUrlSuffix as Pn, describeTypeWithThrottleRetry as Pt, slowCcOperationTimeoutMs as Q, MissingCdkCliError as Qn, BOOTSTRAP_MARKER_PREFIX as Qt, yellow as R, resolveBucketRegion as Rn, LockManager as Rt, ccRoutedFinalSnapshotError as S, resolveStateBucketWithDefaultAndSource as Sn, isSingleDynamicReferenceToken as St, refusesFinalSnapshot as T, warnDeprecatedNoPrefixCliFlag as Tn, scrubResourceRecord as Tt, secretBearingStateKeyWarning as U, AssetError as Un, stringifyValue as Ut, exportAliasCollisionScrubWarning as V, resetAwsClients as Vn, shouldRetainResource as Vt, stateKeySecretExposure as W, CdkdError as Wn, WorkGraph as Wt, findSilentDropProperties as X, LocalStartServiceError as Xn, stripControlChars as Xt, findActionableSilentDrops as Y, LocalMigrateError as Yn, escapeRegExp$1 as Yt, CloudControlProvider as Z, LockError as Zn, AssetModeResolver as Zt, IMPLICIT_DELETE_DEPENDENCIES as _, resolveApp as _n, STATE_SOURCED_CROSS_GENERATION_RULES as _t, DeploymentEventsStore as a, buildDenyExternalAccessPolicy as an, StackTerminationProtectionError as ar, WAFv2WebACLProvider as at, PRE_DELETE_SNAPSHOT_TYPES as b, resolveSkipPrefix as bn, createSecretMasker as bt, producerRegionsFromState as c, getDockerCmd as cn, formatError as cr, assertRegionMatch as ct, updatePartialMessage as d, AssetManifestLoader as dn, withErrorHandling as dr, configStringRefusal as dt, getBootstrapMarkerKey as en, PartialFailureError as er, isTerminationProtectionPropagationError as et, updatePartialReason as f, getDockerImageBySourceHash as fn, isMarkedNonRetryable as fr, readConfigString as ft, maskingRetryLogger as g, getLegacyStateBucketName as gn, __exportAll as gr, requireConfigString as gt, withResourceDeadline as h, getDefaultStateBucketName as hn, markNonRetryable as hr, requireConfigObject as ht, DeploymentEventsReader as i, validateContainerRepoName as in, StackHasActiveImportsError as ir, refStateLookupFromResource as it, renderStatefulReason as j, expectedOwnerParam as jn, applyRoleArnIfSet as jt, MULTI_REGION_RECREATE_BLOCKED_TYPES as k, findLargeInlineResources as kn, s3BucketRegionalDomainName as kt, replayFailedOperations as l, runDockerForeground as ln, isCdkdError as lr, coerceCfnBoolean as lt, deleteSkipReason as m, synthesisStatusMessage as mn, isThrottlingError as mr, requireConfigArray as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, readBootstrapMarkerBody as nn, ResourceTimeoutError as nr, cfnRefValueFromPhysicalId as nt, planFailedOps as o, buildDockerImage as on, StateError as or, normalizeAwsTagsToCfn as ot, UNSPECIFIED_SKIP_REASON as p, Synthesizer as pn, isRetryableTransientError as pr, replayWarn as pt, clearOnUpdateRemoval as q, DeployCancelledError as qn, loadPublishableAssetManifest as qt, DeployEngine as r, validateAssetBucketName as rn, ResourceUpdateNotSupportedError as rr, getAccountInfo as rt, planRollback as s, formatDockerLoginError as sn, SynthesisError as sr, resolveExplicitPhysicalId as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, parseBootstrapMarker as tn, ProvisioningError as tr, IntrinsicFunctionResolver as tt, replayRollback as u, runDockerStreaming as un, normalizeAwsError as ur, configBooleanRefusal as ut, computeImplicitDeleteEdges as v, resolveAutoAssetStorage as vn, STATE_SOURCED_READBACK_RULES as vt, isFinalSnapshotError as w, stateBucketExistenceConfirmed as wn, redactSecretsForState as wt, buildFinalSnapshotIdentifier as x, resolveStateBucketWithDefault as xn, dynamicReferenceTokens as xt, ATOMIC_FINAL_SNAPSHOT_TYPES as y, resolveCaptureObservedState as yn, TEMPLATE_SOURCED_RULES as yt, collectDeclaredOutputNames as z, AwsClients as zn, S3StateBackend as zt };
28009
+ //# sourceMappingURL=deploy-engine-HfFU96oJ.js.map