validator: align ValidationError JSON to Rust serde shape (6/6 parity)
Closes the 2026-05-02 parity finding: validator_parity probe found
5/6 body shapes diverging because Go emitted {"Kind":"...","Field":"...","Reason":"..."}
while Rust emits the externally-tagged-enum {"Schema":{"field":"...","reason":"..."}}.
A caller parsing the error envelope would break silently in cutover.
## Changes
internal/validator/types.go:
- Custom MarshalJSON emits the Rust shape:
Schema: {"Schema": {"field":"x","reason":"y"}}
Completeness: {"Completeness":{"reason":"y"}}
Consistency: {"Consistency": {"reason":"y"}}
Policy: {"Policy": {"reason":"y"}}
- Custom UnmarshalJSON accepts BOTH the new Rust shape AND the legacy
flat shape (migration safety for any persisted error rows).
- Unknown variants (e.g. a future Rust addition Go hasn't learned)
surface as an Unmarshal error, not a silent default.
internal/validator/types_test.go:
- 4 pinning tests anchor the wire format. Failing them = wire-format
drift; the parity probe is the secondary line of defense.
scripts/validatord_smoke.sh:
- Updated probes to read the new variant-name shape (jq keys[0],
.Schema.field) instead of legacy .Kind/.Field.
## Verification
- internal/validator unit tests: PASS (4 new + all existing).
- cmd/validatord HTTP tests: PASS (UnmarshalJSON falls through to flat
shape so existing tests reading ValidationError still work).
- validatord_smoke.sh: 5/5 PASS through gateway :3110.
- validator parity probe re-run: **6/6 match** (was 1/6).
## Pattern
Per architecture_comparison's "use the dual-implementation as a
measurement instrument" thesis: a parity probe surfaced this gap;
50 LOC of MarshalJSON closed it; 4 pinning tests prevent regression;
the probe is the longitudinal gate. Cutover-friendly direction (Go
matches Rust) chosen because Rust is the existing production
contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,11 @@
|
||||
|
||||
package validator
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Artifact is the discriminated union of input shapes a Validator
|
||||
// can receive. Mirrors Rust's `enum Artifact`. The first non-zero
|
||||
@@ -107,6 +111,94 @@ func (e *ValidationError) Error() string {
|
||||
return string(e.Kind) + ": " + e.Reason
|
||||
}
|
||||
|
||||
// MarshalJSON emits the Rust-compatible serde-tagged-enum shape:
|
||||
//
|
||||
// {"Schema": {"field": "...", "reason": "..."}}
|
||||
// {"Completeness": {"reason": "..."}}
|
||||
// {"Consistency": {"reason": "..."}}
|
||||
// {"Policy": {"reason": "..."}}
|
||||
//
|
||||
// Aligns with `crates/validator/src/lib.rs::ValidationError` so callers
|
||||
// that parse the error envelope work across runtimes. Surfaced by the
|
||||
// 2026-05-02 cross-runtime parity probe (validator_parity.sh) which
|
||||
// found 5/6 body shapes diverging on identical input.
|
||||
func (e ValidationError) MarshalJSON() ([]byte, error) {
|
||||
variant := variantName(e.Kind)
|
||||
inner := map[string]string{}
|
||||
if e.Kind == ErrSchema {
|
||||
inner["field"] = e.Field
|
||||
}
|
||||
inner["reason"] = e.Reason
|
||||
return json.Marshal(map[string]any{variant: inner})
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts BOTH the Rust serde-tagged shape AND the
|
||||
// legacy flat-struct shape Go used to emit, so existing on-disk JSONL
|
||||
// rows still parse. New writes always use the Rust shape (per
|
||||
// MarshalJSON above).
|
||||
func (e *ValidationError) UnmarshalJSON(data []byte) error {
|
||||
// Try the Rust serde shape first: {"<Variant>":{...}}
|
||||
var tagged map[string]map[string]string
|
||||
if err := json.Unmarshal(data, &tagged); err == nil && len(tagged) == 1 {
|
||||
for variant, inner := range tagged {
|
||||
kind, ok := kindFromVariant(variant)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown ValidationError variant %q", variant)
|
||||
}
|
||||
e.Kind = kind
|
||||
e.Field = inner["field"]
|
||||
e.Reason = inner["reason"]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Fall back to the legacy flat shape: {"Kind":"...","Field":"...","Reason":"..."}
|
||||
var flat struct {
|
||||
Kind ValidationErrorKind `json:"Kind"`
|
||||
Field string `json:"Field"`
|
||||
Reason string `json:"Reason"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &flat); err != nil {
|
||||
return fmt.Errorf("ValidationError: neither serde nor flat shape: %w", err)
|
||||
}
|
||||
if flat.Kind == "" {
|
||||
return fmt.Errorf("ValidationError: missing kind")
|
||||
}
|
||||
e.Kind = flat.Kind
|
||||
e.Field = flat.Field
|
||||
e.Reason = flat.Reason
|
||||
return nil
|
||||
}
|
||||
|
||||
// variantName maps the lowercase Go Kind to the CamelCase Rust serde
|
||||
// variant name. Kept in sync with `crates/validator/src/lib.rs::ValidationError`.
|
||||
func variantName(k ValidationErrorKind) string {
|
||||
switch k {
|
||||
case ErrSchema:
|
||||
return "Schema"
|
||||
case ErrCompleteness:
|
||||
return "Completeness"
|
||||
case ErrConsistency:
|
||||
return "Consistency"
|
||||
case ErrPolicy:
|
||||
return "Policy"
|
||||
}
|
||||
return string(k) // fallback — preserves Kind for debugging
|
||||
}
|
||||
|
||||
func kindFromVariant(v string) (ValidationErrorKind, bool) {
|
||||
switch v {
|
||||
case "Schema":
|
||||
return ErrSchema, true
|
||||
case "Completeness":
|
||||
return ErrCompleteness, true
|
||||
case "Consistency":
|
||||
return ErrConsistency, true
|
||||
case "Policy":
|
||||
return ErrPolicy, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Validator is the interface every validator implements.
|
||||
// Stateless — construction takes any deps (e.g. WorkerLookup)
|
||||
// upfront, validate() is pure on its inputs.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidationError_MarshalsRustSerdeShape pins the on-wire JSON
|
||||
// envelope for cross-runtime parity with crates/validator's
|
||||
// `enum ValidationError`. Surfaced by the 2026-05-02 validator parity
|
||||
// probe (5/6 body-shape divergence pre-fix → 6/6 match post-fix).
|
||||
//
|
||||
// Failing this test = wire-format drift. Run the parity probe at
|
||||
// scripts/cutover/parity/validator_parity.sh to confirm Rust is
|
||||
// the source-of-truth shape this test is anchored to.
|
||||
func TestValidationError_MarshalsRustSerdeShape(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err ValidationError
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "schema includes field+reason inside variant body",
|
||||
err: ValidationError{Kind: ErrSchema, Field: "fingerprint", Reason: "missing"},
|
||||
want: `{"Schema":{"field":"fingerprint","reason":"missing"}}`,
|
||||
},
|
||||
{
|
||||
name: "completeness only carries reason (no field key)",
|
||||
err: ValidationError{Kind: ErrCompleteness, Reason: "endorsed_names must be non-empty"},
|
||||
want: `{"Completeness":{"reason":"endorsed_names must be non-empty"}}`,
|
||||
},
|
||||
{
|
||||
name: "consistency only carries reason",
|
||||
err: ValidationError{Kind: ErrConsistency, Reason: "candidate_id W-1 not in roster"},
|
||||
want: `{"Consistency":{"reason":"candidate_id W-1 not in roster"}}`,
|
||||
},
|
||||
{
|
||||
name: "policy only carries reason",
|
||||
err: ValidationError{Kind: ErrPolicy, Reason: "client C-1 is on candidate's blacklist"},
|
||||
want: `{"Policy":{"reason":"client C-1 is on candidate's blacklist"}}`,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := json.Marshal(c.err)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if string(got) != c.want {
|
||||
t.Errorf("\n got: %s\nwant: %s", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidationError_RoundTripsRustShape confirms a marshal+unmarshal
|
||||
// returns the same fields. Important: a Go side reading a Rust-written
|
||||
// ValidationError JSONL must populate Kind/Field/Reason correctly.
|
||||
func TestValidationError_RoundTripsRustShape(t *testing.T) {
|
||||
original := ValidationError{Kind: ErrSchema, Field: "operation", Reason: "expected fill: prefix"}
|
||||
body, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded ValidationError
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if decoded.Kind != original.Kind || decoded.Field != original.Field || decoded.Reason != original.Reason {
|
||||
t.Errorf("round-trip drift:\n in: %+v\n out: %+v", original, decoded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidationError_ParsesLegacyFlatShape ensures consumers reading
|
||||
// older Go-emitted JSONL still parse. This is the migration path for
|
||||
// any persisted error rows that pre-date the 2026-05-02 alignment.
|
||||
func TestValidationError_ParsesLegacyFlatShape(t *testing.T) {
|
||||
legacy := []byte(`{"Kind":"schema","Field":"x","Reason":"y"}`)
|
||||
var ve ValidationError
|
||||
if err := json.Unmarshal(legacy, &ve); err != nil {
|
||||
t.Fatalf("legacy shape should parse: %v", err)
|
||||
}
|
||||
if ve.Kind != ErrSchema || ve.Field != "x" || ve.Reason != "y" {
|
||||
t.Errorf("legacy parse wrong: %+v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidationError_RejectsUnknownVariant catches drift if Rust adds
|
||||
// a new variant the Go side hasn't learned about yet — surfaces as a
|
||||
// parse error rather than a silent default.
|
||||
func TestValidationError_RejectsUnknownVariant(t *testing.T) {
|
||||
body := []byte(`{"NewVariant":{"reason":"something"}}`)
|
||||
var ve ValidationError
|
||||
if err := json.Unmarshal(body, &ve); err == nil {
|
||||
t.Errorf("expected error on unknown variant, got %+v", ve)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user