Error Code != Enum
Your Error Code is not an enumeration, it is its own object.
Enumeration (and constants in general) are great ways to encode the initialisation value of an Error Code.
They are horrible as the storage unit itself. Because as you point out a receiver defining the error code as an enumeration dictates what are "legal" error codes, even though it has the least information about it.
Identity, Category, and Semantics
So what do you mean when you say Error Code?
- Is it a unique identifier that describes one very specific failure state?
- Is it a categorical identifier that describes a class of failures?
- Is it an instruction that describes how the receiver of the error should respond?
- Or some combination of the above?
The only place that knows the error code, is the place that generates it. (Hopefully corroborated by judicious unit tests.)
Everywhere else, be it in the same application or afar, does not care about the exact value. What they care about are capabilities (serialisation) and questions (network issue?, can I try again?).
The other issue you have hit upon is that you are distributing the same responsibility across several actors.
This is no different to any other kind of message passed between systems. I recommend defining the protocol of each interface. Each system that works through that interface has to map to and from that protocol in whatever way makes sense.
This is painful, but they are separate pieces of software. Increasing the coupling between them by forcing a shared notion of error code (aside from what is defined in the interface protocol between them) makes both pieces of software more brittle.
Brittle - not just because they must use the same version of the library - but because they must synchronise appropriate uses of unique identifying error codes, and fully agreeing to the categories and semantics of error codes in general.
You could ameliorate the brittleness by insisting on backward compatibility, but that is its own cage of snakes.