Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.

fix(validator): widen failure_detail from 200 to 4096 chars - #553

Merged
Peyton-Spencer merged 1 commit into
mainfrom
validator/widen-failure-detail
Jul 28, 2026
Merged

fix(validator): widen failure_detail from 200 to 4096 chars#553
Peyton-Spencer merged 1 commit into
mainfrom
validator/widen-failure-detail

Conversation

@Peyton-Spencer

@Peyton-Spencer Peyton-Spencer commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

failure_detail (added in ditto-subnet#282) was bounded to 200 characters. The first time it carried a real diagnostic message rather than a failure code, it cut it mid-word — and the message it cut was the one that finally identified a root cause three separate investigations had failed to reach:

DittobenchError: run 2b7c6b6c-ae45-493d-b8f5-b1a4a6ff8b3a failed: harness exhausted
its inference allowance: agent-attributable inference decline: the platform rejected
81 of the harness's inference r[equest(s) outright, before reserving any capacity]

The bracketed remainder never arrived. The count (81) and the verb (rejected) survived by luck of word order; a sentence built slightly differently would have lost both. Worse than the lost characters was the lost signal — the surviving half ends on a word boundary and reads as a finished sentence, so nothing indicated a second clause existed.

This widens the bound to 4096 and makes any remaining truncation announce itself.

No migration. The bound lived entirely on FailJobRequest; validator_tickets.failure_detail has been TEXT since the day it was added. Nothing about the column changes, no rows are rewritten, and every value ever stored is still valid — which is also why there is no safe_add_column dance on a hot table here.

The bound: why 4096 and not unlimited

The ask was "remove the limit or make it very long". I chose a large explicit cap over unbounded, and want the reasoning on the record:

  • This field is written by validators, per ticket, onto a hot table (validator_tickets). It is a ledger row, not a log line.
  • Its content is derived from strings a miner's harness can influence — the message above is the scorer relaying a harness-side condition. An unbounded, attacker-influenceable string with a write path into the ledger is a storage and log-volume liability, and removing the ceiling entirely means the only thing standing between a hostile harness and arbitrary-size ledger writes is that nobody has tried.
  • 4096 is ~16x the longest message actually observed (the one above is ~250 chars) and comfortably fits a scorer message carrying a run id, a full account of an exhaustion, and its counts. At worst it is ~16 KB of UTF-8 per failed ticket.

So: generous enough that no realistic diagnostic can hit it, bounded enough that the field cannot become a log-shipping channel.

Truncation is no longer silent

Previously a value over the cap was cut with no marker, which is what made the 200-char bound expensive — the fragment read as complete. Overflow is still truncated by the sender rather than rejected here (a 422 loses the entire hand-back and leaves the lease to expire silently, trading the diagnosis for exactly the ambiguity the field exists to remove), but ditto-subnet now appends ...[truncated, N chars], sized so the result still fits the cap including the marker. A reader can now distinguish an amputated message from a whole one, and knows how much is missing.

The golden contract had no authority behind it

Found while tracing enforcement points, and worth flagging separately: FailJobRequest / FailJobResponse were in ditto-subnet's copy of SHARED_MODELS but never in this repo's. The golden ditto-subnet checks itself against is supposed to be generated from the platform (the source of truth) — instead the FailJobRequest entry had been generated from ditto-subnet's own models, so the two copies of this field's length bound could drift apart silently.

That is precisely the field where it matters: the length bound is a number both repos must agree on exactly, and disagreement surfaces as a production 422, not a test failure. Added both models here and regenerated. A one-sided change to the cap now fails the contract test in both repos.

Changes

  • api_models/validator.py: _FAILURE_DETAIL_MAX_LENGTH 200 → FAILURE_DETAIL_MAX_LENGTH = 4096 (made public — ditto-subnet mirrors it by name and the golden pins it). Added LEGACY_FAILURE_DETAIL_MAX_LENGTH = 200 documenting the accepted floor.
  • tests/contract/_schema.py: added FailJobRequest / FailJobResponse to SHARED_MODELS; regenerated validator_contract.json.
  • db/models.py, docs/validator-retry-model.md: doc corrections — the column is and was TEXT, plus the truncation marker and the skew matrix.
  • Tests.

Version skew

Validators run mixed versions (0.34.1 through 0.37.3 concurrently). Both directions are handled, and neither depends on deploy order:

old validator (≤200 char detail) new validator (up to 4096)
new platform (this PR) Fine. Widening a max_length only ever admits more — a 200-char value validates unchanged. Pinned by test_legacy_validator_detail_at_the_old_bound_still_validates. Fine, and the point of the change.
old platform Fine, unchanged. 422 → ditto-subnet retries once with the detail re-truncated to 200, so the hand-back still lands and only the message tail is lost.

The reverse-skew retry lives in ditto-subnet (see that PR). It is safe to replay with the same nonce and signature: a 422 is request validation raised before the endpoint body runs, so the nonce is never consumed, and failure_detail is unsigned by design, so shortening it leaves the signed payload byte-identical.

Deploy order is therefore not load-bearing, though platform-first is preferable since it means full messages from the first widened validator onward.

Test plan

  • test_diagnostic_message_past_the_old_bound_survives_intact — the real production message above, asserted end to end (HTTP body → ticket row), character for character, with a separate endswith on the clause the old cap ate.
  • test_a_detail_at_the_widened_cap_round_trips — 4096 chars survive the full round trip; this is what would catch a VARCHAR(n) introduced under the widened wire type.
  • test_legacy_validator_detail_at_the_old_bound_still_validates — forward skew.
  • test_overlong_failure_detail_is_rejected — there is still a cap.
  • make lint lint-copy typecheck clean; 207 tests pass in test_validator.py + contract.

Merge order

Independent of ditto-assistant/ditto-subnet#291, but land this one first so widened validators find a platform that accepts them.

The 200-char bound cut the diagnostic message that finally named a root
cause three investigations had missed, mid-word, and left a half-sentence
that read as a complete finding.

The column was already TEXT, so no migration is needed: the bound lived
entirely on FailJobRequest. Also adds FailJobRequest/FailJobResponse to
SHARED_MODELS -- ditto-subnet had them in its copy and the platform did
not, so the golden ditto-subnet checks itself against carried no
authority from the source of truth.
@Peyton-Spencer Peyton-Spencer changed the title validator/widen failure detail fix(validator): widen failure_detail from 200 to 4096 chars Jul 28, 2026
@Peyton-Spencer
Peyton-Spencer marked this pull request as ready for review July 28, 2026 15:49
@Peyton-Spencer
Peyton-Spencer merged commit 44dd841 into main Jul 28, 2026
7 checks passed
@Peyton-Spencer
Peyton-Spencer deleted the validator/widen-failure-detail branch July 28, 2026 15:50
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant