Context
The extract route (MetaObjectExtractor.extract → ExtractionResult(data, report)) does its job well: it parses a forgiving/LLM-emitted document against declared metadata, produces a typed VO, and classifies everything it could not populate (LOST_REQUIRED / LOST_OPTIONAL / MALFORMED / DEFAULTED) in the ExtractionReport.
But the VO is not the application's domain object. A consumer typically has a hand-written mapper in between:
LLM/wire document → [metadata + lenient reader] → typed VO → [HAND-WRITTEN MAPPER] → domain object
^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
declared, reported undeclared, unreported
Everything to the left of that mapper is modelled and observable. Everything inside it is invisible Java.
The problem this causes (a real incident)
A required boolean was absent from a malformed response. The lenient reader did exactly the right thing — it recorded the field as lost. Then the hand-written mapper did:
boolean flag = Boolean.TRUE.equals(vo.isSomeFlag()); // absent → null → false
…and the loss became indistinguishable from a real answer. No exception, no warning, a healthy-looking log line. It went undetected across an entire test run because nothing read the report and nothing declared the field required.
The framework had already caught it. The hand-written layer un-caught it.
What that layer actually contains
Measured across three mappers in one consumer (~2,300 lines total):
| category |
count |
expressible in metadata today? |
hand-coded literal defaults (x != null ? x : LITERAL, Boolean.TRUE.equals(x), blankToDefault(x, "…")) |
37 |
mostly YES — this is @default, which already exists and the consumer simply wasn't using |
drop-the-entry-and-warn invariants (continue; + log.warn) |
48 |
no |
id → entity resolution (short id like ENT-001 → persistent key, via a session-scoped lookup) |
20 |
no — genuinely needs runtime context |
| field renames / target-type construction |
throughout |
no |
So a large slice is a consumer bug (not using @default), and I'd fix that on my side regardless. But the remainder is a real modelling gap.
The idea
A declared VO → domain mapping (map.* / object.mapping / whatever fits the vocabulary), so the last undeclared hop becomes metadata like every other hop:
- target type + field renames — the mechanical part.
- absent-value policy — reuse the existing
@default / @required rather than inventing a second mechanism.
- drop policy — declare "an entry missing its identity is dropped" instead of hand-writing
continue + a log line, and have the drop appear in the ExtractionReport so it is auditable like every other loss.
- resolver hook — the one thing that inherently needs runtime context. Declared in the metadata, implemented by the consumer (
interface Resolver<K, V>), so that "this short id resolves to that entity" is modelled even though the lookup is not.
The prize is that the report becomes the single, complete account of what survived the wire — today it is only an account of what survived the parser, and a hand-written mapper downstream can quietly contradict it.
A tension worth surfacing regardless of this idea
Extract.java fills a field carrying @default on absence and classifies it DEFAULTED, which satisfies required. So a field can never be both "defaulted when missing" and "reported when missing" — declaring a default silently disables loss detection for that field.
That is defensible (a default is an answer), but it is a sharp edge: adding an innocuous @default to a shared/abstract enum can silently turn off LOST_REQUIRED for every field that extends it, and nothing warns you. Worth documenting explicitly; and if a declared mapping lands, it should make the choice explicit rather than implicit.
Status
Idea / discussion — not blocking anything. Filing so the thinking isn't lost. Happy to prototype the shape against a real consumer if the direction is interesting.
Context
The extract route (
MetaObjectExtractor.extract→ExtractionResult(data, report)) does its job well: it parses a forgiving/LLM-emitted document against declared metadata, produces a typed VO, and classifies everything it could not populate (LOST_REQUIRED/LOST_OPTIONAL/MALFORMED/DEFAULTED) in theExtractionReport.But the VO is not the application's domain object. A consumer typically has a hand-written mapper in between:
Everything to the left of that mapper is modelled and observable. Everything inside it is invisible Java.
The problem this causes (a real incident)
A required boolean was absent from a malformed response. The lenient reader did exactly the right thing — it recorded the field as lost. Then the hand-written mapper did:
…and the loss became indistinguishable from a real answer. No exception, no warning, a healthy-looking log line. It went undetected across an entire test run because nothing read the report and nothing declared the field required.
The framework had already caught it. The hand-written layer un-caught it.
What that layer actually contains
Measured across three mappers in one consumer (~2,300 lines total):
x != null ? x : LITERAL,Boolean.TRUE.equals(x),blankToDefault(x, "…"))@default, which already exists and the consumer simply wasn't usingcontinue;+log.warn)ENT-001→ persistent key, via a session-scoped lookup)So a large slice is a consumer bug (not using
@default), and I'd fix that on my side regardless. But the remainder is a real modelling gap.The idea
A declared VO → domain mapping (
map.*/object.mapping/ whatever fits the vocabulary), so the last undeclared hop becomes metadata like every other hop:@default/@requiredrather than inventing a second mechanism.continue+ a log line, and have the drop appear in theExtractionReportso it is auditable like every other loss.interface Resolver<K, V>), so that "this short id resolves to that entity" is modelled even though the lookup is not.The prize is that the report becomes the single, complete account of what survived the wire — today it is only an account of what survived the parser, and a hand-written mapper downstream can quietly contradict it.
A tension worth surfacing regardless of this idea
Extract.javafills a field carrying@defaulton absence and classifies itDEFAULTED, which satisfiesrequired. So a field can never be both "defaulted when missing" and "reported when missing" — declaring a default silently disables loss detection for that field.That is defensible (a default is an answer), but it is a sharp edge: adding an innocuous
@defaultto a shared/abstract enum can silently turn offLOST_REQUIREDfor every field that extends it, and nothing warns you. Worth documenting explicitly; and if a declared mapping lands, it should make the choice explicit rather than implicit.Status
Idea / discussion — not blocking anything. Filing so the thinking isn't lost. Happy to prototype the shape against a real consumer if the direction is interesting.