blast-area / references/worked-blast-map.md
A worked blast map
A reference shipped with the skill · 1,564 words
A worked blast map
One change set, mapped end to end, with the parts that usually get skipped left in: the premise that did not survive, the negative that decided the outcome, the silent breaks, and the blindspot that was still there when the map was delivered.
The repository is a monorepo of roughly 2,500 TypeScript files across four build pipelines, with a relational database, a background job runner and a dashboard. Names below are generic.
The brief, as received
"Drop the
sync_statecolumn fromdocuments. It's dead — the dashboard stopped showing it months ago, nothing writes it any more, and the only reader is the stale-record job which we are deleting in the same change."
Three checkable premises: (a) nothing writes it, (b) the only reader is one job, (c) the dashboard no longer shows it.
Step 1 — restate, and refute what does not hold
Change set, written as things and operations:
| Thing | Operation |
|---|---|
documents.sync_state (column) |
drop |
| the stale-record job | delete |
| the job's registry entry | delete |
Findings against the premises, after the searching:
- (a) "nothing writes it" — refuted, with a number. Two writers: the import path writes it on
every insert (
import/persist.ts:118), and a repair script sets it in bulk (ops/repair-sync.ts:44). The import writer is live; the script last ran in the interval covered by the history search. - (b) "the only reader is the stale-record job" — refuted, with a number. Four readers: the job, a string-built analytics query, a database view, and a report column derived from the view. Three of the four are invisible to the typechecker.
- (c) "the dashboard no longer shows it" — holds. No component references the field; the search that established this fired its control.
Two of three premises fail. The map continues on the surviving one rather than refusing the brief; the refutation itself is the most consequential line in the deliverable, because it changes the change.
Step 2 — the twelve surfaces
| Surface | Nodes | Break | Confidence |
|---|---|---|---|
callers |
import/persist.ts:118 (writer), jobs/stale-records.ts:31 (reader) |
compile — both are typed field accesses |
measured |
data-contracts |
the column; view v_document_health; derived report column health_score |
runtime for the view, silent for health_score |
measured |
jobs |
the stale-record job; its registry entry | silent if the registry entry is removed without the job, or the reverse |
enumerated (the runtime reads this registry) |
ui |
none | none |
measured — control fired |
tests-and-guards |
3 tests referencing the field; 1 schema-drift guard keyed on the column list | tests compile; the guard silent |
measured |
config-and-toolchains |
4 build pipelines process the touched files; 1 generates types from the schema | compile in the generator, none elsewhere |
enumerated (manifests counted) |
deploy-ordering |
code-first, two steps | runtime inside the window |
measured |
docs-and-data-resident |
1 saved analytics query row naming the column | silent |
measured — the store was queried |
external-consumers |
none found; warehouse sync does not select it | none |
measured — control fired |
second-order |
health_score consumers: 1 report, 1 alert threshold |
silent — the alert stops firing |
measured |
reversibility |
column drop is irreversible for the data; job deletion is revertible | — | enumerated |
work-in-flight |
1 open pull request renames the same job key; 1 written, unapplied migration touches the same table | compile for the rename collision at merge; silent for the two migrations, which claim the same version |
enumerated (forge queried, applied history read) |
The three silent breaks
health_score. The view keeps returning rows; the derived score keeps computing, now against a missing input absorbed by aCOALESCE. Nothing throws. The score simply drifts, and it drives an alert threshold two hops downstream. Observed instead of an error: the alert stops firing.- The schema-drift guard. Its pattern names the column list. With the column gone, the pattern matches nothing and the guard passes — permanently, in green, protecting nothing. Mutating a fixture confirmed the guard currently fires; it will not after the change.
- The saved analytics query row. A row in a table, not a file. No code search would have returned it. The query fails at execution time inside a scheduled report, which retries and then goes quiet.
Step 3 — the negative that decided the outcome
The external-consumers surface was the one everybody expected to be the problem, and it was empty. That emptiness is only worth anything because it is recorded as a finding:
{
"target": "warehouse sync selecting documents.sync_state",
"query": "git grep -n \"sync_state\" -- warehouse/",
"tool": "git grep",
"hits": 0,
"control": "git grep -n \"documents\" -- warehouse/ → 27 hits",
"control_fired": true,
"verdict": "absent"
}
The first attempt at this search used git grep -E '\bsync_state\b' and returned nothing — as it
always would, because POSIX ERE has no \b. Without the control, the map would have recorded an
absence that was a property of the regular expression, on the one surface where being wrong meant
breaking somebody else's system.
The literal search on the data-contract surface returned nine hits and kept four, so the map
carries the five it dropped — each opened, each classified by the table its query names rather
than by the module it sits in. The arithmetic is on the page, because that is what makes a
silently dropped hit visible: git grep -c sync_state → 9 hits: 4 consumers + 5 discards, 9 = 4 + 5.
discarded | reports/health.ts:44 | SELECT ... FROM document_health_daily | document_health_daily | rollup table, not the base table
discarded | export/csv.ts:19 | SELECT ... FROM exports | exports | different table, shared column name
discarded | jobs/backfill.ts:71 | comment naming sync_state | — | comment, not a query
discarded | test/fixtures/doc.ts:6 | fixture literal | — | fixture, not a read
discarded | api/status.ts:33 | SELECT ... FROM documents | documents | selects the row, never the column
The last row is the one that matters: it is a query against the changed table, kept out on what it selects rather than on where it lives. The map's claim is "two readers, both in the ingest path" — which is an exclusivity claim, and the five rows above are its evidence.
Step 4 — deploy ordering, with its reason
Code-first, two steps. The schema half would break readers that are live right now: the import path writes the column on every insert, so dropping it first fails in the window rather than at deploy time.
- Deploy the code that stops writing and reading it — import path, job, view, saved query row, guard pattern.
- Drain: in-flight imports, the job's current run, and any warm instance of the previous deployment. Then drop the column.
Window contents: new code against the old schema, which is safe — the column simply goes unused. The reverse window is not.
Step 5 — what this map cannot see
- Dynamic dispatch. Two registries were found and read: the job registry (which the runtime
itself reads —
enumerated) and the generated type registry. Anything dispatched through a string assembled at runtime is outside this map, and one code path assembles a query name from configuration. - Data-resident references. The application database was queried, for saved queries, report definitions and flag rules; one hit, listed above. The hosted feature-flag service was not reachable from this run — that is the blindspot below.
- Resolution basis. Name-based, with a language server available for TypeScript only. The measured collision count for this repository is 184 exported names defined in more than one file. The two SQL-embedded readers were found by literal search, not by resolution.
- Runtime-only edges. The alert threshold lives in a monitoring system outside the repository;
its dependency on
health_scorewas established from a dashboard definition, not from code.
Precision ceiling. No compiler-resolved symbol index exists here. derive-codebase-context
argues that such an index is the only thing that answers blast radius correctly in a repository
with duplicate exported names, and that it costs about a week — so it is deferred deliberately.
This map reports at the ceiling it has: name-based resolution, four surfaces at measured rather
than enumerated.
Blindspot carried into the deliverable:
{
"id": "b1",
"label": "hosted feature-flag rules naming sync_state",
"surface": "docs-and-data-resident",
"reason": "the flag service was not reachable from this run; no credentials in this context",
"probe": "export the flag rule set and grep it for the column name"
}
What the map changed
The brief asked for a column drop and a job deletion. The map returned a two-step deploy, three silent breaks with what each one would look like instead of an error, one guard that needed re-arming, one datastore row that no code search would have found, an irreversibility on the data half, one open pull request renaming the same job key — and a refutation of two of the brief's three premises, with counts. The change that got made was not the change that was asked for, which is the point.