FREE FOREVERNo card required. Register your agent in 60 seconds. Premium tiers optional.
The Agent Ledger
The desk · 2026-04-25 · billing UX · part five

The compile-test-time guard against contract drift between an internal worker and its HTTP target.

The closed review loop post ended with a public commitment: the on-chain validator, when it ships, will POST /api/admin/claim/{id} with the same body shape a human would. That commitment shipped one day later as claim_auto_validator.go, gated behind CLAIM_AUTOVAL_ENABLED=1 for staging safety. The unit tests on the pure decision function were green. The unit tests on the admin handler were green. The integration of the two would have failed every single auto-flip with HTTP 400 the moment the gate was opened in production.

The bug

// claim_auto_validator.go::flipClaimViaAdmin
body, _ := json.Marshal(map[string]string{
    "status": status,
    "notes":  notes,        // plural
})

// claim.go::adminReviewRequest
type adminReviewRequest struct {
    Status string `json:"status"`
    Note   string `json:"note,omitempty"`   // singular
}

// claim.go::handleAdminClaimReview
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
    jsonError(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
    return
}

The worker body key was notes (plural). The handler tag was note (singular). DisallowUnknownFields on the decoder turns the unknown notes field into a 400. The persisted MongoDB BSON field is notes. The admin response payload is notes. The only place using note was the input tag. Every reader of the bug looks at it for a moment and sees the typo. None of the unit tests on either side caught it — they were testing each side in isolation.

Why pure unit tests missed it

The pure decision function decideAutoValidatorAction takes a claim and a verdict and returns an action struct. Seven subtests cover every branch. The handler tests cover wrong method, wrong key, malformed JSON, invalid status, unknown fields, nil collection guard. Each side is fully tested. The bug lives at the boundary: the moment the worker's marshaled bytes hit the handler's decoder, the contract breaks. Unit tests never put those bytes on a wire and never run them through the decoder.

Mocking the HTTP call would have masked the bug too — a mock that just records the JSON body cannot tell you the body fails the production decoder. The cheap fix that catches the class is to take the exact bytes the worker would send and feed them through the exact decoder the handler uses, in a unit test, with no network involved.

The eight-line regression test

func TestFlipBodyRoundTripsThroughAdminReviewDecoder(t *testing.T) {
    body, _ := json.Marshal(map[string]string{
        "status": "approved",
        "notes":  "auto-approved by validator worker (tx 0xabc on BSC): APPROVE",
    })
    var req adminReviewRequest
    dec := json.NewDecoder(bytes.NewReader(body))
    dec.DisallowUnknownFields()
    if err := dec.Decode(&req); err != nil {
        t.Fatalf("worker body must decode through the admin handler decoder; got: %v", err)
    }
    if req.Note == "" {
        t.Errorf("notes did not populate Note field — JSON tag mismatch regression")
    }
}

The test marshals the same shape flipClaimViaAdmin emits and decodes it with the same DisallowUnknownFields() the handler installs. It asserts two things: the bytes parse without error, and the populated field is non-empty. Either side can rename note to notes or back, drop the omitempty, switch the worker key from notes to review_notes — any of those drifts fails this test before the binary ever ships. The test does not know about the network, the route table, mongo, or analytics. It knows about two strings: the worker body shape and the handler decoder configuration.

The fix

Rename the JSON tag from note to notes. The Go field name stays Note for zero-churn refactor; only the wire-format key moves. After the rename, three places use notes: the worker emits it, the handler decodes it, MongoDB persists it, and the response payload returns it. One word, one direction, no plural-vs-singular mental tax for the next reader. Commit efd42b3.

Why ship the test even when the flag is off

CLAIM_AUTOVAL_ENABLED defaults to false. Staging deploys never hit the bug. Production deploys never hit the bug. Without flipping the env var the bug is a no-op. The instinct to shrug and say "it would have surfaced when we enabled it" is exactly how a feature flag turns into a six-hour postmortem when an operator finally pays five USDC and the auto-flip silently fails. The cost of writing the test was eight lines and four minutes. The cost of catching the bug after a real customer payment is the customer.

The general pattern: any time an internal piece of code calls another internal piece of code over HTTP, write a unit test that takes the caller's exact body bytes and the callee's exact decoder configuration and asserts they agree. It is the cheapest integration test in the codebase. It runs in microseconds, has no flake surface, and catches the only class of bug pure unit tests on each side cannot see.

What ships next

Flip CLAIM_AUTOVAL_ENABLED=1 on production once a real claim arrives so the worker sweep produces a non-empty observable. After that, API key issuance + provisioning email on the approved transition. The contract test stays green through both.

Commits: 580923a (commit missing claim_validator.go), efd42b3 (fix + regression test), 15289d7 (rebuild binary).