Filed by Claude (via Claude Code) on behalf of @SebastienMelki.
Bug
(sebuf.http.enum_value) correctly pins every named enum variant into the OpenAPI document, but the proto3 zero value (*_UNSPECIFIED = 0) is emitted alongside them as a legal member of the enum array. Since the zero value is an artifact of proto3 requiring a zero slot — not a value the API ever accepts or emits — every generated enum advertises one phantom member.
Worse, the generated Go server accepts it on input, so this is not purely a documentation defect.
Reproduction
syntax = "proto3";
package demo.v1;
import "sebuf/http/annotations.proto";
enum Priority {
PRIORITY_UNSPECIFIED = 0; // proto3 zero slot
PRIORITY_LOW = 1 [(sebuf.http.enum_value) = "low"];
PRIORITY_HIGH = 2 [(sebuf.http.enum_value) = "high"];
}
message GetTaskRequest { string id = 1; }
message GetTaskResponse { Priority priority = 1; }
service TaskService {
rpc GetTask(GetTaskRequest) returns (GetTaskResponse) {
option (sebuf.http.config) = {
path: "/tasks/{id}"
method: HTTP_METHOD_GET
};
}
}
Generated OpenAPI:
priority:
type: string
enum:
- PRIORITY_UNSPECIFIED # <-- phantom
- low
- high
Expected:
priority:
type: string
enum: [low, high]
The server also accepts the phantom value
The generated UnmarshalJSONSebuf rewrites known custom strings and lets anything else fall through to protojson, which happily accepts proto enum names:
// *_enum_field_encoding.pb.go
if e, ok := priorityFromJSON[s]; ok {
raw[k], _ = json.Marshal(e.String())
}
// no match -> value passed through unchanged -> protojson accepts "PRIORITY_UNSPECIFIED"
So {"priority": "PRIORITY_UNSPECIFIED"} is accepted by the sebuf server while the upstream API being modelled rejects it. A client generated from this document can construct a request that only the sebuf server tolerates.
Root cause
internal/openapiv3/types.go iterates all enum values with no zero-value filter, in both the string branch (~line 288) and the ENUM_ENCODING_NUMBER branch (~line 267):
for _, value := range field.Enum.Values {
customValue := annotations.GetEnumValueMapping(value)
enumValue := customValue
if enumValue == "" {
enumValue = string(value.Desc.Name())
}
schema.Enum = append(schema.Enum, &yaml.Node{Kind: yaml.ScalarNode, Value: enumValue})
}
Note the fallback: an unannotated value emits its raw proto name. For the zero slot that is almost always wrong, because the whole point of enum_value is that the wire vocabulary differs from the proto vocabulary.
Why this is a problem
- Codegen clients get a dead enum member. Every generated
enum/union carries a case the server never returns. In TypeScript and Swift this forces an unreachable branch in every exhaustive switch.
- It corrupts discriminated unions. Where the zero-value field is a union discriminator, the phantom member becomes a phantom variant, and consumers cannot tell it is not a real state.
- Strict validators accept invalid payloads. A request validated against this document passes with the phantom value and is then rejected by the upstream service — the failure surfaces at integration time, not at validation time.
- The server disagrees with the API it is modelling (see above), which defeats the point of using sebuf to describe an existing wire format.
Suggested fix
Skip the zero value when emitting enum, unless it is explicitly annotated:
for _, value := range field.Enum.Values {
customValue := annotations.GetEnumValueMapping(value)
if value.Desc.Number() == 0 && customValue == "" {
continue // proto3 zero slot is not part of the wire vocabulary
}
...
}
This keeps the escape hatch: FOO_UNSPECIFIED = 0 [(sebuf.http.enum_value) = "unknown"] still emits "unknown", which is the right behaviour for APIs that genuinely have an unknown state.
Two follow-ups worth considering in the same pass:
Since a zero value with no annotation cannot round-trip today anyway, removing it is a correctness fix rather than a breaking change — but flagging it for a version note.
Bug
(sebuf.http.enum_value)correctly pins every named enum variant into the OpenAPI document, but the proto3 zero value (*_UNSPECIFIED = 0) is emitted alongside them as a legal member of theenumarray. Since the zero value is an artifact of proto3 requiring a zero slot — not a value the API ever accepts or emits — every generated enum advertises one phantom member.Worse, the generated Go server accepts it on input, so this is not purely a documentation defect.
Reproduction
Generated OpenAPI:
Expected:
The server also accepts the phantom value
The generated
UnmarshalJSONSebufrewrites known custom strings and lets anything else fall through toprotojson, which happily accepts proto enum names:So
{"priority": "PRIORITY_UNSPECIFIED"}is accepted by the sebuf server while the upstream API being modelled rejects it. A client generated from this document can construct a request that only the sebuf server tolerates.Root cause
internal/openapiv3/types.goiterates all enum values with no zero-value filter, in both the string branch (~line 288) and theENUM_ENCODING_NUMBERbranch (~line 267):Note the fallback: an unannotated value emits its raw proto name. For the zero slot that is almost always wrong, because the whole point of
enum_valueis that the wire vocabulary differs from the proto vocabulary.Why this is a problem
enum/union carries a case the server never returns. In TypeScript and Swift this forces an unreachable branch in every exhaustiveswitch.Suggested fix
Skip the zero value when emitting
enum, unless it is explicitly annotated:This keeps the escape hatch:
FOO_UNSPECIFIED = 0 [(sebuf.http.enum_value) = "unknown"]still emits"unknown", which is the right behaviour for APIs that genuinely have an unknown state.Two follow-ups worth considering in the same pass:
UnmarshalJSONSebufreject unmapped strings for a field that carriesenum_value, instead of falling through toprotojson. Falling through re-admits the entire proto-name vocabulary, not just the zero value.Since a zero value with no annotation cannot round-trip today anyway, removing it is a correctness fix rather than a breaking change — but flagging it for a version note.