DEV Community

D_SECURITY
D_SECURITY

Posted on AI-assisted

When "Skipped" Means Two Different Things in Rust

While working on safe-migrate, a Rust tool for analyzing PostgreSQL migrations, I ran into a problem with one of the types in the analysis engine.

It looked small, but the distinction mattered for the conclusions the rest of the engine could draw.

The problem with "Skipped"

The mutation result type originally had a "Skipped" variant, alongside outcomes such as "Applied", "NotExecuted", and "Conflict".

The problem was that "Skipped" could represent two different situations:

  • An operation genuinely made no change.
  • The analyzer couldn't determine the operation's effect because it didn't have enough information.

Those aren't equivalent.

In the first case, the analyzer has a meaningful result: nothing changed. In the second, it doesn't know enough to make that claim.

If both situations look the same to downstream code, it's easy to lose track of what the analyzer actually knows.

Making the distinction explicit

In v0.11.0, I split that old outcome into "NoOp" and "Unresolved(EvidenceCode)".

The relevant part of the new model looks like this:

enum MutationResult {
    Applied,
    NoOp,
    NotExecuted,
    Unresolved(EvidenceCode),
    Conflict(ConflictKind),
}
Enter fullscreen mode Exit fullscreen mode

The important change isn't the number of variants. It's that an unresolved operation carries an evidence code explaining why the analyzer couldn't determine its effect.

That gives the rest of the engine a clearer distinction between a known result and an unknown one.

It also changes how rules are evaluated

Making the state explicit was only part of the work. The rule engine needed to respond to that uncertainty.

Not every rule needs the same information. Some checks can identify a problem from the SQL statement and the known pre-migration state. Others depend on the resulting state being modeled accurately.

I added an opt-in for rules that can still evaluate when the state is unresolved. Rules that can't safely run are reported as "NotEvaluated", with the relevant context explaining what was missing.

There's also a safety consequence: when a Tier 1 check cannot run, the overall verdict is capped at "CAUTIOUS" rather than "SAFE".

I don't want missing information to look like evidence that a migration is safe.

Where should this boundary live?

This change made the analysis model more explicit, but it also made me think about where uncertainty belongs in a system like this.

Putting "Unresolved" in the mutation result means every consumer has to account for that possibility. That adds complexity to the type and to the code handling it.

Keeping uncertainty separate might make individual mutation results simpler, but it could also make it easier for callers to overlook the distinction between “nothing changed” and “we couldn't determine what changed.”

For this analyzer, I preferred making the distinction explicit and letting rules state whether they can work with unresolved state.

How would you model this in Rust? Would you represent uncertainty directly in the state-transition result, or keep the transition result simpler and track uncertainty in a separate analysis layer?

I'm particularly interested in how you'd draw that boundary when some checks can still produce useful findings from partial information, while others must not claim a clean result.

Relevant implementation:

Top comments (0)