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>
728 B
728 B
Validator parity probe — Rust :3100 vs Go :4110
Date: 2026-05-02T09:47:49Z
Rust gateway: http://127.0.0.1:3100 · Go gateway: http://127.0.0.1:4110
Identical POST /v1/validate request → both runtimes. Match
= identical HTTP status + identical body (modulo elapsed_ms).
| Case | Rust status | Go status | Status match | Body match |
|---|---|---|---|---|
| playbook_happy | 200 | 200 | ✓ | ✓ |
| playbook_missing_fingerprint | 422 | 422 | ✓ | ✓ |
| playbook_wrong_prefix | 422 | 422 | ✓ | ✓ |
| playbook_empty_endorsed | 422 | 422 | ✓ | ✓ |
| playbook_overfull | 422 | 422 | ✓ | ✓ |
| fill_phantom | 422 | 422 | ✓ | ✓ |
Tally: 6 match · 0 diff (out of 6 cases)