Case Study 1: Two to Add, One to Change, One to Destroy
"The pull request renamed a database. Everyone reviewed the rename. Terraform read it as: delete this database, then make an empty one with the new name."
Executive Summary
A pull request standardized Kestrel's Snowflake naming: ANALYTICS became KESTREL_ANALYTICS,
alongside three other cosmetic changes. Two engineers approved it. The diff was four lines and
every one of them was a string.
snowflake_database.name is a ForceNew attribute. Terraform's plan said:
Plan: 2 to add, 1 to change, 1 to destroy.
The one to destroy was the production analytics database, containing every gold model. The apply ran at 14:12 on a Wednesday and completed in nine seconds.
Recovery took six hours and was possible only because Snowflake's Time Travel retained the dropped database for one day, and because someone thought to check within that day. The margin was under seven hours.
Skills applied: reading a plan's resource lines rather than its summary (§28.3); prevent_destroy;
the moved block (§28.12's 🧭 note); and the general shape of a destructive change that looks
cosmetic.
Background
The change, in full:
resource "snowflake_database" "analytics" {
- name = "ANALYTICS"
+ name = "KESTREL_ANALYTICS"
comment = "gold + silver models"
}
resource "snowflake_warehouse" "transform" {
- name = "TRANSFORM_WH"
+ name = "KESTREL_TRANSFORM_WH"
...
}
A naming-convention cleanup, of exactly the kind that gets merged on a quiet afternoon. It was reviewed by two people, both of whom read it as a rename, because that is what it is.
What Terraform does with a rename depends entirely on whether the provider marks the attribute
ForceNew, and the review has no way to see that — it is a property of the provider, documented on
a page nobody has open, and it differs between resource types in the same provider:
snowflake_database.name ForceNew → destroy and recreate
snowflake_warehouse.name ForceNew → destroy and recreate (but empty)
snowflake_database.comment in-place
Both renames forced a replacement. Only one of them mattered, because a warehouse contains no data and a database contains everything.
The Problem
The plan output, in full, as it appeared in the CI log:
Terraform will perform the following actions:
# snowflake_database.analytics must be replaced
-/+ resource "snowflake_database" "analytics" {
~ name = "ANALYTICS" -> "KESTREL_ANALYTICS" # forces replacement
}
# snowflake_warehouse.transform must be replaced
-/+ resource "snowflake_warehouse" "transform" {
~ name = "TRANSFORM_WH" -> "KESTREL_TRANSFORM_WH" # forces replacement
}
Plan: 2 to add, 1 to change, 1 to destroy.
Everything needed is in that output. must be replaced, -/+, and # forces replacement — three
separate signals, all present, all correct.
And the summary line, which is what people read, says 1 to destroy — which is true, and does not
say what, and is easy to read as the routine churn every plan contains.
⚠️ Failure Mode — the summary line is the only part that gets read
Plan: 2 to add, 1 to change, 1 to destroyappears at the bottom of every plan, and most plans contain a destroy that is completely routine — a security-group rule being replaced, a null resource, a rotated attachment.So the summary line trains you to expect a destroy, and by the time a plan contains one that matters, the count carries no information at all.
Three properties made this specific instance nearly invisible:
- The count was small. One. A plan proposing to destroy fourteen things gets read.
- The diff was cosmetic. Four string literals, in a PR titled "standardize naming."
- The plan was in a CI log, collapsed, and the summary was in the PR check's title.
The fix is not "read the plan more carefully." That is Chapter 27 §27.10's rejected instruction — if a defect is invisible in the artifact under review, diligence is not the fix.
The fix is that the artifact under review must show it. Two independent controls, and Kestrel now has both:
prevent_destroyon the resource, which makes the apply fail rather than proceed. It is one block, and it converts a catastrophe into an error message.- A CI check on the plan JSON that surfaces the resource-level detail as a blocking comment, above the fold.
code/plan_review.py.Neither is sufficient alone.
prevent_destroycan be removed in the same pull request, and a CI check can be overridden — which is why two, and why they fail in different ways.
The Analysis
The apply ran from CI on merge, which was the team's normal flow and had been for a year.
14:12:03 Terraform apply
14:12:04 snowflake_database.analytics: Destroying... [id=ANALYTICS]
14:12:07 snowflake_database.analytics: Destruction complete after 3s
14:12:07 snowflake_database.analytics: Creating...
14:12:09 snowflake_database.analytics: Creation complete after 2s
14:12:12 Apply complete! Resources: 2 added, 1 changed, 1 destroyed.
Nine seconds, and Apply complete! — a success message, because from Terraform's point of view it
did exactly what it was asked.
What was lost: every gold and silver model. 90 tables. fct_order_item, dim_customer,
gold.daily_revenue, and everything downstream of them.
What was not lost: bronze, which is in a separate database, and the lake, which is in S3. So the data was recoverable by rebuilding — which would have taken about four hours — and the question was whether anything was needed that could not be rebuilt.
Step 1: what noticed? Not the apply. The first signal was a dashboard error at 14:31, nineteen minutes later, when someone opened the executive report.
And this is worth pausing on: Chapter 25's monitoring is built around a nightly cadence — freshness against a 06:00 deadline, duration against a trailing median, a margin. None of it fires at 14:12 on a Wednesday, because none of it was designed for a mid-afternoon catastrophe.
Step 2: what is recoverable, and for how long? This determined everything, and it is Chapter 26 §26.5's retention check being run under pressure rather than in advance:
-- Snowflake retains a dropped database for its Time Travel period.
SHOW DATABASES HISTORY LIKE 'ANALYTICS';
-- dropped_on: 2026-09-16 14:12:07 retention_time: 1 day
One day. DATA_RETENTION_TIME_IN_DAYS was at the account default of 1, on an Enterprise account
where it could have been up to 90.
$$\text{recovery window} = 24\ \text{hours from } 14{:}12 \Rightarrow \text{deadline } 14{:}12 \text{ tomorrow}$$
UNDROP DATABASE ANALYTICS; -- 14:47. Complete in 4 seconds.
Four seconds to restore what took nine seconds to destroy, and the entire incident's risk was contained in the gap between 14:12 and whenever somebody thought to run that.
Step 3: what did the six hours go on? Not the restore. The reconciliation:
- Confirming all 90 tables were present and at the expected row counts.
- Re-running the day's incremental loads, which had been skipped.
- Re-granting permissions — the
UNDROPrestored the database and not the grants, which were managed by a separate Terraform resource and had to be re-applied. - Verifying that the 14:12–14:47 window had not written anything into the new empty database that now needed reconciling.
🔎 Read the Plan —
UNDROPrestored the data and not the accessThe detail that turned a forty-minute incident into a six-hour one, and it generalizes past Snowflake.
A restore returns the object. It does not return everything about the object's context — grants, tags, policies, and, in some systems, replication or streams.
```sql -- After UNDROP: the tables are there. SELECT COUNT(*) FROM ANALYTICS.GOLD.FCT_ORDER_ITEM; -- 6,483,117 ✓
-- And nothing can read it. SHOW GRANTS ON DATABASE ANALYTICS; -- 0 rows ```
Terraform had the grants, in a different resource, and re-applying them was straightforward once somebody realized. Finding that out took ninety minutes, most of it spent debugging permission errors from a BI tool that reported them as connection failures.
The runbook item this produced, and it belongs in yours: "after any restore, verify grants, tags, and policies separately from data. The restore command does not." Then test it — Chapter 26 Case Study 2's drill, which Kestrel then ran on this runbook and found two more implicit steps.
The Decision
Five changes, and the first two are the ones that matter.
One: prevent_destroy on every stateful resource.
resource "snowflake_database" "analytics" {
name = "ANALYTICS"
lifecycle { prevent_destroy = true }
}
The apply now fails with an explicit error naming the resource, and removing the block is a separate, visible, reviewable line in a diff.
Two: plan_review.py between plan and apply, blocking on any destroy or replacement of a stateful
resource. Above the fold, in the pull request, with the resource named.
Three: DATA_RETENTION_TIME_IN_DAYS raised from 1 to 7, and set in Terraform so it cannot drift
back.
📐 Design Decision — why 7, and not 90
The Enterprise account allows up to 90 days of Time Travel, and the obvious response to a near-miss with a 24-hour window is to take all of it.
They chose 7, and the reasoning is the one Chapter 26 §26.5 asks for:
Time Travel is not free. Snowflake charges for the storage the retained versions occupy, and on a database rebuilt nightly that is substantial — a rough estimate put 90 days at $1,900 a month against $150 for 7.
And the question retention answers is "how long until somebody notices." Kestrel's realistic detection time for a destroyed database is minutes — a dashboard breaks. Seven days is more than two orders of magnitude of margin on that.
Which is not the same question Chapter 24 Case Study 1 asked, where a March write was damaged in August and needed 141 days. The two incidents want different retentions because they are different failures, and the honest conclusion Kestrel recorded is that Time Travel is the right control for a destruction and the wrong one for a slow corruption — for which the source system's 18-month retention is the actual recovery path.
Set retention from the failure it is protecting against, and name that failure in the comment.
Four: moved blocks for genuine renames. §28.12's 🧭 note — a rename in configuration without a
destroy:
moved {
from = snowflake_database.analytics
to = snowflake_database.kestrel_analytics
}
This handles renaming the Terraform address. Renaming the object is still a replacement, and the team's conclusion — do not rename production databases for cosmetic reasons — is recorded as a decision rather than left implicit.
Five: applies to the stateful state file require a second approver. §28.8's split, which was implemented in the same fortnight for exactly this reason.
What Happened
| Time to destroy | 9 seconds |
| Time to first signal | 19 minutes, a broken dashboard |
| Time to restore data | 4 seconds, at 14:47 |
| Time to restore access | 90 minutes |
| Total incident | 6 hours |
| Recovery window remaining | ~23 hours of 24 |
The margin is the number that stayed with the team. The UNDROP was available for one day, it was
used within 35 minutes, and nobody involved knew what the retention was until they looked it up
during the incident.
Three further findings:
Nothing in Chapter 25's monitoring fires at 14:12. Every check was built around a nightly cadence and a 06:00 deadline. A freshness check that runs hourly was added — cheap, and it would have signalled at 15:00 rather than depending on a person opening a dashboard.
Twelve other resources had no prevent_destroy, including both lake buckets. A bucket
replacement would not have been recoverable at all, because S3 versioning does not survive a bucket
deletion.
The naming convention was abandoned. Not because it was wrong, but because the team could not identify a benefit that justified any risk to a production database, and writing that down was the outcome: "cosmetic changes to stateful resource names are not worth doing. If a name is genuinely wrong, that is a migration, not a rename."
Lessons
-
A rename can be a destruction, and whether it is depends on a
ForceNewflag in the provider that is not visible in the diff. -
The summary line is the only part that gets read, and most plans contain a routine destroy — so the count carries no information by the time it matters.
-
"Read the plan more carefully" is not a fix. The artifact under review must show it.
-
Two independent controls, failing differently:
prevent_destroymakes the apply fail; a CI check on the plan JSON makes it visible before merge. Either can be bypassed; both being bypassed requires two deliberate acts. -
Apply complete!is a success message for doing exactly what was asked. -
Nothing in a nightly-cadence monitoring system fires at 14:12 on a Wednesday. The first signal was a person opening a dashboard nineteen minutes later.
-
A restore returns the object, not its context. Grants, tags, and policies came back separately, and finding that out took ninety minutes of debugging permission errors reported as connection failures.
-
Set retention from the failure it protects against, and name that failure. Seven days for a destruction (detected in minutes); Chapter 24 Case Study 1's slow corruption needed 141 and Time Travel is the wrong control for it.
-
Nobody knew the retention until they looked it up during the incident. It should have been in a runbook, and Chapter 26 §26.5 says so.
-
A bucket replacement is not recoverable at all — versioning does not survive the bucket.
-
The cosmetic change was abandoned and the reason was written down. "If a name is genuinely wrong, that is a migration, not a rename."
Questions for Discussion
-
Two engineers reviewed a four-line diff of string literals. What could either have done, and is it reasonable to expect it?
-
The plan output contained three separate correct signals. Why did none of them work, and what does that suggest about signal design generally?
-
prevent_destroyand the CI check can both be bypassed. Is "two controls that fail differently" genuine defence, or two-thirds of a ritual? -
Retention was set at 7 days from a detection time of minutes. Argue for 30. What would you be buying?
-
Nothing fired at 14:12 because the monitoring assumed a nightly cadence. What else in your systems assumes a schedule that a failure does not respect?
-
The
UNDROPwindow was 24 hours and was used within 35 minutes. What would have happened at 02:00 on a Saturday, given Chapter 26 §26.1's paging policy? -
The team abandoned a naming convention rather than accept any risk to a production database. Is that proportionate, or is it an over-correction from one incident?