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:
@@ -1,6 +1,6 @@
|
||||
# Validator parity probe — Rust :3100 vs Go :4110
|
||||
|
||||
**Date:** 2026-05-02T08:59:17Z
|
||||
**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
|
||||
@@ -9,124 +9,11 @@ Identical `POST /v1/validate` request → both runtimes. Match
|
||||
| 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 | ✓ | ✗ |
|
||||
| playbook_missing_fingerprint | 422 | 422 | ✓ | ✓ |
|
||||
| playbook_wrong_prefix | 422 | 422 | ✓ | ✓ |
|
||||
| playbook_empty_endorsed | 422 | 422 | ✓ | ✓ |
|
||||
| playbook_overfull | 422 | 422 | ✓ | ✓ |
|
||||
| fill_phantom | 422 | 422 | ✓ | ✓ |
|
||||
|
||||
**Tally:** 1 match · 5 diff (out of 6 cases)
|
||||
**Tally:** 6 match · 0 diff (out of 6 cases)
|
||||
|
||||
## Divergences
|
||||
|
||||
<details><summary>DIFF — `playbook_missing_fingerprint`</summary>
|
||||
|
||||
**Rust** (HTTP 422):
|
||||
```json
|
||||
{
|
||||
"Schema": {
|
||||
"field": "fingerprint",
|
||||
"reason": "missing — required for Phase 25 validity window"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Go** (HTTP 422):
|
||||
```json
|
||||
{
|
||||
"Field": "fingerprint",
|
||||
"Kind": "schema",
|
||||
"Reason": "missing — required for Phase 25 validity window"
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>DIFF — `playbook_wrong_prefix`</summary>
|
||||
|
||||
**Rust** (HTTP 422):
|
||||
```json
|
||||
{
|
||||
"Schema": {
|
||||
"field": "operation",
|
||||
"reason": "expected `fill: ...` prefix, got \"sms_draft: hello\""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Go** (HTTP 422):
|
||||
```json
|
||||
{
|
||||
"Field": "operation",
|
||||
"Kind": "schema",
|
||||
"Reason": "expected `fill: ...` prefix, got \"sms_draft: hello\""
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>DIFF — `playbook_empty_endorsed`</summary>
|
||||
|
||||
**Rust** (HTTP 422):
|
||||
```json
|
||||
{
|
||||
"Completeness": {
|
||||
"reason": "endorsed_names must be non-empty"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Go** (HTTP 422):
|
||||
```json
|
||||
{
|
||||
"Field": "",
|
||||
"Kind": "completeness",
|
||||
"Reason": "endorsed_names must be non-empty"
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>DIFF — `playbook_overfull`</summary>
|
||||
|
||||
**Rust** (HTTP 422):
|
||||
```json
|
||||
{
|
||||
"Completeness": {
|
||||
"reason": "endorsed_names (3) exceeds target_count × 2 (2)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Go** (HTTP 422):
|
||||
```json
|
||||
{
|
||||
"Field": "",
|
||||
"Kind": "completeness",
|
||||
"Reason": "endorsed_names (3) exceeds target_count × 2 (2)"
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>DIFF — `fill_phantom`</summary>
|
||||
|
||||
**Rust** (HTTP 422):
|
||||
```json
|
||||
{
|
||||
"Consistency": {
|
||||
"reason": "fills[0].candidate_id \"W-PHANTOM-NEVER-EXISTS\" does not exist in worker roster"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Go** (HTTP 422):
|
||||
```json
|
||||
{
|
||||
"Field": "",
|
||||
"Kind": "consistency",
|
||||
"Reason": "fills[0].candidate_id \"W-PHANTOM-NEVER-EXISTS\" does not exist in worker roster"
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Reference in New Issue
Block a user