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
Conversation
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.
5 tasks
Peyton-Spencer
marked this pull request as ready for review
July 28, 2026 15:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: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_detailhas beenTEXTsince 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 nosafe_add_columndance 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:
validator_tickets). It is a ledger row, not a log line.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/FailJobResponsewere in ditto-subnet's copy ofSHARED_MODELSbut 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 theFailJobRequestentry 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_LENGTH200 →FAILURE_DETAIL_MAX_LENGTH = 4096(made public — ditto-subnet mirrors it by name and the golden pins it). AddedLEGACY_FAILURE_DETAIL_MAX_LENGTH = 200documenting the accepted floor.tests/contract/_schema.py: addedFailJobRequest/FailJobResponsetoSHARED_MODELS; regeneratedvalidator_contract.json.db/models.py,docs/validator-retry-model.md: doc corrections — the column is and wasTEXT, plus the truncation marker and the skew matrix.Version skew
Validators run mixed versions (0.34.1 through 0.37.3 concurrently). Both directions are handled, and neither depends on deploy order:
max_lengthonly ever admits more — a 200-char value validates unchanged. Pinned bytest_legacy_validator_detail_at_the_old_bound_still_validates.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_detailis 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 separateendswithon 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 aVARCHAR(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 typecheckclean; 207 tests pass intest_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.