Define timestamp_format and bytes_encoding proto annotations and create shared annotation parsing functions in internal/annotations.

Purpose: Establish the annotation infrastructure that all 4 generators will use to control JSON encoding of google.protobuf.Timestamp fields and bytes fields. This is the foundation for the rest of Phase 6.

Output: Proto extensions for timestamp_format (50015) and bytes_encoding (50016) annotations. Shared Go functions for parsing, detection, and validation 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/06-json-data-encoding/06-RESEARCH.md

Existing annotation patterns to follow exactly

@internal/annotations/int64_encoding.go @internal/annotations/nullable.go @internal/annotations/empty_behavior.go @proto/sebuf/http/annotations.proto

Task 1: Add timestamp_format and bytes_encoding annotations to proto/sebuf/http/annotations.proto proto/sebuf/http/annotations.proto Add the following proto definitions. Place enums before the FieldOptions extend block (following the pattern of Int64Encoding, EnumEncoding, EmptyBehavior enums).
  1. TimestampFormat enum:
// TimestampFormat controls how google.protobuf.Timestamp fields serialize to JSON.
enum TimestampFormat {
  // Follow protojson default (RFC 3339 string: "2024-01-15T09:30:00Z")
  TIMESTAMP_FORMAT_UNSPECIFIED = 0;
  // Explicit RFC 3339 (same as default but self-documenting)
  TIMESTAMP_FORMAT_RFC3339 = 1;
  // Unix seconds as integer: 1705312200 (nanos truncated)
  TIMESTAMP_FORMAT_UNIX_SECONDS = 2;
  // Unix milliseconds as integer: 1705312200000 (sub-millisecond nanos truncated)
  TIMESTAMP_FORMAT_UNIX_MILLIS = 3;
  // Date-only string: "2024-01-15" (time component dropped, lossy)
  TIMESTAMP_FORMAT_DATE = 4;
}
  1. BytesEncoding enum:
// BytesEncoding controls how bytes fields serialize to JSON.
enum BytesEncoding {
  // Follow protojson default (standard base64 with padding)
  BYTES_ENCODING_UNSPECIFIED = 0;
  // Standard base64 with padding (RFC 4648): "SGVsbG8="
  BYTES_ENCODING_BASE64 = 1;
  // Base64 without padding: "SGVsbG8"
  BYTES_ENCODING_BASE64_RAW = 2;
  // URL-safe base64 with padding: "SGVsbG8="
  BYTES_ENCODING_BASE64URL = 3;
  // URL-safe base64 without padding: "SGVsbG8"
  BYTES_ENCODING_BASE64URL_RAW = 4;
  // Hexadecimal encoding (lowercase): "48656c6c6f"
  BYTES_ENCODING_HEX = 5;
}
  1. Add to existing FieldOptions extension block (inside the extend google.protobuf.FieldOptions block):
  // Controls timestamp JSON encoding for this field.
  // Valid on: google.protobuf.Timestamp fields only.
  // Default: RFC3339 (protojson default).
  optional TimestampFormat timestamp_format = 50015;
 
  // Controls bytes JSON encoding for this field.
  // Valid on: bytes fields only.
  // Default: BASE64 (protojson default).
  optional BytesEncoding bytes_encoding = 50016;

Use extension numbers 50015 and 50016 (continuing from existing 50014 for empty_behavior).

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/...
- TimestampFormat enum exists with UNSPECIFIED, RFC3339, UNIX_SECONDS, UNIX_MILLIS, DATE values - BytesEncoding enum exists with UNSPECIFIED, BASE64, BASE64_RAW, BASE64URL, BASE64URL_RAW, HEX values - timestamp_format field extension exists (TimestampFormat, number 50015) - bytes_encoding field extension exists (BytesEncoding, number 50016) - Generated Go code in http/ package compiles - E_TimestampFormat and E_BytesEncoding extension descriptors accessible
Task 2: Create internal/annotations/timestamp_format.go and bytes_encoding.go with tests internal/annotations/timestamp_format.go internal/annotations/bytes_encoding.go internal/annotations/annotations_test.go Create timestamp_format.go following the established pattern from nullable.go and int64_encoding.go:
package annotations
 
import (
    "google.golang.org/protobuf/compiler/protogen"
    "google.golang.org/protobuf/proto"
    "google.golang.org/protobuf/reflect/protoreflect"
    "google.golang.org/protobuf/types/descriptorpb"
 
    "github.com/SebastienMelki/sebuf/http"
)
 
// TimestampFormatValidationError represents an error in timestamp_format annotation validation.
type TimestampFormatValidationError struct {
    MessageName string
    FieldName   string
    Reason      string
}
 
func (e *TimestampFormatValidationError) Error() string {
    return "invalid timestamp_format annotation on " + e.MessageName + "." + e.FieldName + ": " + e.Reason
}
 
// GetTimestampFormat returns the timestamp format for a field.
// Returns TIMESTAMP_FORMAT_UNSPECIFIED if not set (callers should use protojson default: RFC3339).
func GetTimestampFormat(field *protogen.Field) http.TimestampFormat {
    // Standard annotation extraction pattern: check options, get extension, type assert
    // Return UNSPECIFIED for nil/missing/wrong-type at each step
}
 
// HasTimestampFormatAnnotation returns true if the field has any non-default timestamp_format.
// Returns false for UNSPECIFIED and RFC3339 (both use protojson default behavior).
func HasTimestampFormatAnnotation(field *protogen.Field) bool {
    format := GetTimestampFormat(field)
    return format != http.TimestampFormat_TIMESTAMP_FORMAT_UNSPECIFIED &&
        format != http.TimestampFormat_TIMESTAMP_FORMAT_RFC3339
}
 
// IsTimestampField returns true if the field is a google.protobuf.Timestamp message type.
// Uses field.Desc.Kind() == MessageKind && field.Message.Desc.FullName() == "google.protobuf.Timestamp"
func IsTimestampField(field *protogen.Field) bool {
    return field.Desc.Kind() == protoreflect.MessageKind &&
        field.Message != nil &&
        field.Message.Desc.FullName() == "google.protobuf.Timestamp"
}
 
// ValidateTimestampFormatAnnotation checks if timestamp_format is valid for a field.
// Returns error if used on non-Timestamp fields.
func ValidateTimestampFormatAnnotation(field *protogen.Field, messageName string) error {
    format := GetTimestampFormat(field)
    if format == http.TimestampFormat_TIMESTAMP_FORMAT_UNSPECIFIED {
        return nil // No annotation, nothing to validate
    }
    if !IsTimestampField(field) {
        return &TimestampFormatValidationError{...}
    }
    return nil
}

Create bytes_encoding.go following the same pattern:

package annotations
 
// BytesEncodingValidationError represents an error in bytes_encoding annotation validation.
type BytesEncodingValidationError struct {
    MessageName string
    FieldName   string
    Reason      string
}
 
func (e *BytesEncodingValidationError) Error() string {
    return "invalid bytes_encoding annotation on " + e.MessageName + "." + e.FieldName + ": " + e.Reason
}
 
// GetBytesEncoding returns the bytes encoding for a field.
// Returns BYTES_ENCODING_UNSPECIFIED if not set (callers should use protojson default: BASE64).
func GetBytesEncoding(field *protogen.Field) http.BytesEncoding {
    // Same extraction pattern as GetTimestampFormat
}
 
// HasBytesEncodingAnnotation returns true if the field has any non-default bytes_encoding.
// Returns false for UNSPECIFIED and BASE64 (both use protojson default behavior).
func HasBytesEncodingAnnotation(field *protogen.Field) bool {
    encoding := GetBytesEncoding(field)
    return encoding != http.BytesEncoding_BYTES_ENCODING_UNSPECIFIED &&
        encoding != http.BytesEncoding_BYTES_ENCODING_BASE64
}
 
// ValidateBytesEncodingAnnotation checks if bytes_encoding is valid for a field.
// Returns error if used on non-bytes fields.
func ValidateBytesEncodingAnnotation(field *protogen.Field, messageName string) error {
    encoding := GetBytesEncoding(field)
    if encoding == http.BytesEncoding_BYTES_ENCODING_UNSPECIFIED {
        return nil
    }
    if field.Desc.Kind() != protoreflect.BytesKind {
        return &BytesEncodingValidationError{...}
    }
    return nil
}

Add unit tests to annotations_test.go for:

  • Extension descriptor availability (E_TimestampFormat, E_BytesEncoding)
  • TimestampFormat and BytesEncoding enum values are correctly defined
  • TimestampFormatValidationError and BytesEncodingValidationError error message format
  • IsTimestampField helper returns expected results
go build ./internal/annotations/...
go test -v ./internal/annotations/...
make lint-fix

All tests pass, lint clean.

  • GetTimestampFormat function exists and returns correct TimestampFormat enum
  • HasTimestampFormatAnnotation returns true for non-default, non-RFC3339 formats
  • IsTimestampField correctly detects google.protobuf.Timestamp fields
  • ValidateTimestampFormatAnnotation rejects non-Timestamp fields
  • GetBytesEncoding function exists and returns correct BytesEncoding enum
  • HasBytesEncodingAnnotation returns true for non-default, non-BASE64 encodings
  • ValidateBytesEncodingAnnotation rejects non-bytes fields
  • All error types have Error() methods
  • All functions follow existing annotation package patterns
  • Unit tests cover extension descriptors, enum values, and error types
  • 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 (50013=nullable, 50014=empty_behavior) 6. All existing tests continue to pass: `go test ./...`

<success_criteria>

  • Proto files can import and use timestamp_format and bytes_encoding annotations
  • GetTimestampFormat(field) returns the configured TimestampFormat enum value
  • GetBytesEncoding(field) returns the configured BytesEncoding enum value
  • IsTimestampField(field) detects google.protobuf.Timestamp by FullName
  • Validation functions reject invalid annotation usage (wrong field types)
  • HasTimestampFormatAnnotation and HasBytesEncodingAnnotation correctly exclude default values
  • All existing tests continue to pass (zero regression) </success_criteria>
After completion, create `.planning/phases/06-json-data-encoding/06-01-SUMMARY.md`