DEV Community

Dariusz Newecki
Dariusz Newecki

Posted on

When One Enum Is Secretly Two

I was one commit away from a bug that would never have thrown an error.

My system keeps every closed vocabulary in a single file — one source of truth for "here are the legal values for this field." One of those vocabularies described filesystem operations: read, create, modify, delete. Clean, small, obvious. Two different parts of the system were going to read it.

The first part is authorization. Every capability in the system declares a filesystem profile — what it's permitted to do. This worker may modify files but may not delete them. For that, the distinctions that matter live on the write axis: create, modify, and delete are three different permissions you might grant or withhold independently. Reading? Reading is just read. One bucket. The profile doesn't need to slice it finer.

The second part is audit. A taxonomy classifies every filesystem call the code makes, so a completeness check can prove that no category of access slips by unaccounted for. For that, the distinctions that matter live on the read axis: Path.read_text reads a file, Path.glob enumerates a directory, yaml.safe_load(path) parses a protected config off disk. Those are three different audit subjects. Writing? For the audit's purposes, writing collapses to a single write — because the policy it enforces is shaped like this namespace forbids the write class, full stop.

Look at the inversion. Authorization splits writes and collapses reads. Audit splits reads and collapses writes. Same domain — filesystem operations — sliced along perpendicular axes, because the two readers are answering different questions.

And they were about to share one enum.

The race that nearly buried it

The reason they were about to share it is almost funny. Two design decisions, written months apart, both declared they'd use "the filesystem operation vocabulary." A clause settled the overlap: whichever one gets implemented first creates the list; the second just uses what's already there. A materialization race — a data race, but for a vocabulary decision spread across two documents.

The authorization side shipped first and wrote [read, create, modify, delete]: the write axis. Which meant the audit side, when I finally got to it, would have inherited a vocabulary with no word for traverse and no word for parse. It would have had to lie in the only language it was given.

The tell

Here's the moment it stopped being a style preference and became undeniable.

Take Path.glob. Under the authorization vocabulary, the most honest label for it is read — it doesn't mutate anything. Under the audit vocabulary, read is flatly wrong; it's traverse, and that distinction is the entire point, because "this code enumerated a protected directory" is a different finding than "this code read a single file."

Same call. Two correct answers. The enum can only hold one.

That is the signature of one enum doing two jobs: a single concrete value that belongs in different buckets depending on who's asking. There is no naming fix for that. read isn't badly named. It's being asked to mean two things at once.

Why DRY was lying to me

The pull toward one enum was DRY, and DRY is usually right, which is exactly what makes this trap good. One vocabulary, one place, both consumers referencing it — that looks like the discipline you're supposed to practice. It feels like hygiene.

But DRY is about not duplicating knowledge, not about not duplicating shape. Two vocabularies that happen to overlap in spelling are not a repeated fact. They're two separate decisions that rhyme. Merging them doesn't remove duplication — it manufactures coupling, binding two things that change for different reasons. The authorization vocabulary changes when the permission model changes. The audit vocabulary changes when the set of call-classes you care about changes. Different forces, different cadence, different owners.

That's the Single Responsibility Principle, except aimed at a data type instead of a class: if two independent forces can each demand an edit, you're holding two things.

One spelling, one meaning

The rule I'd actually broken has a cleaner statement than any of this: one spelling, one meaning.

I had one spelling — fs_operation_class — quietly carrying two meanings. And that's the same defect as the version everybody already polices: two spellings for one meaning, userId in one file and user_id in the next. We catch the synonym on sight; linters scream about it.

The homonym hides. One word, two meanings, nothing visibly duplicated. It doesn't look like a smell. It looks like economy.

How I spot a fused enum now

Any one of these is a yellow flag. Two of them is a decision:

  • Two subsystems both branch on the enum, for unrelated decisions.
  • You keep wanting to add a value "just for" one consumer that's meaningless to the other.
  • A single concrete value belongs in different buckets depending on which consumer is reading it.
  • The description has to say "for X this means…, for Y this means…"

That last one is the confession. The moment your docstring needs the word "for," you have two enums.

The fix, and the line

The fix wasn't clever. Two enums.

One keeps [read, create, modify, delete] for authorization. A new one carries [read, traverse, parse, write, neutral] for audit. Their overlap is exactly one value — read — and even that is a coincidence of spelling, not a shared decision: it's just the single operation that means the same thing under both questions. Each vocabulary is now free to move along its own axis without dragging the other behind it.

The sentence I wrote into the decision record, the one I'll reuse for the rest of my life:

When a unification claim doesn't survive the material differences between two surfaces, the unification was the bug — not either surface.

Why this one kept me up

A normal refactor earns a shrug. This one didn't, and here's why.

Nothing about the fused enum would have crashed. In a fail-closed system, that's the nightmare case: it doesn't fail closed, because it doesn't fail at all. It validates fine. It loads fine. It quietly hands one of its two readers a lossy answer, forever.

The authorization side would have been correct. The audit side would have cheerfully reported all reads accounted for — while folding traversals and parses into a read bucket it could no longer tell apart. A completeness check that's complete only because it went blind.

The worst bugs in a governance system aren't the ones that throw. They're the ones that pass.

One enum, two meanings. Go check your docstrings for the word "for."


This is from CORE, an open-source constitutional governance runtime for AI-generated code. The decision above is ADR-080; the two enums live in the repo if you want to call my bluff: github.com/DariuszNewecki/CORE

Top comments (0)