Skip to content

fix: report an unknown field once, by the innermost strict that does not know it - #185

Merged
kawasima merged 2 commits into
developfrom
feature/nested-strict
Oct 2, 2026
Merged

kawasima merged 2 commits into
developfrom
feature/nested-strict

Conversation

@kawasima

@kawasima kawasima commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #183 (item 1). Items 2–4 and item 5 follow in their own PRs.

Cause

Decoders.strict built its unknown_field issues before running the inner decoder and merged them with whatever the inner decoder returned, without looking at it. Every strict level therefore reported a field it did not know, whether or not a strict inside had already reported it:

  • R000862: strict(discriminate("kind", {rect: strict(...)}), ...) gave unknown_field at /extra twice.
  • R000863: strict(strict(object([a]), [a]), [a]) gave unknown_field at /b twice.

The specification (raoh-specification#24) leaves out only an unknown_field that 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). Issue does not say what made it, so filtering by path and code would also drop the user's issue. Decoders.strict is the only producer of unknown_field: MapDecoders.strict, JsonDecoders.strict and Combiner*.strict all delegate to it.

Change

  • IssueList, the list behind Issues, 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. add and merge keep the marks; rebase and resolve (Issues and IssueTree.map) now replace the issues through IssueList.replacedBy, which keeps them too. A list built anew from the issues, such as new Issues(new ArrayList<>(...)) in a user's decoder, has none.
  • The mark is not visible: Issue is unchanged, Issues equality is List equality of the issues, and toJsonList and the other views read the issues only. It is read and written through net.unit8.raoh.internal.IssueProvenance, which IssueList implements, so no public API changes.
  • Decoders.strict runs the inner decoder first, collects the paths of the marked unknown_field issues at the top level of its Err, and reports only the unknown fields at other paths, after the inner issues. Issues inside metadata, such as a one_of_failed issue's candidates, are not at the top level and do not count, as in the specification's flow.
  • Why not a ThreadLocal sidecar or an origin component on Issue: 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's unknown_field is reported again; an inner strict's issues still count after flatMap rebases them to the outer path; a strict inside a oneOf candidate does not count; the inner result is kept when no field is unknown.
  • IssueProvenanceTest: the marks survive add, merge, rebase, resolve (with and without issues in metadata), are gone from a list built anew, and do not affect equals, hashCode or toJsonList.
  • Against develop's strict, R000862, R000863 and the flatMap case fail. Replacing the mark check with path and code alone fails the user-decoder case.
  • mvn verify (effect audit included) and -Pnullcheck clean compile on Zulu 25 pass. The effect audit's new uses are filed under the existing reasons; the stale IssueTree#map -> List#copyOf approval is removed.

Review fix: what strict observes

Cause: running the inner decoder first also moved the point where strict reads 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 removed extra made strict accept it, and one that added a member made strict report a field the input never had.

Same root: strict's decision must rest only on what strict itself observed.

  • Raoh reading the input after caller code: every other combinator (recover, discriminate, nullable, withDefault, the Combiners, the Map / JSON / jOOQ field readers) reads the input once, before delegating. strict was the only one.
  • strict trusting what caller code supplies: IssueList.copyOf believed any IssueProvenance implementation, so a user's decoder could forge "a strict made this" and hide its unknown_field from the outer strict.

Change: strict copies the names (new ArrayList<>(inputFields.fieldNames(in)), which keeps whatever names the InputFields gives) before dec runs, and builds its issues afterwards. The Javadoc now states this. IssueList.copyOf keeps marks only from the list IssueProvenance.unknownMembers makes. The new tests in NestedStrictTest failed 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

…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>
…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>
@kawasima
kawasima merged commit 3068c49 into develop Oct 2, 2026
3 checks passed
@kawasima
kawasima deleted the feature/nested-strict branch October 2, 2026 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant