Summary
The lenient extract route classifies every field it could not populate (LOST_REQUIRED / LOST_OPTIONAL / MALFORMED / DEFAULTED), which is what makes losses audible. But that classification is presence-only. There is no vocabulary for a cardinality invariant, so a payload whose collection came back empty extracts "successfully" with a completely clean report — hasLostRequired() is false.
For the lenient reader's main use case (extracting structured output from a language model) this is the dangerous case, not an edge case: a model that emits a well-formed wrapper full of garbage yields an empty collection, and every downstream consumer sees a valid-but-empty object.
Why @required on the items doesn't cover it
Extract.extract classifies an absent field at its own path and continues — it does not recurse into nested children — and per-item @required is only evaluated for items that are present. So for:
<container>
<items>
<item id="A"/>
<item id="B"/>
</items>
</container>
- Marking
item.id @required detects "an item that exists but lost its id".
- It cannot detect "zero items" — with no items present, no per-item check ever runs.
- Marking the
items wrapper @required detects "no <items> element at all", but not <items></items>.
There is no combination of today's markers that makes an empty collection audible.
Ask
A cardinality vocabulary on array fields — e.g. @minItems: 1 — classified as a loss (LOST_REQUIRED, or a new EMPTY_REQUIRED state) so hasLostRequired() fires.
Alternatively, define @required on an isArray field to mean non-empty rather than "the element is present". That is the more intuitive reading, but it is a behavior change for anyone relying on the current semantics.
Workaround today
Assert cardinality in code after every extract — which is precisely the hand-rolled loss check the ExtractionReport otherwise makes unnecessary. It also has to be repeated at each call site, and its absence is silent.
Related: #199
Summary
The lenient extract route classifies every field it could not populate (
LOST_REQUIRED/LOST_OPTIONAL/MALFORMED/DEFAULTED), which is what makes losses audible. But that classification is presence-only. There is no vocabulary for a cardinality invariant, so a payload whose collection came back empty extracts "successfully" with a completely clean report —hasLostRequired()isfalse.For the lenient reader's main use case (extracting structured output from a language model) this is the dangerous case, not an edge case: a model that emits a well-formed wrapper full of garbage yields an empty collection, and every downstream consumer sees a valid-but-empty object.
Why
@requiredon the items doesn't cover itExtract.extractclassifies an absent field at its own path andcontinues — it does not recurse into nested children — and per-item@requiredis only evaluated for items that are present. So for:item.id@requireddetects "an item that exists but lost its id".itemswrapper@requireddetects "no<items>element at all", but not<items></items>.There is no combination of today's markers that makes an empty collection audible.
Ask
A cardinality vocabulary on array fields — e.g.
@minItems: 1— classified as a loss (LOST_REQUIRED, or a newEMPTY_REQUIREDstate) sohasLostRequired()fires.Alternatively, define
@requiredon anisArrayfield to mean non-empty rather than "the element is present". That is the more intuitive reading, but it is a behavior change for anyone relying on the current semantics.Workaround today
Assert cardinality in code after every extract — which is precisely the hand-rolled loss check the
ExtractionReportotherwise makes unnecessary. It also has to be repeated at each call site, and its absence is silent.Related: #199