Define the oneof_config, oneof_value, flatten, and flatten_prefix proto annotations and create shared annotation parsing and validation functions in internal/annotations.

Purpose: Establish the annotation infrastructure that all 4 generators will use for oneof discriminated unions and nested message flattening. This is the foundation for the rest of Phase 7. The annotations use a coupled OneofConfig message (containing both discriminator name and flatten flag) at the oneof level via google.protobuf.OneofOptions, plus per-variant oneof_value and per-field flatten/flatten_prefix via existing google.protobuf.FieldOptions.

Output: Proto extensions for oneof_config (50017), oneof_value (50018), flatten (50019), flatten_prefix (50020). Shared Go functions for parsing, detection, validation, and collision detection in internal/annotations package.

<execution_context> @/Users/sebastienmelki/.claude/get-shit-done/workflows/execute-plan.md @/Users/sebastienmelki/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/07-json-structural-transforms/07-RESEARCH.md @.planning/phases/07-json-structural-transforms/07-CONTEXT.md

Existing annotation patterns to follow exactly

@internal/annotations/nullable.go @internal/annotations/timestamp_format.go @internal/annotations/empty_behavior.go @internal/annotations/annotations_test.go @proto/sebuf/http/annotations.proto

Task 1: Add OneofConfig, oneof_value, flatten, and flatten_prefix to proto/sebuf/http/annotations.proto proto/sebuf/http/annotations.proto Add the following proto definitions to annotations.proto:
  1. OneofConfig message (place before the extend blocks, after existing enums):
// OneofConfig controls oneof serialization as a discriminated union.
// Applied to a oneof definition via (sebuf.http.oneof_config).
message OneofConfig {
  // The JSON field name for the discriminator (e.g., "type", "kind").
  // Required: if empty, the annotation is ignored.
  string discriminator = 1;
 
  // Whether to flatten variant message fields to the same level as the discriminator.
  // When true: variant's child fields are promoted to the parent object alongside the discriminator.
  // When false: variant stays nested under its field name with the discriminator alongside.
  // Requires all variant fields to be message types when true.
  bool flatten = 2;
}
  1. Add a new extend block for OneofOptions (after the existing extend blocks for FieldOptions and EnumValueOptions):
// Extension for oneof-level options
extend google.protobuf.OneofOptions {
  // Controls oneof serialization as a discriminated union.
  // When set, adds a discriminator field to the JSON output identifying which variant is set.
  optional OneofConfig oneof_config = 50017;
}
  1. Add to the existing FieldOptions extension block (after bytes_encoding at 50016):
  // Custom discriminator value for this oneof variant field.
  // When set, this value is used in the discriminator field instead of the proto field name.
  // Only valid on fields that are part of a oneof with oneof_config annotation.
  optional string oneof_value = 50018;
 
  // Flatten a nested message field, promoting its child fields to the parent level in JSON.
  // Only valid on singular message fields (not repeated, not map, not oneof variant).
  // When true: child message fields appear at the parent level (e.g., address.street becomes street).
  optional bool flatten = 50019;
 
  // Prefix to prepend to flattened field names to avoid collisions.
  // Only valid when flatten=true is also set.
  // Example: flatten_prefix="billing_" with child field "street" produces "billing_street" in JSON.
  optional string flatten_prefix = 50020;

Extension numbers continue the sequence: 50017 (oneof_config on OneofOptions), 50018-50020 (on FieldOptions, continuing from 50016).

After editing, regenerate Go code: cd proto && buf generate Run buf build to verify the proto compiles without errors:

cd proto && buf build

Regenerate Go code:

cd proto && buf generate

Verify the generated Go code compiles:

go build ./http/...
- OneofConfig message exists with discriminator (string) and flatten (bool) fields - oneof_config extension exists on OneofOptions (number 50017) - oneof_value extension exists on FieldOptions (number 50018) - flatten extension exists on FieldOptions (number 50019) - flatten_prefix extension exists on FieldOptions (number 50020) - Generated Go code in http/ package compiles - E_OneofConfig, E_OneofValue, E_Flatten, E_FlattenPrefix extension descriptors accessible
Task 2: Create internal/annotations/oneof_discriminator.go and flatten.go with tests internal/annotations/oneof_discriminator.go internal/annotations/flatten.go internal/annotations/annotations_test.go Create internal/annotations/oneof_discriminator.go:
package annotations
 
import (
    "fmt"
 
    "google.golang.org/protobuf/compiler/protogen"
    "google.golang.org/protobuf/proto"
    "google.golang.org/protobuf/types/descriptorpb"
 
    "github.com/SebastienMelki/sebuf/http"
)
 
// OneofDiscriminatorInfo holds parsed oneof discriminator configuration for a single oneof.
type OneofDiscriminatorInfo struct {
    Oneof         *protogen.Oneof
    Discriminator string            // JSON field name for discriminator (e.g., "type")
    Flatten       bool              // Whether to flatten variant fields to parent level
    Variants      []OneofVariant    // Resolved variant info
}
 
// OneofVariant holds information about a single oneof variant.
type OneofVariant struct {
    Field            *protogen.Field
    DiscriminatorVal string  // Value for this variant in the discriminator field
    IsMessage        bool    // Whether variant is a message type (required for flatten)
}
 
// GetOneofConfig returns the OneofConfig for a oneof, or nil if not annotated.
// Returns nil if discriminator is empty (annotation is treated as absent).
func GetOneofConfig(oneof *protogen.Oneof) *http.OneofConfig {
    options := oneof.Desc.Options()
    if options == nil {
        return nil
    }
    if !proto.HasExtension(options, http.E_OneofConfig) {
        return nil
    }
    ext := proto.GetExtension(options, http.E_OneofConfig)
    config, ok := ext.(*http.OneofConfig)
    if !ok || config == nil {
        return nil
    }
    if config.GetDiscriminator() == "" {
        return nil // No discriminator = no config
    }
    return config
}
 
// GetOneofVariantValue returns the custom discriminator value for a oneof variant field.
// Returns empty string if not set (caller should use the proto field name as default).
func GetOneofVariantValue(field *protogen.Field) string {
    options := field.Desc.Options()
    if options == nil {
        return ""
    }
    if !proto.HasExtension(options, http.E_OneofValue) {
        return ""
    }
    ext := proto.GetExtension(options, http.E_OneofValue)
    value, ok := ext.(string)
    if !ok {
        return ""
    }
    return value
}
 
// GetOneofDiscriminatorInfo resolves the full discriminator info for a oneof.
// Returns nil if the oneof has no oneof_config annotation.
// For each variant, uses oneof_value if set, otherwise proto field JSON name.
func GetOneofDiscriminatorInfo(oneof *protogen.Oneof) *OneofDiscriminatorInfo {
    config := GetOneofConfig(oneof)
    if config == nil {
        return nil
    }
 
    info := &OneofDiscriminatorInfo{
        Oneof:         oneof,
        Discriminator: config.GetDiscriminator(),
        Flatten:       config.GetFlatten(),
    }
 
    for _, field := range oneof.Fields {
        variant := OneofVariant{
            Field:     field,
            IsMessage: field.Message != nil,
        }
        // Use custom oneof_value if set, otherwise use proto field JSON name
        customValue := GetOneofVariantValue(field)
        if customValue != "" {
            variant.DiscriminatorVal = customValue
        } else {
            variant.DiscriminatorVal = string(field.Desc.Name())
        }
        info.Variants = append(info.Variants, variant)
    }
 
    return info
}
 
// HasOneofDiscriminator returns true if ANY oneof in the message has a discriminator annotation.
func HasOneofDiscriminator(message *protogen.Message) bool {
    for _, oneof := range message.Oneofs {
        if GetOneofConfig(oneof) != nil {
            return true
        }
    }
    return false
}
 
// ValidateOneofDiscriminator validates a oneof with discriminator annotation.
// Checks for:
// 1. Discriminator name collisions with parent message fields
// 2. When flatten=true: all variants must be message types
// 3. When flatten=true: variant child field names must not collide with parent fields or discriminator
func ValidateOneofDiscriminator(message *protogen.Message, oneof *protogen.Oneof, config *http.OneofConfig) error {
    discriminator := config.GetDiscriminator()
 
    // 1. Check discriminator vs parent message fields (non-oneof fields)
    for _, field := range message.Fields {
        if field.Oneof == oneof {
            continue // Skip oneof's own fields
        }
        if string(field.Desc.JSONName()) == discriminator {
            return fmt.Errorf(
                "oneof %s.%s: discriminator name %q collides with field %q (JSON: %q)",
                message.Desc.Name(), oneof.Desc.Name(), discriminator,
                field.Desc.Name(), field.Desc.JSONName(),
            )
        }
    }
 
    if config.GetFlatten() {
        // 2. All variants must be message types when flatten=true
        for _, field := range oneof.Fields {
            if field.Message == nil {
                return fmt.Errorf(
                    "oneof %s.%s with flatten=true: variant %q must be a message type (got scalar)",
                    message.Desc.Name(), oneof.Desc.Name(), field.Desc.Name(),
                )
            }
        }
 
        // 3. Collect parent JSON names (non-oneof fields + discriminator)
        reserved := make(map[string]string) // json_name -> source description
        reserved[discriminator] = "discriminator"
 
        for _, field := range message.Fields {
            if field.Oneof == oneof {
                continue
            }
            reserved[string(field.Desc.JSONName())] = fmt.Sprintf("parent field %q", field.Desc.Name())
        }
 
        // Check each variant's child fields against reserved names
        for _, variantField := range oneof.Fields {
            if variantField.Message == nil {
                continue // Already caught above
            }
            for _, childField := range variantField.Message.Fields {
                childJSON := string(childField.Desc.JSONName())
                if source, exists := reserved[childJSON]; exists {
                    return fmt.Errorf(
                        "oneof %s.%s with flatten=true: variant %q child field %q (JSON: %q) collides with %s",
                        message.Desc.Name(), oneof.Desc.Name(),
                        variantField.Desc.Name(), childField.Desc.Name(), childJSON, source,
                    )
                }
            }
        }
    }
 
    return nil
}

Create internal/annotations/flatten.go:

package annotations
 
import (
    "fmt"
 
    "google.golang.org/protobuf/compiler/protogen"
    "google.golang.org/protobuf/proto"
    "google.golang.org/protobuf/reflect/protoreflect"
 
    "github.com/SebastienMelki/sebuf/http"
)
 
// IsFlattenField returns true if the field has flatten=true annotation.
func IsFlattenField(field *protogen.Field) bool {
    options := field.Desc.Options()
    if options == nil {
        return false
    }
    if !proto.HasExtension(options, http.E_Flatten) {
        return false
    }
    ext := proto.GetExtension(options, http.E_Flatten)
    flatten, ok := ext.(bool)
    return ok && flatten
}
 
// GetFlattenPrefix returns the flatten prefix for a field, or empty string if not set.
func GetFlattenPrefix(field *protogen.Field) string {
    options := field.Desc.Options()
    if options == nil {
        return ""
    }
    if !proto.HasExtension(options, http.E_FlattenPrefix) {
        return ""
    }
    ext := proto.GetExtension(options, http.E_FlattenPrefix)
    prefix, ok := ext.(string)
    if !ok {
        return ""
    }
    return prefix
}
 
// HasFlattenFields returns true if any field in the message has a flatten annotation.
func HasFlattenFields(message *protogen.Message) bool {
    for _, field := range message.Fields {
        if IsFlattenField(field) {
            return true
        }
    }
    return false
}
 
// ValidateFlattenField validates that flatten is used correctly on a field.
// Returns error if:
// - flatten is on a repeated field
// - flatten is on a map field
// - flatten is on a non-message field
// - flatten is on a oneof variant field (oneof_flatten handles that)
// - flatten_prefix is set without flatten=true
func ValidateFlattenField(field *protogen.Field, messageName string) error {
    isFlatten := IsFlattenField(field)
    prefix := GetFlattenPrefix(field)
 
    // flatten_prefix without flatten=true is an error
    if !isFlatten && prefix != "" {
        return fmt.Errorf(
            "field %s.%s: flatten_prefix=%q set without flatten=true",
            messageName, field.Desc.Name(), prefix,
        )
    }
 
    if !isFlatten {
        return nil // No annotation, nothing to validate
    }
 
    // flatten on repeated fields
    if field.Desc.IsList() {
        return fmt.Errorf(
            "field %s.%s: flatten is not valid on repeated fields",
            messageName, field.Desc.Name(),
        )
    }
 
    // flatten on map fields
    if field.Desc.IsMap() {
        return fmt.Errorf(
            "field %s.%s: flatten is not valid on map fields",
            messageName, field.Desc.Name(),
        )
    }
 
    // flatten on non-message fields
    if field.Desc.Kind() != protoreflect.MessageKind {
        return fmt.Errorf(
            "field %s.%s: flatten is only valid on message fields (got %s)",
            messageName, field.Desc.Name(), field.Desc.Kind(),
        )
    }
 
    // flatten on oneof variant fields
    if field.Oneof != nil {
        return fmt.Errorf(
            "field %s.%s: flatten is not valid on oneof variant fields (use oneof_config.flatten instead)",
            messageName, field.Desc.Name(),
        )
    }
 
    return nil
}
 
// ValidateFlattenCollisions checks for field name collisions when multiple fields
// are flattened at the same level. Also checks flattened child fields against
// non-flattened parent fields.
// Returns error on first collision found.
func ValidateFlattenCollisions(message *protogen.Message) error {
    // Collect all JSON names from non-flattened parent fields
    usedNames := make(map[string]string) // json_name -> source description
 
    for _, field := range message.Fields {
        if IsFlattenField(field) {
            continue // We'll check flattened fields below
        }
        usedNames[string(field.Desc.JSONName())] = fmt.Sprintf("parent field %q", field.Desc.Name())
    }
 
    // Now check each flattened field's children against used names
    for _, field := range message.Fields {
        if !IsFlattenField(field) || field.Message == nil {
            continue
        }
 
        prefix := GetFlattenPrefix(field)
        for _, childField := range field.Message.Fields {
            flattenedName := prefix + string(childField.Desc.JSONName())
            if source, exists := usedNames[flattenedName]; exists {
                return fmt.Errorf(
                    "field %s.%s: flattened child %q (JSON: %q) collides with %s",
                    string(message.Desc.Name()), field.Desc.Name(),
                    childField.Desc.Name(), flattenedName, source,
                )
            }
            usedNames[flattenedName] = fmt.Sprintf("flattened from %s.%s", field.Desc.Name(), childField.Desc.Name())
        }
    }
 
    return nil
}

Add unit tests to annotations_test.go for:

  • Extension descriptor availability (E_OneofConfig, E_OneofValue, E_Flatten, E_FlattenPrefix)
  • OneofConfig message fields are correctly defined
  • IsFlattenField returns false for nil options
  • GetFlattenPrefix returns empty string for nil options
  • GetOneofVariantValue returns empty string for nil options
  • Error message format for validation errors (use descriptive test names)

Follow the established pattern in the existing annotations_test.go file (look at existing tests for E_Int64Encoding, E_Nullable, etc.).

go build ./internal/annotations/...
go test -v ./internal/annotations/...
make lint-fix

All tests pass, lint clean.

  • GetOneofConfig returns the OneofConfig for annotated oneofs, nil for unannotated
  • GetOneofVariantValue returns custom discriminator string or empty
  • GetOneofDiscriminatorInfo resolves full variant info with defaults
  • HasOneofDiscriminator detects annotated oneofs in a message
  • ValidateOneofDiscriminator catches discriminator-vs-parent collisions
  • ValidateOneofDiscriminator catches scalar variants with flatten=true
  • ValidateOneofDiscriminator catches variant-child-vs-parent collisions when flatten=true
  • IsFlattenField correctly detects flatten=true
  • GetFlattenPrefix returns configured prefix
  • HasFlattenFields detects any flatten annotations in a message
  • ValidateFlattenField rejects repeated, map, non-message, and oneof variant fields
  • ValidateFlattenField rejects flatten_prefix without flatten=true
  • ValidateFlattenCollisions detects sibling flattened field name collisions
  • All functions follow existing annotation package patterns
  • Unit tests cover extension descriptors and error scenarios
  • Lint passes
1. Proto annotations compile: `cd proto && buf build && buf generate` 2. Go package compiles: `go build ./http/... ./internal/annotations/...` 3. Tests pass: `go test -v ./internal/annotations/...` 4. Lint clean: `make lint-fix` reports no issues 5. Verify extension numbers don't conflict with existing (50016=bytes_encoding, latest) 6. All existing tests continue to pass: `go test ./...`

<success_criteria>

  • Proto files can import and use oneof_config, oneof_value, flatten, and flatten_prefix annotations
  • GetOneofConfig(oneof) returns the configured OneofConfig with discriminator and flatten
  • GetOneofVariantValue(field) returns custom variant discriminator value
  • GetOneofDiscriminatorInfo(oneof) resolves all variant info with defaults
  • IsFlattenField(field) detects flatten=true on message fields
  • GetFlattenPrefix(field) returns the configured prefix string
  • Validation functions detect all collision types at generation time
  • Validation rejects invalid annotation usage (wrong field types, invalid combinations)
  • All existing tests continue to pass (zero regression) </success_criteria>
After completion, create `.planning/phases/07-json-structural-transforms/07-01-SUMMARY.md`