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:
root
2026-05-02 04:49:28 -05:00
co-authored by Claude Opus 4.7
parent b0c8a3f227
commit 7d6636b33e
6 changed files with 212 additions and 133 deletions
+12 -9
View File
@@ -115,13 +115,15 @@ if [ "$STATUS" != "422" ]; then
echo " ✗ expected 422; got $STATUS body=$(cat /tmp/playbook_422.json)"
exit 1
fi
KIND="$(jq -r '.Kind' /tmp/playbook_422.json)"
FIELD="$(jq -r '.Field' /tmp/playbook_422.json)"
if [ "$KIND" != "schema" ] || [ "$FIELD" != "fingerprint" ]; then
echo " ✗ expected kind=schema field=fingerprint; got kind=$KIND field=$FIELD"
# Rust serde-tagged-enum shape (parity with crates/validator):
# {"Schema":{"field":"fingerprint","reason":"..."}}
VARIANT="$(jq -r 'keys[0]' /tmp/playbook_422.json)"
FIELD="$(jq -r '.Schema.field' /tmp/playbook_422.json)"
if [ "$VARIANT" != "Schema" ] || [ "$FIELD" != "fingerprint" ]; then
echo " ✗ expected variant=Schema field=fingerprint; got variant=$VARIANT field=$FIELD"
exit 1
fi
echo " ✓ playbook missing fingerprint → 422 schema/fingerprint"
echo " ✓ playbook missing fingerprint → 422 Schema/fingerprint"
# 4. /v1/validate fill with phantom candidate → 422 Consistency
echo "[validatord-smoke] /v1/validate fill with phantom candidate → 422:"
@@ -132,12 +134,13 @@ if [ "$STATUS" != "422" ]; then
echo " ✗ expected 422; got $STATUS body=$(cat /tmp/fill_422.json)"
exit 1
fi
KIND="$(jq -r '.Kind' /tmp/fill_422.json)"
if [ "$KIND" != "consistency" ]; then
echo " ✗ expected kind=consistency; got kind=$KIND body=$(cat /tmp/fill_422.json)"
# Rust serde-tagged-enum shape: {"Consistency":{"reason":"..."}}
VARIANT="$(jq -r 'keys[0]' /tmp/fill_422.json)"
if [ "$VARIANT" != "Consistency" ]; then
echo " ✗ expected variant=Consistency; got variant=$VARIANT body=$(cat /tmp/fill_422.json)"
exit 1
fi
echo " ✓ phantom candidate W-PHANTOM → 422 consistency"
echo " ✓ phantom candidate W-PHANTOM → 422 Consistency"
# 5. /v1/validate unknown kind → 400
echo "[validatord-smoke] /v1/validate unknown kind → 400:"