fix: report an unknown field once, by the innermost strict that does not know it - #185
Merged
Merged
Conversation
…not know it (#183) Decoders.strict counted unknown fields before running the inner decoder and never looked at what it returned, so every strict level reported the same field again (R000862, R000863). The Raoh Specification 0.9.0 leaves out a field the inner flow already reported, but only an unknown_field an unknown-members check made: a same-looking issue from a user's decoder, or an issue of another code at the field's path (R000866), still counts. Issue carries no provenance, and filtering by path and code would drop the user's issue too. The list of an Issues now records which issues a strict made: IssueList holds such an issue in a wrapper slot, add, merge, rebase and resolve keep it, and equality and serialization ignore it. strict runs the inner decoder first and skips the paths its top-level, marked unknown_field issues name. The mark is read and written through the internal IssueProvenance interface, so no public API changes and no ambient state is added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 1, 2026
…ly Raoh's marks Review of #185: running the inner decoder first also moved the point where strict reads the input's field names to after caller code. The specification asks for the inner issues to come first, not for the inner decoder to run first. A decoder of the caller's that removes a member from a mutable input made strict accept it, and one that adds a member made strict report a field the input never had. strict now copies the names before dec runs and only builds its issues afterwards. Same root: strict's decision must rest only on what strict itself observed. Every other combinator reads the input once, before handing it to caller code. But strict also takes provenance from the inner result, which caller code controls: IssueList.copyOf believed any list implementing IssueProvenance, so a user's decoder could forge "a strict made this" and hide its unknown_field from the outer strict. copyOf now keeps marks only from the list IssueProvenance.unknownMembers makes. Both cases fail before this change and are pinned in NestedStrictTest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Part of #183 (item 1). Items 2–4 and item 5 follow in their own PRs.
Cause
Decoders.strictbuilt itsunknown_fieldissues before running the inner decoder and merged them with whatever the inner decoder returned, without looking at it. Everystrictlevel therefore reported a field it did not know, whether or not astrictinside had already reported it:strict(discriminate("kind", {rect: strict(...)}), ...)gaveunknown_fieldat/extratwice.strict(strict(object([a]), [a]), [a])gaveunknown_fieldat/btwice.The specification (raoh-specification#24) leaves out only an
unknown_fieldthat an unknown-members check made at the member's path. A same-looking issue a user's decoder returns is still reported, and so is an issue of another code at that path (R000866).Issuedoes not say what made it, so filtering by path and code would also drop the user's issue.Decoders.strictis the only producer ofunknown_field:MapDecoders.strict,JsonDecoders.strictandCombiner*.strictall delegate to it.Change
IssueList, the list behindIssues, records which issues an unknown-members check made. Such an issue sits in its slot inside a small wrapper record, so unmarked issues cost nothing, and appending copies the mark with the slot.addandmergekeep the marks;rebaseandresolve(IssuesandIssueTree.map) now replace the issues throughIssueList.replacedBy, which keeps them too. A list built anew from the issues, such asnew Issues(new ArrayList<>(...))in a user's decoder, has none.Issueis unchanged,Issuesequality isListequality of the issues, andtoJsonListand the other views read the issues only. It is read and written throughnet.unit8.raoh.internal.IssueProvenance, whichIssueListimplements, so no public API changes.Decoders.strictruns the inner decoder first, collects the paths of the markedunknown_fieldissues at the top level of itsErr, and reports only the unknown fields at other paths, after the inner issues. Issues inside metadata, such as aone_of_failedissue's candidates, are not at the top level and do not count, as in the specification's flow.ThreadLocalsidecar or anorigincomponent onIssue: the first is mutable static state reached from a decoder package, which the effect audit refuses; the second would make "which combinator made this" part of what an issue means, its equality and its serialization.Tests
JsonStrictTest: R000862–R000866 on the JSON boundary.NestedStrictTest: a user decoder'sunknown_fieldis reported again; an innerstrict's issues still count afterflatMaprebases them to the outer path; astrictinside aoneOfcandidate does not count; the inner result is kept when no field is unknown.IssueProvenanceTest: the marks surviveadd,merge,rebase,resolve(with and without issues in metadata), are gone from a list built anew, and do not affectequals,hashCodeortoJsonList.develop'sstrict, R000862, R000863 and theflatMapcase fail. Replacing the mark check with path and code alone fails the user-decoder case.mvn verify(effect audit included) and-Pnullcheck clean compileon Zulu 25 pass. The effect audit's new uses are filed under the existing reasons; the staleIssueTree#map -> List#copyOfapproval is removed.Review fix: what strict observes
Cause: running the inner decoder first also moved the point where
strictreads the input's field names to after caller code. The specification orders the issues (the inner ones first), not the execution. With a mutable input, a decoder that removedextramadestrictaccept it, and one that added a member madestrictreport a field the input never had.Same root:
strict's decision must rest only on whatstrictitself observed.recover,discriminate,nullable,withDefault, the Combiners, the Map / JSON / jOOQ field readers) reads the input once, before delegating.strictwas the only one.stricttrusting what caller code supplies:IssueList.copyOfbelieved anyIssueProvenanceimplementation, so a user's decoder could forge "a strict made this" and hide itsunknown_fieldfrom the outerstrict.Change:
strictcopies the names (new ArrayList<>(inputFields.fieldNames(in)), which keeps whatever names theInputFieldsgives) beforedecruns, and builds its issues afterwards. The Javadoc now states this.IssueList.copyOfkeeps marks only from the listIssueProvenance.unknownMembersmakes. The new tests inNestedStrictTestfailed before the fix. Effect audit: the copy is approved as reading configured field listings, and the iteration as iterating a list Raoh made.🤖 Generated with Claude Code