@venturekit/infra 0.0.0-dev.20260514025219 → 0.0.0-dev.20260515022321

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.
Files changed (49) hide show
  1. package/dist/cdk/app-stack.d.ts +330 -0
  2. package/dist/cdk/app-stack.d.ts.map +1 -0
  3. package/dist/cdk/app-stack.js +875 -0
  4. package/dist/cdk/app-stack.js.map +1 -0
  5. package/dist/cdk/config-stack.d.ts +104 -0
  6. package/dist/cdk/config-stack.d.ts.map +1 -0
  7. package/dist/cdk/config-stack.js +161 -0
  8. package/dist/cdk/config-stack.js.map +1 -0
  9. package/dist/cdk/data-stack.d.ts +198 -0
  10. package/dist/cdk/data-stack.d.ts.map +1 -0
  11. package/dist/cdk/data-stack.js +511 -0
  12. package/dist/cdk/data-stack.js.map +1 -0
  13. package/dist/cdk/edge-stack.d.ts +91 -0
  14. package/dist/cdk/edge-stack.d.ts.map +1 -0
  15. package/dist/cdk/edge-stack.js +364 -0
  16. package/dist/cdk/edge-stack.js.map +1 -0
  17. package/dist/cdk/identity-stack.d.ts +98 -0
  18. package/dist/cdk/identity-stack.d.ts.map +1 -0
  19. package/dist/cdk/identity-stack.js +254 -0
  20. package/dist/cdk/identity-stack.js.map +1 -0
  21. package/dist/cdk/index.d.ts +16 -0
  22. package/dist/cdk/index.d.ts.map +1 -1
  23. package/dist/cdk/index.js +17 -0
  24. package/dist/cdk/index.js.map +1 -1
  25. package/dist/cdk/messaging-stack.d.ts +109 -0
  26. package/dist/cdk/messaging-stack.d.ts.map +1 -0
  27. package/dist/cdk/messaging-stack.js +320 -0
  28. package/dist/cdk/messaging-stack.js.map +1 -0
  29. package/dist/cdk/network-stack.d.ts +87 -0
  30. package/dist/cdk/network-stack.d.ts.map +1 -0
  31. package/dist/cdk/network-stack.js +265 -0
  32. package/dist/cdk/network-stack.js.map +1 -0
  33. package/dist/cdk/shared/cross-stack-refs.d.ts +278 -0
  34. package/dist/cdk/shared/cross-stack-refs.d.ts.map +1 -0
  35. package/dist/cdk/shared/cross-stack-refs.js +326 -0
  36. package/dist/cdk/shared/cross-stack-refs.js.map +1 -0
  37. package/dist/cdk/shared/lambda-helpers.d.ts +82 -0
  38. package/dist/cdk/shared/lambda-helpers.d.ts.map +1 -0
  39. package/dist/cdk/shared/lambda-helpers.js +201 -0
  40. package/dist/cdk/shared/lambda-helpers.js.map +1 -0
  41. package/dist/cdk/shared/ssm-keys.d.ts +460 -0
  42. package/dist/cdk/shared/ssm-keys.d.ts.map +1 -0
  43. package/dist/cdk/shared/ssm-keys.js +291 -0
  44. package/dist/cdk/shared/ssm-keys.js.map +1 -0
  45. package/dist/cdk/stack.d.ts +3 -11
  46. package/dist/cdk/stack.d.ts.map +1 -1
  47. package/dist/cdk/stack.js +184 -41
  48. package/dist/cdk/stack.js.map +1 -1
  49. package/package.json +2 -2
package/dist/cdk/stack.js CHANGED
@@ -131,26 +131,11 @@ function djb2Hash(input) {
131
131
  }
132
132
  return h >>> 0;
133
133
  }
134
- /**
135
- * Smallest power of two that is `>= n`. Used to pick the nested-stack
136
- * bucket count, so that growing past a threshold doubles the count
137
- * rather than incrementing it — the doubled-size hash bucketing only
138
- * relocates ~half the routes, and growth is geometric (rare events)
139
- * rather than additive (every deploy after a route added).
140
- */
141
- export function nextPowerOfTwo(n) {
142
- if (n <= 1)
143
- return 1;
144
- let p = 1;
145
- while (p < n)
146
- p <<= 1;
147
- return p;
148
- }
149
134
  /**
150
135
  * Map a route slug to a nested-stack slot index in `[0, numSlots)`.
151
- * `numSlots` MUST be a power of two so the modulo distributes the
152
- * hash output evenly. Exported for unit tests asserting bucketing
153
- * stability across route-set changes.
136
+ * `numSlots` MUST be a power of two so the bitwise-AND modulo
137
+ * distributes the hash output evenly. Exported for unit tests
138
+ * asserting bucketing stability across route-set changes.
154
139
  */
155
140
  export function stableSlotFor(slug, numSlots) {
156
141
  return djb2Hash(slug) & (numSlots - 1);
@@ -833,6 +818,19 @@ export class VentureStack extends cdk.Stack {
833
818
  },
834
819
  disableExecuteApiEndpoint: false,
835
820
  });
821
+ // Well-Architected: Reliability — RETAIN the HttpApi under strict
822
+ // dataSafety. The `*.execute-api.{region}.amazonaws.com` URL is unique
823
+ // to the API ID, so deleting the API invalidates every consumer that
824
+ // hardcoded that URL (mobile apps shipped with the URL baked in,
825
+ // partner integrations, webhook subscribers, etc.). Custom-domain
826
+ // mappings live on this same API ID — losing the API also breaks the
827
+ // mapping until manually re-attached. RETAIN means a deleted CFN
828
+ // stack leaves the API in place for `cdk import` recovery.
829
+ const cfnApi = api.node.defaultChild;
830
+ if (this.dataSafetyConfig.removalPolicy === 'retain') {
831
+ cfnApi.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
832
+ cfnApi.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
833
+ }
836
834
  // Well-Architected: Operational Excellence — access logging for API
837
835
  const apiLogGroup = new logs.LogGroup(this, 'ApiAccessLog', {
838
836
  logGroupName: `/venturekit/${projectName}/${stage}/api-access`,
@@ -1221,35 +1219,58 @@ export class VentureStack extends cdk.Stack {
1221
1219
  // automatically via SSM Parameter passthrough.
1222
1220
  const NESTED_STACK_THRESHOLD = 80;
1223
1221
  const ROUTES_PER_NESTED_STACK = 50;
1222
+ // Fixed, immutable nested-stack slot count.
1223
+ //
1224
+ // The bucketing function `stableSlotFor(slug, numSlots)` is a
1225
+ // pure function of these two inputs. As long as `numSlots`
1226
+ // never changes, every previously-deployed route keeps its
1227
+ // slot — which means CloudFormation never has to migrate a
1228
+ // route's `ApiGatewayV2::Route` resource across nested stack
1229
+ // boundaries between deploys.
1230
+ //
1231
+ // That invariant is load-bearing. `ApiGatewayV2::Route` carries
1232
+ // a unique `(method, path)` key at the API Gateway level, not
1233
+ // just the CloudFormation level. If the same route key lives in
1234
+ // two nested stacks (old + new) at the same moment, the create
1235
+ // call fails with `Route with key <METHOD> <path> already exists
1236
+ // for this API` because CFN updates nested stacks in parallel
1237
+ // and has no cross-stack delete-before-create ordering.
1238
+ //
1239
+ // 16 slots × ~80 routes/slot (the CloudFormation per-stack 500-
1240
+ // resource limit at ~5 CFN resources per route) gives ~1280
1241
+ // routes of headroom — comfortably above anything we've seen
1242
+ // in practice. Raising this number later would force a
1243
+ // one-shot mass migration of every nested-stack route in every
1244
+ // already-deployed project; don't do it casually.
1245
+ const NUM_NESTED_STACK_SLOTS = 16;
1224
1246
  if (discoveredRoutes.length <= NESTED_STACK_THRESHOLD) {
1225
1247
  for (const route of discoveredRoutes) {
1226
1248
  this.createRouteFunction(route, projectName, stage, api, lambdaRole, lambdaSg, vpc);
1227
1249
  }
1228
1250
  }
1229
1251
  else {
1230
- // Stable hash-based bucketing.
1252
+ // Stable hash-based bucketing into a fixed, never-changing slot
1253
+ // pool.
1231
1254
  //
1232
- // The previous index-based scheme (`routes.slice(i, i+50)`) sliced
1233
- // the discovered-route array into fixed 50-route windows. Adding,
1234
- // removing, or renaming any single route shifted every later route
1235
- // by one position, which moved a tail of routes across a 50-route
1236
- // boundary into a different `RoutesN` nested stack. Combined with
1237
- // explicit `FunctionName`s on each Lambda this caused
1238
- // `<name> already exists in stack <other-stack>` deploy failures
1239
- // — CFN updates nested stacks in parallel, so the new copy tries
1240
- // to come up before the old one is torn down.
1255
+ // The previous index-based scheme (`routes.slice(i, i+50)`)
1256
+ // sliced the discovered-route array into fixed 50-route windows.
1257
+ // Adding, removing, or renaming any single route shifted every
1258
+ // later route by one position, which moved a tail of routes
1259
+ // across a 50-route boundary into a different `RoutesN` nested
1260
+ // stack. Combined with the unique `(method, path)` key carried
1261
+ // by `ApiGatewayV2::Route`, this caused
1262
+ // `Route with key <METHOD> <path> already exists for this API`
1263
+ // deploy failures — CFN updates nested stacks in parallel, so
1264
+ // the new route resource tries to register at API Gateway
1265
+ // before the old one in the other nested stack is deleted.
1241
1266
  //
1242
- // Hashing the slug into a bucket pins each route to a slot that is
1243
- // independent of its siblings. Adding an unrelated route never
1244
- // moves an existing one. The bucket count is rounded up to the
1245
- // next power of two so that, in the rare case where the project
1246
- // grows enough to require more buckets, the doubling reshuffle is
1247
- // a one-shot event rather than a continuous churn — and
1248
- // because nested-stack route Lambdas are auto-named (see
1249
- // `RoutesNestedStack.createRouteFunction`), CloudFormation can
1250
- // migrate them across stacks without a name collision.
1251
- const targetSlots = Math.max(2, Math.ceil(discoveredRoutes.length / ROUTES_PER_NESTED_STACK));
1252
- const numSlots = nextPowerOfTwo(targetSlots);
1267
+ // Hashing the slug into a fixed slot pool pins each route to a
1268
+ // slot that's determined the first time it's deployed and
1269
+ // never moves. Adding or removing siblings doesn't reshuffle
1270
+ // anything; changing slot count is the only event that would —
1271
+ // which is exactly why `NUM_NESTED_STACK_SLOTS` is a hard-coded
1272
+ // constant rather than something derived from the route count.
1273
+ const numSlots = NUM_NESTED_STACK_SLOTS;
1253
1274
  const buckets = Array.from({ length: numSlots }, () => []);
1254
1275
  for (const route of discoveredRoutes) {
1255
1276
  const slot = stableSlotFor(route.slug, numSlots);
@@ -1626,6 +1647,17 @@ export class VentureStack extends cdk.Stack {
1626
1647
  enabled: true,
1627
1648
  ...aliasProps,
1628
1649
  });
1650
+ // Well-Architected: Reliability — RETAIN the distribution under
1651
+ // strict dataSafety. Distributions are slow to create (~15-30 min
1652
+ // for the global edge rollout), have a stable `*.cloudfront.net`
1653
+ // domain that consumers may cache, and re-issuing the ACM cert
1654
+ // requires re-doing DNS validation. RETAIN means an accidental
1655
+ // stack delete leaves the distribution in place for `cdk import`.
1656
+ if (this.dataSafetyConfig.removalPolicy === 'retain') {
1657
+ const cfnDistribution = distribution.node.defaultChild;
1658
+ cfnDistribution.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
1659
+ cfnDistribution.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
1660
+ }
1629
1661
  new cdk.CfnOutput(this, `${logicalId}-cdn-domain`, {
1630
1662
  value: distribution.distributionDomainName,
1631
1663
  description: `CloudFront domain for ${intent.id}`,
@@ -1713,6 +1745,16 @@ export class VentureStack extends cdk.Stack {
1713
1745
  : cognito.Mfa.OFF,
1714
1746
  // Well-Architected: Reliability — dataSafety removal policy
1715
1747
  removalPolicy: this.toRemovalPolicy(),
1748
+ // Well-Architected: Reliability — native deletion protection on top of
1749
+ // RETAIN. `DeletionPolicy=Retain` only blocks CFN-driven deletes; the
1750
+ // user pool can still be deleted via console / API / CLI. Cognito's
1751
+ // native deletionProtection flag blocks that path too. Both layers
1752
+ // must be explicitly disabled before the pool can be deleted, and
1753
+ // every flip is CloudTrail-logged. Losing a Cognito User Pool means
1754
+ // losing every user identity + password hash — the most catastrophic
1755
+ // recoverable state in a VentureKit deploy, since users must re-
1756
+ // register via password reset.
1757
+ deletionProtection: this.dataSafetyConfig.removalPolicy === 'retain',
1716
1758
  // Well-Architected: Security — advanced security mode for medium+ presets
1717
1759
  advancedSecurityMode: this.envConfig.lambda.memoryMb >= 512
1718
1760
  ? cognito.AdvancedSecurityMode.ENFORCED
@@ -1817,6 +1859,14 @@ export class VentureStack extends cdk.Stack {
1817
1859
  clientId: cdk.SecretValue.unsafePlainText('PLACEHOLDER'),
1818
1860
  clientSecret: cdk.SecretValue.unsafePlainText('PLACEHOLDER'),
1819
1861
  },
1862
+ // Well-Architected: Reliability — federated provider credentials
1863
+ // are operator-set values pasted from the third-party developer
1864
+ // console (Google Cloud, Meta for Developers, Apple Developer).
1865
+ // They can be re-generated by the operator but require manually
1866
+ // revisiting that console; we'd rather not force that on every
1867
+ // stack-replace event. Promote to RETAIN under strict dataSafety
1868
+ // so accidental stack deletes don't wipe them.
1869
+ removalPolicy: this.toRemovalPolicy(),
1820
1870
  });
1821
1871
  this.federatedAuthSecrets.push(secret);
1822
1872
  const envVar = `COGNITO_FEDERATED_${provider.toUpperCase()}_SECRET_ARN`;
@@ -1876,6 +1926,18 @@ export class VentureStack extends cdk.Stack {
1876
1926
  // for a topic only consumed by our own
1877
1927
  // Lambda + the operator's SNS console.
1878
1928
  });
1929
+ // Well-Architected: Reliability — RETAIN the events topic under
1930
+ // strict dataSafety. SNS topics carry durable subscriptions (the
1931
+ // bounce-handler Lambda is one; operators may attach additional
1932
+ // subscribers via the console for observability tools or paging).
1933
+ // Deleting the topic silently drops every subscription; re-creating
1934
+ // the topic does NOT restore them. RETAIN means an accidental stack
1935
+ // delete leaves the topic + subscriptions in place for recovery.
1936
+ if (this.dataSafetyConfig.removalPolicy === 'retain') {
1937
+ const cfnEventsTopic = eventsTopic.node.defaultChild;
1938
+ cfnEventsTopic.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
1939
+ cfnEventsTopic.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
1940
+ }
1879
1941
  new cdk.CfnOutput(this, `${logicalId}-events-topic-arn`, {
1880
1942
  value: eventsTopic.topicArn,
1881
1943
  description: `SES events SNS topic for notify '${intent.id}'`,
@@ -2120,6 +2182,12 @@ export class VentureStack extends cdk.Stack {
2120
2182
  queueName: `${projectName}-${stage}-${dispatcherFnId}-dlq`,
2121
2183
  retentionPeriod: cdk.Duration.days(14),
2122
2184
  encryption: sqs.QueueEncryption.SQS_MANAGED,
2185
+ // Well-Architected: Reliability — queues hold accepted-but-not-yet-
2186
+ // processed work. CDK's default removalPolicy for sqs.Queue is
2187
+ // DESTROY, which would silently delete the DLQ and all its failed-
2188
+ // message diagnostics on stack delete. Under dataSafety='strict' we
2189
+ // promote to RETAIN so the queue + messages survive any stack churn.
2190
+ removalPolicy: this.toRemovalPolicy(),
2123
2191
  });
2124
2192
  const dispatcherFn = new lambda.Function(this, dispatcherFnId, {
2125
2193
  functionName: `${projectName}-${stage}-${dispatcherFnId}`,
@@ -2165,6 +2233,10 @@ export class VentureStack extends cdk.Stack {
2165
2233
  queueName: `${projectName}-${stage}-${bounceFnId}-dlq`,
2166
2234
  retentionPeriod: cdk.Duration.days(14),
2167
2235
  encryption: sqs.QueueEncryption.SQS_MANAGED,
2236
+ // Same rationale as the dispatcher DLQ above: bounced/complained
2237
+ // notifications are the canonical record of what failed and why;
2238
+ // dropping them on stack delete loses the audit trail.
2239
+ removalPolicy: this.toRemovalPolicy(),
2168
2240
  });
2169
2241
  const bounceFn = new lambda.Function(this, bounceFnId, {
2170
2242
  functionName: `${projectName}-${stage}-${bounceFnId}`,
@@ -2213,6 +2285,12 @@ export class VentureStack extends cdk.Stack {
2213
2285
  retentionPeriod: cdk.Duration.days(14),
2214
2286
  // Well-Architected: Security — encryption at rest
2215
2287
  encryption: sqs.QueueEncryption.SQS_MANAGED,
2288
+ // Well-Architected: Reliability — DLQs are the durable record of every
2289
+ // message a consumer Lambda failed to process. Default CDK removalPolicy
2290
+ // for sqs.Queue is DESTROY; under dataSafety='strict' we promote to
2291
+ // RETAIN so investigation of past failures survives any redeploy or
2292
+ // stack-delete event.
2293
+ removalPolicy: this.toRemovalPolicy(),
2216
2294
  });
2217
2295
  const queue = new sqs.Queue(this, logicalId, {
2218
2296
  queueName: `${projectName}-${intent.id}-${stage}${intent.type === 'fifo' ? '.fifo' : ''}`,
@@ -2228,6 +2306,15 @@ export class VentureStack extends cdk.Stack {
2228
2306
  queue: dlq,
2229
2307
  maxReceiveCount: intent.maxReceiveCount ?? 3,
2230
2308
  },
2309
+ // Well-Architected: Reliability — primary work queue holds accepted
2310
+ // jobs that have not yet been processed. CDK's default is DESTROY,
2311
+ // which would lose every in-flight message on any stack delete or
2312
+ // logical-ID rename. Under dataSafety='strict' we promote to RETAIN.
2313
+ // Combined with the upcoming multi-stack messaging-tier split, the
2314
+ // queue then survives even an app-stack tear-down — new ESMs in the
2315
+ // redeployed app stack resume consumption from where the old one
2316
+ // stopped.
2317
+ removalPolicy: this.toRemovalPolicy(),
2231
2318
  });
2232
2319
  new cdk.CfnOutput(this, `${logicalId}-queue-url`, {
2233
2320
  value: queue.queueUrl,
@@ -2337,6 +2424,12 @@ export class VentureStack extends cdk.Stack {
2337
2424
  const secret = new secretsmanager.Secret(this, resourceName, {
2338
2425
  secretName: `${projectName}/${stage}/${intent.id}`,
2339
2426
  description: intent.description || `Secret for ${intent.id}`,
2427
+ // Well-Architected: Reliability — operator-declared secrets hold
2428
+ // values the operator pasted in manually (API keys, third-party
2429
+ // integration tokens). They cannot be re-generated by VentureKit
2430
+ // and an accidental delete forces a manual rotation of every
2431
+ // downstream credential. RETAIN under strict dataSafety.
2432
+ removalPolicy: this.toRemovalPolicy(),
2340
2433
  });
2341
2434
  new cdk.CfnOutput(this, `${resourceName}-secret-arn`, {
2342
2435
  value: secret.secretArn,
@@ -2351,6 +2444,13 @@ export class VentureStack extends cdk.Stack {
2351
2444
  stringValue: 'PLACEHOLDER — set value after deploy',
2352
2445
  tier: ssm.ParameterTier.STANDARD,
2353
2446
  });
2447
+ // SSM parameters don't carry their own DeletionPolicy attribute
2448
+ // (the resource type doesn't support it in CFN), and AWS doesn't
2449
+ // expose a "deletion protection" flag for them either. Operators
2450
+ // relying on these for production secret-like config should
2451
+ // either move to Secrets Manager via `store: 'secretsmanager'`
2452
+ // (which we now RETAIN above) or accept that an accidental
2453
+ // stack delete loses the value.
2354
2454
  new cdk.CfnOutput(this, `${resourceName}-param-name`, {
2355
2455
  value: param.parameterName,
2356
2456
  description: `SSM Parameter name for ${intent.id}`,
@@ -2386,12 +2486,30 @@ export class VentureStack extends cdk.Stack {
2386
2486
  domainName: intent.domain,
2387
2487
  certificate,
2388
2488
  });
2489
+ // Well-Architected: Reliability — RETAIN both the domain name and the
2490
+ // mapping under strict dataSafety. The custom domain is the *public*
2491
+ // contract with the world; deleting it forces DNS reconfiguration on
2492
+ // every client/integration. RETAIN means an accidental stack delete
2493
+ // leaves the domain claimed to this account and ready for `cdk
2494
+ // import`. The ApiMapping rides along — without it the domain points
2495
+ // nowhere, but mappings recreate cleanly so RETAIN is mostly a
2496
+ // convenience for the recovery path.
2497
+ if (this.dataSafetyConfig.removalPolicy === 'retain') {
2498
+ const cfnDomain = domainName.node.defaultChild;
2499
+ cfnDomain.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
2500
+ cfnDomain.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
2501
+ }
2389
2502
  // API mapping
2390
- new apigatewayv2.ApiMapping(this, `${resourceName}-mapping`, {
2503
+ const apiMapping = new apigatewayv2.ApiMapping(this, `${resourceName}-mapping`, {
2391
2504
  api,
2392
2505
  domainName,
2393
2506
  stage: api.defaultStage,
2394
2507
  });
2508
+ if (this.dataSafetyConfig.removalPolicy === 'retain') {
2509
+ const cfnMapping = apiMapping.node.defaultChild;
2510
+ cfnMapping.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
2511
+ cfnMapping.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
2512
+ }
2395
2513
  // Route53 A record if hosted zone provided
2396
2514
  if (intent.hostedZoneId) {
2397
2515
  const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, `${resourceName}-zone-record`, {
@@ -2437,6 +2555,13 @@ export class VentureStack extends cdk.Stack {
2437
2555
  timeToLiveAttribute: 'ttl',
2438
2556
  // Well-Architected: Operational Excellence — contributor insights for medium+
2439
2557
  contributorInsightsEnabled: this.envConfig.lambda.memoryMb >= 512,
2558
+ // Well-Architected: Reliability — native deletion protection on top of
2559
+ // RETAIN. DeletionPolicy=Retain only blocks CFN-driven deletes; turning
2560
+ // on deletionProtectionEnabled also blocks the AWS console / API / CLI
2561
+ // "delete table" path. Together they make accidental data loss require
2562
+ // two explicit operator actions (disable protection AND override the
2563
+ // deletion policy), each loggable in CloudTrail.
2564
+ deletionProtection: this.dataSafetyConfig.removalPolicy === 'retain',
2440
2565
  });
2441
2566
  // Well-Architected: Reliability — prevent accidental replacement
2442
2567
  const cfnTable = table.node.defaultChild;
@@ -2477,10 +2602,28 @@ export class VentureStack extends cdk.Stack {
2477
2602
  tableName,
2478
2603
  partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
2479
2604
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
2480
- removalPolicy: cdk.RemovalPolicy.DESTROY,
2605
+ // Well-Architected: Reliability — rate-limit counters are per-user
2606
+ // state. Hardcoding DESTROY would erase every user's current rate-
2607
+ // limit window on any stack churn, surfacing as a simultaneous
2608
+ // "all users get a fresh quota" event — minor but observable under
2609
+ // load and not what dataSafety='strict' implies. Track dataSafety
2610
+ // the same way every other stateful resource does.
2611
+ removalPolicy: this.toRemovalPolicy(),
2481
2612
  timeToLiveAttribute: 'ttl',
2482
2613
  encryption: dynamodb.TableEncryption.AWS_MANAGED,
2614
+ // Native AWS deletion protection alongside RETAIN. See the
2615
+ // user-declared DDB path above for the two-layer rationale.
2616
+ deletionProtection: this.dataSafetyConfig.removalPolicy === 'retain',
2483
2617
  });
2618
+ // Well-Architected: Reliability — UpdateReplacePolicy=Retain so that a
2619
+ // property change requiring REPLACE (e.g. changing the partition-key
2620
+ // schema, which CFN executes as delete-then-create) preserves the old
2621
+ // table rather than dropping every active rate-limit window. Matches
2622
+ // the user-declared DDB path.
2623
+ const cfnRateLimitTable = table.node.defaultChild;
2624
+ if (this.dataSafetyConfig.removalPolicy === 'retain') {
2625
+ cfnRateLimitTable.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
2626
+ }
2484
2627
  this.ventureBaseEnv['VENTURE_RATE_LIMIT_TABLE'] = table.tableName;
2485
2628
  new cdk.CfnOutput(this, `${logicalId}-table-name`, {
2486
2629
  value: table.tableName,