Phase 2: Foundation - Shared Annotations - Research
Researched: 2026-02-05 Domain: Go code refactoring -- extracting duplicated annotation parsing across 4 protoc plugin generators Confidence: HIGH
Summary#
This research examines the exact duplication across the four generators (httpgen, clientgen, tsclientgen, openapiv3) to determine what belongs in a shared internal/annotations package and how to design it for zero behavior change.
The duplication is confirmed at exactly 1,289 lines across the 4 annotation files, with additional annotation-related code in openapiv3/types.go (~80 lines of hasUnwrapAnnotation, getUnwrapField, getFieldExamples). All four generators follow the same pattern: extract protobuf extension options using proto.GetExtension, cast to the appropriate type, and return a domain struct. The key challenge is that each generator uses slightly different domain structs (QueryParam has different fields per generator, ServiceConfigImpl vs ServiceHTTPConfig naming, HTTP methods are uppercase in 3 generators but lowercase in openapiv3).
The serialization audit is narrowly scoped: the encoding/json import in httpgen's generated binding code is legitimate (used for json.Marshaler/json.Unmarshaler interface checks on unwrap types), while protojson is correctly used for actual proto message serialization. The unwrap code in unwrap.go correctly uses both encoding/json for structural marshaling and protojson for proto message marshaling. No incorrect encoding/json usage for proto message serialization was found.
Primary recommendation: Create internal/annotations/ with one file per annotation type following GetXxx(*protogen.Type) *ReturnType convention. Use transparent structs. Accept *protogen.Method, *protogen.Service, *protogen.Message, *protogen.Field (protogen types, not protoreflect). Return the proto types directly for headers ([]*http.Header) since all 4 generators already use them identically.
Standard Stack#
No new libraries needed. This is a pure refactoring within the existing codebase.
Core Dependencies (Already in go.mod)#
| Library | Version | Purpose | Role in Shared Package |
|---|---|---|---|
google.golang.org/protobuf/compiler/protogen |
v1.36.11 | protoc plugin framework | Input types for all Get functions |
google.golang.org/protobuf/proto |
v1.36.11 | proto extension access | proto.GetExtension calls |
google.golang.org/protobuf/types/descriptorpb |
v1.36.11 | descriptor option types | Type assertions for options |
github.com/SebastienMelki/sebuf/http |
(local) | Annotation proto types | Extension variables and message types |
Not Needed in Shared Package#
| Library | Why Not |
|---|---|
github.com/pb33f/libopenapi |
OpenAPI-specific, stays in openapiv3 |
buf.build/.../protovalidate |
Validation-specific, stays in openapiv3 and httpgen |
Architecture Patterns#
Recommended Package Structure#
internal/annotations/
http_config.go # GetMethodHTTPConfig, GetServiceBasePath, HTTPConfig, ServiceConfig structs
headers.go # GetServiceHeaders, GetMethodHeaders, CombineHeaders
query.go # GetQueryParams, QueryParamBase struct
unwrap.go # HasUnwrapAnnotation, GetUnwrapField, UnwrapFieldInfo, UnwrapValidationError
field_examples.go # GetFieldExamples
path.go # ExtractPathParams, BuildHTTPPath, pathParamRegex
method.go # HTTPMethodToString (uppercase), HTTPMethodToLower (for OpenAPI)
doc.go # Package documentation
Pattern: Convention-Based Extensibility#
Each file follows the same convention:
- A
Get-prefixed exported function that accepts a protogen type - Returns a struct or proto type
- Handles nil/missing options gracefully (returns nil, not error)
- One file = one annotation concept
When Phases 4-7 add new annotations, the pattern is: create a new .go file, define a GetXxx function following the same shape. No interfaces to implement, no registration -- just files with functions.
Pattern: Struct Design for QueryParam#
The QueryParam struct varies across generators:
| Field | httpgen | clientgen | tsclientgen | openapiv3 |
|---|---|---|---|---|
| FieldName | yes | yes | yes | yes |
| FieldGoName | yes | yes | no | no |
| FieldJSONName | no | no | yes | no |
| ParamName | yes | yes | yes | yes |
| Required | yes | yes | yes | yes |
| FieldKind | no | yes | yes | no |
| Field (*protogen.Field) | no | no | no | yes |
Recommendation: Use a base struct with all fields that any generator needs. Each generator already selects what it uses. The shared struct should include ALL fields -- generators ignore what they don't need. Include the raw *protogen.Field reference so any generator can derive whatever it needs.
// QueryParam represents a query parameter extracted from a proto field annotation.
type QueryParam struct {
FieldName string // Proto field name (e.g., "page_number")
FieldGoName string // Go field name (e.g., "PageNumber")
FieldJSONName string // JSON field name (e.g., "pageNumber")
ParamName string // Query parameter name from annotation
Required bool // Whether this parameter is required
FieldKind string // Proto field kind (e.g., "string", "int32")
Field *protogen.Field // Raw field reference for generator-specific needs
}Pattern: HTTP Method Case Handling#
Three generators use UPPERCASE ("GET", "POST"), openapiv3 uses lowercase ("get", "post"). The shared package should:
- Store as uppercase (standard HTTP convention, matches
net/httpconstants) - Provide
HTTPMethodToString(m http.HttpMethod) stringreturning uppercase - Openapiv3 calls
strings.ToLower()on the result (1 line in caller, not worth a second function)
Actually, on reflection, providing both avoids the caller needing to know this. A simple helper:
func HTTPMethodToString(m http.HttpMethod) string { ... } // "GET", "POST", etc.
func HTTPMethodToLower(m http.HttpMethod) string { ... } // "get", "post", etc. (for OpenAPI)Pattern: BuildHTTPPath Consolidation#
Three slightly different path-building implementations exist:
- httpgen: Custom logic in
generator.golines 770-786 (usescamelToSnakefor default paths, not relevant to shared) - clientgen: Inline in
buildRPCMethodConfig(lines 425-433) --TrimSuffix+HasPrefix+ concatenation - tsclientgen: Identical to clientgen (lines 281-288)
- openapiv3:
buildHTTPPathfunction (lines 182-200) withensureLeadingSlashhelper
The clientgen and tsclientgen path-building logic is identical. The openapiv3 version is slightly more defensive. Recommendation: Extract BuildHTTPPath(basePath, methodPath string) string using the openapiv3 implementation (most robust), but keep generator-specific default-path logic (like camelToSnake fallbacks) in each generator.
Anti-Patterns to Avoid#
- Generator-specific logic in shared package: The shared package must ONLY contain annotation extraction. Generator-specific transformations (like openapiv3's
convertHeadersToParametersormapHeaderTypeToOpenAPI) stay in their generators. - Breaking the function signature convention: All exported functions should accept protogen types and return simple types. No
interface{}or complex option patterns. - Abstracting too much: Do NOT create an "annotation registry" or "annotation interface." Keep it as simple functions.
Don't Hand-Roll#
Problems with existing solutions that should be used:
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Proto extension extraction | Custom reflection | proto.GetExtension |
Already used, battle-tested |
| Path param regex | Per-generator regex | Single compiled pathParamRegex |
Identical regex in all 4 generators |
| Header merging | N/A | CombineHeaders in shared |
Currently only in openapiv3, but all generators will need it for consistency |
Key insight: The annotation extraction pattern is identical across all 4 generators -- the only differences are in the return types. The shared package eliminates copy-paste drift.
Common Pitfalls#
Pitfall 1: Breaking the function call graph#
What goes wrong: Renaming a function or changing its signature breaks callers silently at first (since each generator had its own copy).
Why it happens: The migration changes from package-local calls to cross-package calls.
How to avoid: After each generator migration, run make lint-fix && go build ./... before running tests. The compiler catches all call-site mismatches.
Warning signs: Build failures mentioning undefined functions.
Pitfall 2: QueryParam struct field mismatch#
What goes wrong: A generator accesses a field that existed in its local struct but wasn't populated by the shared function.
Why it happens: The four QueryParam structs have different fields. If the shared function doesn't populate all fields, generators break.
How to avoid: The shared GetQueryParams must populate ALL fields in the unified struct: FieldName, FieldGoName, FieldJSONName, ParamName, Required, FieldKind, and Field. Each generator uses a subset.
Warning signs: Empty strings or zero values where generators expect data.
Pitfall 3: OpenAPI lowercase method mismatch#
What goes wrong: OpenAPI generator starts producing uppercase HTTP methods ("GET" instead of "get") in YAML output.
Why it happens: The shared HTTPMethodToString returns uppercase, and the openapiv3 generator forgets to lowercase.
How to avoid: Either provide HTTPMethodToLower in the shared package, or ensure the openapiv3 migration explicitly lowercases. Golden file tests will catch this.
Warning signs: Golden file diff showing case changes in OpenAPI output.
Pitfall 4: Exporting previously-unexported types#
What goes wrong: The shared package must export types that were package-private in each generator.
Why it happens: Functions like getMethodHTTPConfig (lowercase) become GetMethodHTTPConfig (uppercase) in the shared package.
How to avoid: This is expected and correct. Update all call sites from getMethodHTTPConfig(...) to annotations.GetMethodHTTPConfig(...).
Warning signs: None -- this is the normal migration pattern.
Pitfall 5: Import cycle risk#
What goes wrong: If the shared package accidentally imports a generator package, Go refuses to compile.
Why it happens: Shared package depends only on protogen and sebuf/http. This direction is safe. The risk is accidentally making a generator depend on another generator.
How to avoid: The shared package imports ONLY: google.golang.org/protobuf/*, github.com/SebastienMelki/sebuf/http, and stdlib. Nothing from internal/httpgen, internal/clientgen, etc.
Warning signs: Import cycle compiler errors.
Pitfall 6: Test files referencing private annotation functions#
What goes wrong: Test files in generators (e.g., annotations_test.go) call local package functions. After migration, those functions no longer exist locally.
Why it happens: Tests for httpMethodToString and extractPathParams are in httpgen/annotations_test.go.
How to avoid: Move annotation-specific tests to internal/annotations/ test files. Generator test files that tested annotation parsing need to either: (a) import from annotations package, or (b) test the generator's usage through golden file tests (which already exist).
Warning signs: Test compilation failures after deleting old annotation code.
Pitfall 7: openapiv3/types.go contains annotation code#
What goes wrong: Migrating only http_annotations.go misses the hasUnwrapAnnotation, getUnwrapField, and getFieldExamples duplicated in openapiv3/types.go.
Why it happens: These annotation functions were placed in the types file instead of the annotations file.
How to avoid: Audit types.go (lines 242-298) and migrate those functions too. The non-annotation functions in types.go (like convertField, convertScalarField, convertMapField) stay in openapiv3.
Warning signs: Duplicate function definitions if both shared package and types.go define hasUnwrapAnnotation.
Code Examples#
Shared HTTPConfig extraction (canonical pattern)#
// internal/annotations/http_config.go
package annotations
import (
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/descriptorpb"
"github.com/SebastienMelki/sebuf/http"
)
// HTTPConfig represents the HTTP configuration for a method.
type HTTPConfig struct {
Path string
Method string // "GET", "POST", "PUT", "DELETE", "PATCH" (uppercase)
PathParams []string // Path variable names extracted from path
}
// ServiceConfig represents the HTTP configuration for a service.
type ServiceConfig struct {
BasePath string
}
// GetMethodHTTPConfig extracts HTTP configuration from method options.
func GetMethodHTTPConfig(method *protogen.Method) *HTTPConfig {
options := method.Desc.Options()
if options == nil {
return nil
}
methodOptions, ok := options.(*descriptorpb.MethodOptions)
if !ok {
return nil
}
ext := proto.GetExtension(methodOptions, http.E_Config)
if ext == nil {
return nil
}
httpConfig, ok := ext.(*http.HttpConfig)
if !ok || httpConfig == nil {
return nil
}
path := httpConfig.GetPath()
return &HTTPConfig{
Path: path,
Method: HTTPMethodToString(httpConfig.GetMethod()),
PathParams: ExtractPathParams(path),
}
}
// GetServiceConfig extracts HTTP configuration from service options.
func GetServiceConfig(service *protogen.Service) *ServiceConfig {
options := service.Desc.Options()
if options == nil {
return nil
}
serviceOptions, ok := options.(*descriptorpb.ServiceOptions)
if !ok {
return nil
}
ext := proto.GetExtension(serviceOptions, http.E_ServiceConfig)
if ext == nil {
return nil
}
serviceConfig, ok := ext.(*http.ServiceConfig)
if !ok || serviceConfig == nil {
return nil
}
return &ServiceConfig{
BasePath: serviceConfig.GetBasePath(),
}
}Shared QueryParam extraction (unified struct)#
// internal/annotations/query.go
package annotations
import (
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/descriptorpb"
"github.com/SebastienMelki/sebuf/http"
)
// QueryParam represents a query parameter configuration extracted from a field.
type QueryParam struct {
FieldName string // Proto field name (e.g., "page_number")
FieldGoName string // Go field name (e.g., "PageNumber")
FieldJSONName string // JSON field name (e.g., "pageNumber")
ParamName string // Query parameter name from annotation
Required bool // Whether this parameter is required
FieldKind string // Proto field kind (e.g., "string", "int32")
Field *protogen.Field // Raw field reference
}
// GetQueryParams extracts query parameter configurations from message fields.
func GetQueryParams(message *protogen.Message) []QueryParam {
var params []QueryParam
for _, field := range message.Fields {
options := field.Desc.Options()
if options == nil {
continue
}
fieldOptions, ok := options.(*descriptorpb.FieldOptions)
if !ok {
continue
}
ext := proto.GetExtension(fieldOptions, http.E_Query)
if ext == nil {
continue
}
queryConfig, ok := ext.(*http.QueryConfig)
if !ok || queryConfig == nil {
continue
}
paramName := queryConfig.GetName()
if paramName == "" {
paramName = string(field.Desc.Name())
}
params = append(params, QueryParam{
FieldName: string(field.Desc.Name()),
FieldGoName: field.GoName,
FieldJSONName: field.Desc.JSONName(),
ParamName: paramName,
Required: queryConfig.GetRequired(),
FieldKind: field.Desc.Kind().String(),
Field: field,
})
}
return params
}Migration pattern for a generator (httpgen example)#
// Before (internal/httpgen/generator.go):
config := getMethodHTTPConfig(method)
// After (internal/httpgen/generator.go):
import "github.com/SebastienMelki/sebuf/internal/annotations"
config := annotations.GetMethodHTTPConfig(method)
// config.Path, config.Method, config.PathParams -- same fields, same typesUnwrap shared types with validation errors#
// internal/annotations/unwrap.go
package annotations
// UnwrapFieldInfo contains information about an unwrap field in a message.
type UnwrapFieldInfo struct {
Field *protogen.Field // The field with unwrap=true
ElementType *protogen.Message // The element type of the repeated field (if message type)
IsRootUnwrap bool // True if this is a root-level unwrap (single field in message)
IsMapField bool // True if the unwrap field is a map (only for root unwrap)
}
// UnwrapValidationError represents an error in unwrap annotation validation.
type UnwrapValidationError struct {
MessageName string
FieldName string
Reason string
}
func (e *UnwrapValidationError) Error() string {
return "invalid unwrap annotation on " + e.MessageName + "." + e.FieldName + ": " + e.Reason
}
// HasUnwrapAnnotation checks if a field has the unwrap=true annotation.
func HasUnwrapAnnotation(field *protogen.Field) bool { ... }
// GetUnwrapField returns the unwrap field info for a message, or nil if none exists.
// Returns an error if the annotation is invalid.
func GetUnwrapField(message *protogen.Message) (*UnwrapFieldInfo, error) { ... }State of the Art#
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Copy annotation code per generator | Shared annotations package | Phase 2 (this phase) | Eliminates 1,289+ lines of duplication |
| Per-file unwrap resolution | Two-pass global unwrap (Phase 1) | 2026-02-05 | Cross-file unwrap already works, shared package preserves this |
Not deprecated/outdated: The proto.GetExtension API, protogen types, and descriptorpb option types are all stable in protobuf Go v1.36.11. No upcoming changes affect this work.
Detailed Duplication Map#
Exact duplicates (identical logic, possibly different naming)#
| Function | httpgen | clientgen | tsclientgen | openapiv3 | Notes |
|---|---|---|---|---|---|
getMethodHTTPConfig |
annotations.go:46 | annotations.go:47 | annotations.go:47 | http_annotations.go:41 | Identical extraction, same return struct shape |
httpMethodToString |
annotations.go:79 | annotations.go:80 | annotations.go:78 | http_annotations.go:74 | openapiv3 returns lowercase; others uppercase |
extractPathParams |
annotations.go:101 | annotations.go:102 | annotations.go:97 | http_annotations.go:94 | Identical logic |
pathParamRegex |
annotations.go:23 | annotations.go:23 | annotations.go:23 | http_annotations.go:18 | Same compiled regex |
getServiceHTTPConfig |
annotations.go:117 | annotations.go:118 | annotations.go:113 | http_annotations.go:154 | openapiv3 uses ServiceHTTPConfig name |
getServiceHeaders |
annotations.go:146 | annotations.go:147 | annotations.go:140 | http_annotations.go:214 | Identical |
getMethodHeaders |
annotations.go:173 | annotations.go:174 | annotations.go:165 | http_annotations.go:241 | Identical |
getQueryParams |
annotations.go:237 | annotations.go:201 | annotations.go:190 | http_annotations.go:112 | Struct fields vary (see QueryParam table) |
hasUnwrapAnnotation |
annotations.go:287 | N/A | annotations.go:232 | types.go:253 | 3 copies |
getFieldExamples |
annotations.go:210 | N/A | N/A | types.go:274 | 2 copies |
Partially duplicated (same concept, different implementations)#
| Function | Where | Notes |
|---|---|---|
getUnwrapField |
httpgen/annotations.go:316 (full validation) | Most complete -- returns UnwrapFieldInfo with error |
getUnwrapField |
openapiv3/types.go:243 (simple) | Simpler version -- returns *protogen.Field only |
findUnwrapField |
tsclientgen/types.go:212 | Another simple version -- returns *protogen.Field |
buildHTTPPath |
openapiv3/http_annotations.go:182 | Full function with ensureLeadingSlash |
| (inline path building) | clientgen/generator.go:425-433 | Inline equivalent |
| (inline path building) | tsclientgen/generator.go:281-288 | Identical to clientgen inline |
combineHeaders |
openapiv3/http_annotations.go:267 | Only in openapiv3 currently |
lowerFirst |
httpgen, clientgen, tsclientgen | 3 copies, identical |
Not duplicated (generator-specific, stays in generator)#
| Function | Package | Why Not Shared |
|---|---|---|
convertHeadersToParameters |
openapiv3 | OpenAPI-specific conversion |
mapHeaderTypeToOpenAPI |
openapiv3 | OpenAPI-specific mapping |
extractValidationConstraints |
openapiv3 | buf.validate-specific, uses libopenapi types |
ValidateMethodConfig |
httpgen | HTTP handler-specific validation logic |
collectUnwrapContext |
httpgen | Generator-specific unwrap orchestration |
generateUnwrapFile |
httpgen | Code generation logic |
camelToSnake |
httpgen | Generator-specific path formatting |
snakeToLowerCamel |
tsclientgen | TS-specific naming |
headerNameToPropertyName |
tsclientgen | TS-specific naming |
parseExistingAnnotation |
httpgen | Legacy/temporary, always returns "" |
Serialization Audit Findings#
FOUND-04 scope: Audit encoding/json vs protojson consistency in HTTP handler generation.
Current usage in httpgen generated code:#
-
Binding file (
_http_binding.pb.go): Usesencoding/jsonfor:json.Unmarshalerinterface check (line 369): Correct -- checks if message implements custom unmarshaler (unwrap types do)json.Marshalerinterface check (line 634): Correct -- checks if message implements custom marshaler (unwrap types do)protojson.Unmarshal/protojson.Marshal: Used for all standard proto message (de)serialization
-
Unwrap file (
_unwrap.pb.go): Uses both:encoding/json: For structural marshaling (buildingmap[string]json.RawMessage, marshaling arrays ofjson.RawMessage)protojson: For individual proto message marshaling within the unwrap structure
Assessment: The current usage is CORRECT. The encoding/json import is required because:
- Generated unwrap types implement
json.Marshaler/json.Unmarshalerinterfaces - The binding middleware correctly checks for these interfaces before falling back to
protojson - Structural JSON (maps, arrays of raw messages) uses
encoding/json - Proto message serialization uses
protojson
No changes needed for FOUND-04. The usage is consistent and intentional. The only action item is to document this pattern clearly so future phases don't accidentally introduce encoding/json for proto message serialization where protojson should be used.
Migration Order Detailed Analysis#
1. httpgen first (widest API surface)#
httpgen uses ALL annotation types: HTTPConfig, ServiceConfig, QueryParam, UnwrapFieldInfo (full version with validation), FieldExamples, headers. It defines the most complete versions of shared types, especially UnwrapFieldInfo and UnwrapValidationError. Migrating this first establishes the complete shared API.
Files affected:
annotations.go(392 lines) -- delete entirely after migrationunwrap.go-- keep unwrap LOGIC (generation, context collection), butUnwrapFieldInfo,UnwrapValidationError,hasUnwrapAnnotation,getUnwrapFieldmove to sharedvalidation.go-- stays, but changes calls toannotations.GetMethodHTTPConfig(...),annotations.GetQueryParams(...)generator.go-- changes calls fromgetMethodHTTPConfig(...)toannotations.GetMethodHTTPConfig(...)mock_generator.go-- changesgetFieldExamples(...)callannotations_test.go-- tests forhttpMethodToString,extractPathParamsetc. move to shared package tests
2. clientgen second#
Files affected:
annotations.go(241 lines) -- delete entirelygenerator.go-- change all annotation function calls to use shared package
3. tsclientgen third#
Files affected:
annotations.go(250 lines) -- delete entirelytypes.go--findUnwrapFieldandisRootUnwrapcallannotations.HasUnwrapAnnotationgenerator.go-- change annotation function calls
4. openapiv3 last#
Files affected:
http_annotations.go(406 lines) -- delete most of it; keepconvertHeadersToParametersandmapHeaderTypeToOpenAPI(OpenAPI-specific, ~90 lines), or move those togenerator.gotypes.go-- removehasUnwrapAnnotation,getUnwrapField,getFieldExamples(~80 lines); keepconvertField,convertScalarField, etc.generator.go-- change annotation function calls
Error Handling Recommendation#
Use standard Go errors. The protogen error reporting (plugin.Error()) is designed for user-facing errors that stop generation. The annotation parsing functions currently return nil for missing annotations (which is correct -- missing is not an error) and return error for invalid annotations (e.g., unwrap on non-repeated field).
Pattern:
- Missing annotation = return nil (caller checks nil)
- Invalid annotation = return error with proto file path + message/field name
- The
UnwrapValidationErrortype already follows this pattern -- keep it
Do NOT use protogen.Plugin.Error() inside the shared package because the shared package should not have a dependency on the plugin instance. Return errors; let callers decide how to report them.
Open Questions#
-
Should
CombineHeadersmove to shared?- Currently only in openapiv3, but the concept (method headers override service headers) is universal
- Recommendation: YES -- extract it, other generators may need it
- Confidence: HIGH -- the logic is annotation-level, not generator-level
-
Should
lowerFirstmove to shared?- Duplicated in 3 generators, pure string utility
- Recommendation: YES -- put it in a
helpers.goin the annotations package or a separateinternal/stringutilpackage - Confidence: MEDIUM -- it's not annotation-related, but extracting it alongside annotations makes practical sense. Could go in annotations or a tiny shared helpers package.
-
Should
BuildHTTPPathmove to shared?- openapiv3 has it as a function; clientgen and tsclientgen have identical inline logic
- Recommendation: YES -- extract to shared, eliminate the inline duplication
- Confidence: HIGH
Sources#
Primary (HIGH confidence)#
- Direct codebase analysis of all 4 annotation files (1,289 lines total)
- Direct codebase analysis of openapiv3/types.go (additional annotation code)
- Proto definitions at
proto/sebuf/http/annotations.protoandheaders.proto go.modfor exact dependency versions
Secondary (MEDIUM confidence)#
- Go protobuf API stability:
proto.GetExtensionanddescriptorpbare stable public APIs in google.golang.org/protobuf v1.36.x
Tertiary (LOW confidence)#
- None -- all findings are from direct codebase analysis
Metadata#
Confidence breakdown:
- Standard stack: HIGH -- no new dependencies, pure refactoring
- Architecture: HIGH -- all patterns derived from existing codebase analysis
- Pitfalls: HIGH -- identified from structural analysis of the actual code
- Serialization audit: HIGH -- direct code inspection confirms correct usage
Research date: 2026-02-05 Valid until: Indefinite (internal refactoring, no external dependencies changing)
