What If Your Pull Request Had a Blast Radius Score?

Some pull requests look harmless until they reach production. Then someone notices the change touched authentication, a shared response contract, an EF Core migration, and one production config value that doesn't exist in staging. That wasn't a small PR. That was a blast radius problem. We tend to review pull requests through the shape GitHub gives us. Files changed. Lines added. Lines removed. Comments. Checks. Approvals.
Those signals help, but they're incomplete. A one thousand line test only change may be tedious. A twelve line change to token validation may deserve every senior engineer in the room. A tiny update to appsettings.Production.json might be the whole incident.
So I started thinking about a small tool I wish existed in more .NET teams. What if every pull request had a Blast Radius Score? Not as a gate. Not as a replacement for review. More like a warning label that says, "look here first".
Blast Radius Score: 78 / 100
Risk level: High
Main signals:
- Public response contract changed
- EF Core migration added
- Production configuration changed
- Authorisation code touched
- No matching tests changed
That score doesn't need to be perfect to be useful. It just needs to make risk visible before the first reviewer opens the diff.
PR size is a weak signal
A lot of review conversations still start with size.
"This PR is only 80 lines."
"This PR is too big."
"Can you split this up?"
Size is important, but size alone can be misleading. Some large pull requests are mostly mechanical. Some tiny ones change the behaviour of the whole system. In a .NET application, the dangerous changes are often the ones that cross a boundary. A handler implementation change may stay local. A DTO change can leak into consumers. A migration can affect existing data. A config key can behave differently in every environment. A change to AddAuthorization can alter access rules across an entire API. Thats the kind of difference a blast radius score should surface.
The useful question is simple:
How far could this change spread if it's wrong?
That framing changes the review. Instead of asking someone to stare harder at a diff, the tool points them towards the parts of the change that deserve attention.
What blast radius means in a .NET app
In a .NET codebase, blast radius usually comes from boundaries and runtime behaviour. A private method inside one feature has a small radius. It might still contain a bug, but the damage is likely contained. A public API response has a wider radius. Other services, clients, tests, dashboards, and support scripts may depend on it. A database migration has a different kind of radius. It changes the shape of persisted state. That makes rollback harder, especially when production data starts moving through the new shape.
Configuration is another classic source of risk. A change can pass locally because local config is complete. It can pass in test because test has a fallback. Then production fails because a key was named differently in one deployment slot.
Security sensitive code deserves special weight. Authentication, authorisation, claims mapping, CORS, token validation, and secret handling can be affected by tiny changes. They shouldn't be reviewed with the same level of suspicion as a formatting fix. Runtime behaviour is important too. Retries, timeouts, background workers, queue consumers, caching, and HttpClient setup can change how a system behaves under pressure. Those changes may not look dramatic in code review, but they decide what happens at 2am when a dependency starts timing out.
A good blast radius tool should know about those areas.
The first version should be simple on purpose
The tempting version of this idea is too clever. You could imagine AI reading the whole pull request and writing a risk assessment. You could imagine deep semantic analysis. You could imagine a model trained on historic incidents.
I wouldn't start there.
The first useful version can be rule based. Look at the changed files. Classify them. Apply simple weights. Produce a short report. Make the output simple enough that people trust it. The tool could run as a .NET global tool, a GitHub Action, or both.
dotnet tool install --global BlastGuard.Cli
blastguard analyse --base main --head feature/payment-change
The output might look like this:
Blast Radius Report
Score: 82 / 100
Risk: High
Detected risk areas:
Public contracts
- Modified CreatePaymentRequest
- Modified PaymentStatusResponse
Database
- Added EF Core migration
- Changed payment amount precision
Configuration
- Modified appsettings.Production.json
- Added Payments:ProviderTimeoutSeconds
Security-sensitive code
- Modified ClaimsPrincipalExtensions
- Modified authorisation policy registration
Test signal
- No matching test files changed under Payments.Tests
Suggested review focus:
- Confirm API consumers can handle the response change
- Confirm the migration is backwards compatible
- Confirm production config exists in all environments
- Add tests around authorisation and amount precision
That report is deliberately plain. The value comes from focus. It gives reviewers a starting point.
A simple scoring pipeline
The scoring flow can be simple enough to explain in one diagram.
At the centre is a change set.
public sealed record PullRequestChangeSet(
string BaseRef,
string HeadRef,
IReadOnlyList<ChangedFile> Files);
public sealed record ChangedFile(
string Path,
ChangeKind ChangeKind,
int Additions,
int Deletions,
string? Patch);
public enum ChangeKind
{
Added,
Modified,
Deleted,
Renamed
}
The first pass doesn't need to parse every line of C#. It can get useful signals from paths and filenames.
public enum ChangedFileType
{
Unknown,
Documentation,
Test,
ApplicationCode,
PublicContract,
DatabaseMigration,
Configuration,
ProductionConfiguration,
SecuritySensitive,
RuntimeBehaviour,
Infrastructure
}
A classifier can start with conventions.
public sealed class ChangedFileClassifier
{
public ChangedFileType Classify(string path)
{
if (path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
return ChangedFileType.Documentation;
if (path.Contains(".Tests/", StringComparison.OrdinalIgnoreCase) ||
path.Contains(".Tests\\", StringComparison.OrdinalIgnoreCase))
return ChangedFileType.Test;
if (path.Contains("/Migrations/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("\\Migrations\\", StringComparison.OrdinalIgnoreCase))
return ChangedFileType.DatabaseMigration;
if (path.EndsWith("appsettings.Production.json", StringComparison.OrdinalIgnoreCase))
return ChangedFileType.ProductionConfiguration;
if (path.EndsWith("appsettings.json", StringComparison.OrdinalIgnoreCase))
return ChangedFileType.Configuration;
if (path.Contains("/Contracts/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/Dtos/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/Messages/", StringComparison.OrdinalIgnoreCase))
return ChangedFileType.PublicContract;
if (path.Contains("/Auth/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/Security/", StringComparison.OrdinalIgnoreCase))
return ChangedFileType.SecuritySensitive;
if (path.Contains("/Workers/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/HostedServices/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/HttpClients/", StringComparison.OrdinalIgnoreCase))
return ChangedFileType.RuntimeBehaviour;
if (path.Contains("/Infrastructure/", StringComparison.OrdinalIgnoreCase))
return ChangedFileType.Infrastructure;
return ChangedFileType.ApplicationCode;
}
}
This kind of code won't catch everything. That's fine. Early value beats perfect detection. The tool should also let teams configure their own rules, because every codebase has different risk areas.
{
"riskAreas": [
{
"name": "Payments",
"paths": ["src/Payments/**"],
"points": 15
},
{
"name": "Authentication",
"paths": ["src/**/Auth/**", "src/**/Security/**"],
"points": 25
},
{
"name": "Public Contracts",
"paths": ["src/**/Contracts/**", "src/**/Dtos/**"],
"points": 20
}
]
}
The default rules get you started. The config makes it fit your architecture.
The scoring model
I would keep the scoring model obvious. The tool adds points for detected risk signals. It subtracts points for safer signals. Then it clamps the result between 0 and 100.
| Signal | Points |
|---|---|
| Public API contract changed | +20 |
| Message contract changed | +20 |
| EF Core migration added | +20 |
| Production config changed | +15 |
| Authentication code touched | +25 |
| Authorisation code touched | +25 |
| Background worker changed | +15 |
| Retry or timeout policy changed | +10 |
| Multiple bounded areas touched | +5 each |
| No matching tests changed | +10 |
| Large PR over 500 changed lines | +10 |
| Large PR over 1,500 changed lines | +20 |
| Only documentation changed | -30 |
| Only tests changed | -20 |
The exact numbers aren't sacred. They exist to start a conversation. A basic domain model:
public sealed record BlastRadiusScore(
int Value,
RiskLevel RiskLevel,
IReadOnlyList<RiskFinding> Findings);
public sealed record RiskFinding(
string Category,
int Points,
string Message,
string? FilePath = null);
public enum RiskLevel
{
Low,
Medium,
High,
Critical
}
The scoring engine can be tiny.
public interface IBlastRadiusRule
{
IEnumerable<RiskFinding> Analyse(PullRequestChangeSet changeSet);
}
public sealed class BlastRadiusScorer(IEnumerable<IBlastRadiusRule> rules)
{
public BlastRadiusScore Score(PullRequestChangeSet changeSet)
{
var findings = rules
.SelectMany(rule => rule.Analyse(changeSet))
.ToList();
var rawScore = findings.Sum(x => x.Points);
var score = Math.Clamp(rawScore, 0, 100);
return new BlastRadiusScore(
Value: score,
RiskLevel: ToRiskLevel(score),
Findings: findings);
}
private static RiskLevel ToRiskLevel(int score) =>
score switch
{
>= 75 => RiskLevel.Critical,
>= 50 => RiskLevel.High,
>= 25 => RiskLevel.Medium,
_ => RiskLevel.Low
};
}
The rules do the actual work.
public sealed class EfMigrationRule : IBlastRadiusRule
{
public IEnumerable<RiskFinding> Analyse(PullRequestChangeSet changeSet)
{
foreach (var file in changeSet.Files)
{
var isMigration = file.Path.Contains("/Migrations/", StringComparison.OrdinalIgnoreCase) ||
file.Path.Contains("\\Migrations\\", StringComparison.OrdinalIgnoreCase);
if (!isMigration)
continue;
yield return new RiskFinding(
Category: "Database",
Points: 20,
Message: "EF Core migration changed",
FilePath: file.Path);
}
}
}
A production configuration rule is just as simple.
public sealed class ProductionConfigurationRule : IBlastRadiusRule
{
public IEnumerable<RiskFinding> Analyse(PullRequestChangeSet changeSet)
{
foreach (var file in changeSet.Files)
{
if (!file.Path.EndsWith("appsettings.Production.json", StringComparison.OrdinalIgnoreCase))
continue;
yield return new RiskFinding(
Category: "Configuration",
Points: 15,
Message: "Production configuration changed",
FilePath: file.Path);
}
}
}
That already gives you something useful.
Making it understand .NET better
Path-based rules are a good start, but .NET gives us richer clues. A blast radius tool can look for common framework patterns. It doesn't need to understand the whole application. It just needs to recognise shapes that often carry risk. For Minimal APIs, route changes are important. A tool could inspect changed lines for calls like MapGet, MapPost, MapPut, and MapDelete. If a route changes, the report should mention it.
app.MapPost("/api/payments", async (
CreatePaymentRequest request,
CreatePaymentHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.Handle(request, cancellationToken);
return result.ToHttpResult();
});
For EF Core, migrations are the obvious signal, but model configuration can be just as important. A change to IEntityTypeConfiguration<T> can alter column sizes, indexes, relationships, and delete behaviour.
public sealed class PaymentConfiguration : IEntityTypeConfiguration<Payment>
{
public void Configure(EntityTypeBuilder<Payment> builder)
{
builder.Property(x => x.Amount)
.HasPrecision(19, 4);
}
}
A precision change like that might be correct. It still deserves a focused review because it affects persisted data and financial calculations. For options classes, the tool can flag config binding changes. These often look harmless, but they can break at runtime if the environment is missing a value.
builder.Services
.AddOptions<PaymentProviderOptions>()
.BindConfiguration("Payments:Provider")
.ValidateDataAnnotations()
.ValidateOnStart();
A nice rule would reward ValidateOnStart. If production config changes and options validation is missing, the score should rise. If the options are validated on startup, the score can be softened. For hosted services, queue consumers, and background workers, the tool should assume wider runtime impact. These areas often handle retries, concurrency, poison messages, and long running work.
public sealed class PaymentStatusWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await ProcessPendingPayments(stoppingToken);
}
}
}
Changes here can pass unit tests and still behave badly under load.
For outbound dependencies, the tool should care about HttpClient, retry policies, timeouts, and circuit breakers.
builder.Services
.AddHttpClient<PaymentProviderClient>()
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
});
A timeout change from 10 seconds to 90 seconds is small in the diff. It can be huge in production. For authentication and authorisation, the score should rise quickly. The changed line count barely matters here.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanApprovePayment", policy =>
{
policy.RequireClaim("permission", "payments.approve");
});
});
One altered claim name can lock people out or let the wrong people in.
Test changes should affect the score
A blast radius score should look at the risk and the evidence around that risk. If a PR changes a public contract and also updates contract tests, that is still a risk signal. It just feels better than changing the contract with no matching tests. The first version can use a rough convention.
If files under src/Payments changed, look for changed files under tests/Payments.Tests. If authentication code changed, look for tests with Auth, Authorization, or Claims in the path. If a migration changed, look for integration tests or migration tests.
public sealed class MissingMatchingTestsRule : IBlastRadiusRule
{
public IEnumerable<RiskFinding> Analyse(PullRequestChangeSet changeSet)
{
var applicationAreas = changeSet.Files
.Where(file => file.Path.StartsWith("src/", StringComparison.OrdinalIgnoreCase))
.Select(file => GetTopLevelArea(file.Path))
.Where(area => area is not null)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var testPaths = changeSet.Files
.Where(file => file.Path.Contains(".Tests", StringComparison.OrdinalIgnoreCase))
.Select(file => file.Path)
.ToList();
foreach (var area in applicationAreas)
{
var hasMatchingTests = testPaths.Any(path =>
path.Contains(area!, StringComparison.OrdinalIgnoreCase));
if (hasMatchingTests)
continue;
yield return new RiskFinding(
Category: "Tests",
Points: 10,
Message: $"No matching test changes found for {area}");
}
}
private static string? GetTopLevelArea(string path)
{
var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
return parts.Length >= 2 ? parts[1] : null;
}
}
This is imperfect, but useful. It won't know whether the tests are good. It will spot when no related tests moved at all. That alone catches a lot of risky PRs.
The PR comment
Bad automation creates noise. Good automation helps the reviewer make a better decision. The PR comment should be short, specific, and calm. It shouldn't sound like a security scanner screaming about everything.
## Blast Radius Score: 76 / 100
Risk level: High
This PR touches areas that can affect runtime behaviour outside the changed files.
### Main risk signals
| Area | Finding | Points |
|---|---:|---:|
| API Contract | Response DTO changed | +20 |
| Database | EF Core migration added | +20 |
| Configuration | Production settings changed | +15 |
| Tests | No matching test changes found | +10 |
| Scope | 3 bounded areas touched | +15 |
### Suggested review focus
- Check whether API consumers are affected.
- Check whether the migration is backwards compatible.
- Confirm the new config value exists in each environment.
- Add or update tests around the changed contract.
The phrase "suggested review focus" is important. This shouldn't tell the reviewer what to think. It should tell them where to look.
Running it in GitHub Actions
The most useful place for this tool is probably the pull request itself. A GitHub Action could run on every PR, score the change, then publish a check summary or comment.
name: Blast Radius
on:
pull_request:
branches:
- main
jobs:
blast-radius:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install BlastGuard
run: dotnet tool install --global BlastGuard.Cli
- name: Analyse pull request
run: |
blastguard analyse \
--base origin/${{ github.base_ref }} \
--head HEAD \
--format github
The first implementation doesn't need to block the PR. I would start with advisory comments only. Once the team trusts the signal, you can add optional thresholds. For example, a critical score could require an architecture review, a database review, or a second senior reviewer. That step should come later. If you make the tool a hard gate too early, people will treat it as an obstacle rather than a useful review aid.
What different scores might look like
A low score should be simple.
Score: 8 / 100
Risk: Low
Changed:
- README.md
- One unit test file
Review focus:
- Normal review only
A medium score should give context without being dramatic.
Score: 42 / 100
Risk: Medium
Changed:
- One feature handler
- One validator
- Matching tests
Review focus:
- Check validation behaviour
- Check handler branching
A high score should make the reviewer slow down.
Score: 71 / 100
Risk: High
Changed:
- API response contract
- Mapping logic
- EF Core migration
- No contract tests detected
Review focus:
- Check API compatibility
- Check migration safety
- Check missing tests
A critical score should be rare. If everything is critical, nothing is.
Score: 94 / 100
Risk: Critical
Changed:
- Auth policy registration
- Claims mapping
- Production config
- Shared package version
- No tests changed
Review focus:
- Confirm access rules
- Confirm production config
- Confirm downstream impact
- Consider splitting the PR
The tool earns trust by staying quiet on ordinary changes.
Where this helps
This kind of tool helps most when reviewers are busy. Reviewers don't always know the whole codebase. A backend engineer may not immediately know that a DTO is consumed by a frontend app. A new team member may not know that ClaimsPrincipalExtensions sits on the hot path for every secured endpoint. A senior engineer may be reviewing quickly between meetings.
The blast radius report gives them a map of likely risk. It also helps with PR descriptions. If the author says "small refactor" but the tool spots a migration and production config change, the conversation changes. The reviewer can ask for a better description, a rollout note, or extra tests. It helps team leads too. A dashboard of recent high risk PRs could reveal patterns. Maybe risky changes often arrive late in the sprint. Maybe migrations are regularly merged without tests. Maybe auth changes get reviewed by people outside the owning team.
Thats where the idea becomes more than a toy. It turns code review risk into something visible.
Where it can go wrong
The biggest danger is false confidence. A low score doesn't mean the change is safe. It means the tool didn't detect obvious risk signals. A bug in a private method can still be expensive. The second danger is noise. If the tool posts a dramatic comment on every PR, people will ignore it. The scoring needs to be conservative. It should prefer useful silence over constant warnings. The third danger is gaming. Once a score becomes a hard gate, people may split changes awkwardly or rename files to avoid rules. That is another reason to begin with advisory reports.
The fourth danger is pretending the score is objective. It isn't. It reflects the team's current understanding of risk. The rules should evolve as the codebase evolves. If a production incident came from a config flag, add a config rule. If a breaking change came from a message contract, add a message contract rule. If a slow outage came from retry behaviour, add a runtime behaviour rule.
The tool should learn from the team's scars.
What I would add next
After the first path based version, I would add a few deeper checks. OpenAPI diffing would be high value. If the API contract changes, compare generated OpenAPI output before and after the PR. That would catch changed routes, removed fields, altered response codes, and schema changes.
EF migration analysis would also be worth it. Not all migrations are equal. Adding a nullable column is different from dropping a table. A migration that changes precision is different from one that creates a new index.
Message contract detection would help distributed systems. If a record in Contracts or Messages changes, the tool could flag consumers and publishers. Options validation would be a useful .NET-specific rule. If new configuration is introduced, the tool could check whether the options class uses validation and ValidateOnStart.
Test mapping could become smarter over time. The first version can use folder conventions. Later versions could use project references, namespaces, or code coverage reports. None of this needs to happen on day one. The first version should be small enough to trust.
A possible repo shape
If I built this as a small .NET tool, I would keep the structure plain.
src/
BlastGuard.Cli/
BlastGuard.Core/
BlastGuard.Git/
BlastGuard.GitHub/
tests/
BlastGuard.Core.Tests/
BlastGuard.Cli.Tests/
BlastGuard.Core owns the rules and scoring.
BlastGuard.Git reads diffs locally.
BlastGuard.GitHub formats comments and check summaries.
BlastGuard.Cli wires it together.
The CLI could start with one command.
blastguard analyse --base main --head HEAD --format markdown
Later, it could support JSON output for CI systems.
blastguard analyse --base main --head HEAD --format json --output blast-radius.json
That keeps the tool useful outside GitHub too.
The real value is better review behaviour
The best version of this idea doesn't shame people for opening risky PRs. Sometimes a risky PR is necessary. A payment contract needs to change. A migration needs to ship. An auth policy needs fixing. The issue isn't that the PR has risk. The issue is when the risk is hidden.
A blast radius score makes the risk visible. It gives authors a chance to explain the change properly. It gives reviewers a better place to start. It gives teams a shared language for changes that deserve more care. Thats useful even if the score is rough. A pull request shouldn't be judged only by how many lines changed. It should be judged by how far the change can travel. And if a tiny PR can alter contracts, data, config, security, or runtime behaviour, it shouldn't be allowed to stroll through review pretending to be ordinary.




