Repository navigation
fix(avro): resolve field names by NAME when the codec proves it, and reject nested selectors (#95) - #105
Merged
Conversation
…s it (#95) `.field(_.x)` resolved `x` to the schema field at its DECLARATION INDEX and nothing else: `AvroWalk.fieldNameAt`'s whole body was `fields.get(declIdx).name`, guarded only against a non-record parent and an index past the end. The field's NAME, its TYPE and the record's ARITY were never consulted. That is sound exactly while the codec's schema is positionally 1:1 with the case class — true by construction for every kindlings-derived codec, and NOT true for a hand-written or `vulcan.Codec` field list, which can add a computed column, drop one, or reorder. On those the optic silently targets the WRONG SLOT: schema {a, computed, b, c} vs case class Three(a, b, c) codecPrism[Three].field(_.b).getOption(bytes) // Some("A0|B0") <- `computed` codecPrism[Three].field(_.b).replace("B1")(bytes) // rewrites `computed` and produces valid Avro bytes with wrong content. Nothing catches it: get-put, put-get, put-put and modify-fusion all HOLD on a mis-targeted optic (it is a perfectly lawful Optional onto the wrong field), round-trips hold, and every fixture in `AvroSpecFixtures` is kindlings-derived, hence 1:1, hence blind. One function, six call sites: `.field`, `selectDynamic`, `.fields`, `.each.field`, `.each.fields`, and every deeper hop after a `.union[B]`. The fix adds one rung ABOVE position, and only fires when the codec has proved the whole correspondence: 1. NOMINAL, all-or-nothing. If EVERY case field maps to a DISTINCT schema field — exactly, or uniquely up to `_`/`-`/`.` and case — the map is total and injective, so it is the codec's own answer, not a guess. 2. POSITIONAL (issue #35), unchanged. This is where a name transform lands, because a transform REMOVES the literal Scala name by construction. All-or-nothing is the load-bearing part, and it is measured, not assumed. The per-field form of rung 1 ("does THIS field's name appear?") INTRODUCES corruption: `FpVisit(userId, user)` against legacy columns `{uid, user_id}` is correct by position today, and a per-field rung re-aims `userId` at `user_id`. Requiring totality makes the rung abstain there — `user` matches nothing — and position stays right. An arity gate was rejected on the same evidence: it refuses 13 of 28 legitimate call sites (a trailing `ingested_at`, a dropped `cachedHash`, a trailing checksum) to buy 3 cells. Per-operation cost is zero: resolution runs once, at prism construction, off the cached schema. `to`, `from`, `scan`, `spliceAff`, `spliceFoci` and both erased bridges contain no reference to it on either carrier. Known residual, pinned as specs rather than prose: a codec that both renames beyond recognition AND reorders (equal arity, no name hit) still resolves by position and is still wrong. Known behaviour change: a codec that PERMUTES the Scala names was right by position and is now wrong by name — no transform can produce that shape, only a hand-written field list. Back-compat: every touched signature is `private[avro]`, but `transparent inline` bakes the accessor into CALLER bytecode, so downstream must recompile, not re-jar. Tests: - `DivergentCodecs` (new) — hand-written vulcan codecs whose schema is not positionally 1:1. The standing gap: every existing fixture is derived. - `AvroNominalResolutionSpec` (new) — the 9 repro cells (computed field on the byte and record faces, the leaf past the divergence, `.fields` grouped read+write, `.each.field`, a nested record's own divergence, a reversed field list, snake_case + a computed field) plus the two false-positive controls. Every repro cell failed before this change; both controls passed. - `ResolutionFalsePositiveSpec` (new) — 28 legitimate, currently-working call sites scored against SLOT TRUTH. 28/28 CORRECT before and after: that scorecard not moving is the entire safety argument for the mechanism. - `AvroWalkSpec` — three direct `resolveFieldName` / `fieldNameAt` calls take the new case-name list (mechanical, `Nil`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V
…misses The scaladoc that shipped with issue #35 was wrong in three ways, and each one is a reason the hazard in #95 went unnoticed for a release: - It warned only about schema field ORDER divergence. Order is not the shape that bit: a COMPUTED/derived schema column with no case-class parameter (or a dropped one) shifts every later slot on a codec whose field order is perfectly sensible. - It never said the rule governs `.fields`, `selectDynamic` or `.each.field`. They share one resolver, and a grouped `.fields` write on a divergent parent damaged two slots and silently no-op'd a third. - It advertised kebab-case as a supported transform. Kebab cannot produce a legal Avro schema at all — `SchemaParseException: Illegal character in: click-id` — so the claim was never true. Rewritten to state the actual ladder (name-when-total, else position), the precondition the positional rung needs, the three shapes that stay wrong after it, and the one accepted behaviour change. Same pass over the docs site's "Field navigation is by SCHEMA name" section and `AvroWalk`'s internal banner, plus a CHANGELOG entry carrying the recompile-not-rejar note. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V
Three codec shapes are still resolved to the wrong schema field after the
nominal rung, and the decision memo asserted them by INSPECTING the rule rather
than running it. This runs them. All eight cells behave exactly as predicted,
so the ledger below is measured, not argued:
cause 1, no name signal (the names are unrecoverable AND the list is permuted,
so the rung abstains and position decides, wrongly)
5a {beta_name, alpha_name} vs {alpha, beta} -> reads beta for alpha
b2 {seq_no, event_ts} vs {occurredAt, seqNo} -> reads the sequence number
(a timestamp-millis logical type annotates the same physical long, so
it cannot disambiguate either)
b6 compensating arity: one case field dropped, one computed column added,
counts equal, position wrong from the insertion point on
at3 `userId_` and `user_id` both normalise to `userid` — ambiguity is no
signal, so the rung disqualifies itself and position lands on the stale
duplicate
cause 2, a MISLEADING name signal (a column BEARS a case field's name but
HOLDS a different value; the map is total and injective, so it is trusted)
at1 {digest, id, raw_ident, payload} — `id` is a derived public identifier
at2 {USER_ID, user_ident, balance} — `USER_ID` is a stale legacy column
Both cause-2 cells were wrong on 0.15.1 too (they read `digest` / position 0),
with one real cost: the corrupt value is now more PLAUSIBLE, `pub-REAL-ID`
rather than a digest length. Note at1's `payload` is FIXED by the change — it
read `id` before.
Also pins the accepted regression (a PERMUTING rename was right by position and
is now wrong by name) and asserts that `.fieldNamed` reaches every residual,
which is what the error messages and the docs both point at.
Reaching cause 1 needs a differential probe of the codec itself (encode
sentinels, observe which slot they land in) or an explicit declaration; no
amount of name matching helps. Filed as a follow-up, not attempted here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V
`.fieldNamed("schema_name")` appended the literal to the path with NO schema
lookup at all — `widenPathNamed` was one `widenPathStep(PathStep.Field(name))` —
although the prism already holds the schema. A typo, or the Scala field name
passed where the schema name was meant, therefore produced a runtime SILENT
MISS: reads return `None`, writes hand back the payload unchanged and report
success.
That is the same failure class the hatch exists to avoid, and it is the failure
class every error message in this resolver points AT: "navigate by explicit
schema name with .fieldNamed(...)". Sending someone from a loud construction
failure to a silent runtime one is the wrong direction.
Two deliberate carve-outs:
- a MAP parent. `.fieldNamed` is also how a map KEY is addressed, keys are data
rather than schema fields, and feature-detecting an absent key is legitimate
(`hatch-map-key-present` / `-absent` / `-write` in the false-positive spec
pin all three).
- an unresolvable parent path. The walk already reports that at runtime, and
refusing here would change the meaning of a prism deliberately built against
a drifted root schema.
Record-level feature detection ("does this schema carry X?") moves to the
schema, where it belongs: `codec.schema.getField(name)`.
Two existing examples change, both deliberately:
- `AvroFieldNamingSpec`'s "a bad explicit .fieldNamed misses (None), it does
not corrupt" PINNED the defect. Rewritten to assert the refusal.
- `AvroBytesSpec` used `.fieldNamed("name")` on a deliberately narrowed root
schema to reach the walker's `PathMissing` arm. The coverage is kept by
storing the `PathStep.Field("name")` through the internal constructor that
example already uses two lines above.
Also amends the normative laws bullet, which promised "structurally drifted
payloads Miss silently" without distinguishing PAYLOAD drift (still a runtime
miss, still undetectable) from a name absent from the READER schema (now a
construction-time refusal on both `.field` and `.fieldNamed`).
The false-positive scorecard moves by exactly one cell of 28 —
`hatch-record-probe-absent`, None -> refusal — and by no other.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V
#95) `MacroSelectors.extractFieldName` — the selector parser shared by every cursor macro — matched `Lambda(_, Select(_, name))` with ANY receiver. A nested path `_.inner.y` is `Select(Select(Ident(_), "inner"), "y")`, so it matched, yielded the bare name `"y"`, and the macro resolved `y` on the PARENT record. Where the parent carries a field of that name — and a record holding a nested record often does — that is not a miss. It is a well-typed, perfectly lawful optic aimed at the wrong field: NOuter(inner: NInner(x, y), y) codecPrism[NOuter].field(_.inner.y).getOption(bytes) // Some("OUTER_Y") codecPrism[NOuter].field(_.inner).field(_.y) // Some("INNER_Y") Silent corruption on a perfectly 1:1, derived codec, with no schema divergence involved at all — a second, independent hazard from the resolution one, and 100% decidable at compile time. The macros' own "nested paths are not yet supported inside a single call; chain them" abort was UNREACHABLE for exactly the shape it was written for. `LensMacro` never had this: it uses the strict `extractSingleFieldName` (which requires the `Select` receiver to be the lambda parameter) plus a `knownFields.contains` check. The cursor macros simply never got the same treatment. Both halves land here: - `extractFieldName` now unwraps `Inlined` / `Typed` around the lambda, around its `Select` body AND around the `Select`'s RECEIVER, then requires that receiver to be an `Ident`. That keeps the wrapper tolerance the cursor macros need (which is the only reason this function exists beside the strict one) and drops the receiver looseness, which was never intentional. - `requireCaseField[A]` aborts when a single-hop selector names something that is not a case field of the parent. Those used to be passed through as a literal field name and miss at runtime; the declaration index comes back `-1` for them, which is also the legitimate "NamedTuple parent" signal, so the two cannot be told apart downstream. They are told apart here. Skipped when `A` has no case fields at all, which is the shape the literal fallback exists for. Five call sites, all covered: `AvroPrismMacro.fieldImpl` / `fieldTraversalImpl`, `JsonPrismMacro.fieldImpl` / `fieldTraversalImpl` (eo-circe), and `JsoniterPrismMacro.fieldName` (eo-jsoniter, shared by prism and traversal). The two traversal messages also gained the "chain them" hint their prism twins already carried. `grep` finds zero nested-selector call sites in the repo, and the circe, jsoniter, avro and generics suites all pass untouched — this closes a hole, it does not move any working code. Tests: `NestedSelectorMacroErrorSpec` in avro, circe and jsoniter — the nested prism selector, the nested traversal selector, the not-a-case-field selector, and a runtime row asserting the CHAINED form reaches the inner field. Every compile-error row produced NO error at all before this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V
Resolution is construction-time only, but it is not free, and the first cut
paid more than it needed to. Measured with the prototype's own harness (best of
5 x 200_000 builds, same box, same session, `ConstructionCostProbe`):
base Set[Int] this
identity .field(_.name) 84.9 115.2 98.6 ns/build
snake .field(_.clickId) 85.1 316.0 264.0 ns/build
nested .field(_.meta).field(…) 116.9 462.2 430.3 ns/build
Two changes, both free:
- `Set[Int]` -> a linear scan over the filled prefix of the result array. The
set was allocating a boxed Integer and a new set node per case field, on a
list that is a handful of entries; the scan is O(n^2) on an n that is the
case-class arity.
- An early precondition: a total, injective map from case fields into schema
fields cannot exist when the case class has MORE fields than the record, so
that shape skips the scan entirely. Same answer, no work.
The remaining gap is inherent to the rule: the total-nominal rung resolves
EVERY case field before it trusts any one of them, so a snake_case parent runs
a normalised name compare per (case field x schema field) pair where position
ran none. That is the price of not re-aiming a working call site at a lucky
single match, it is paid once per drilled prism at construction, and it stays
strictly off the read/write path.
Zero per-operation cost re-verified from bytecode rather than asserted:
`javap -p -c` over the compiled avro classes puts every reference to
`resolveFieldName` / `fieldNameAt` / `requireField*` in `AvroPrism`'s
`widenPath` / `widenPathNamed` / `toFieldsPrism` / `resolveFieldNames` and
`AvroTraversal`'s `widenSuffix` / `widenSuffixNamed` / `toFieldsTraversal`.
`AvroFocus`, `AvroFocus$Leaf`, `AvroFocus$Fields`, `AvroRecordPrism`,
`AvroRecordTraversal` and `AvroBinaryCursor` contain none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V
Contributor
|
🚀 Cloudflare Pages preview for https://35da3fe0.cats-eo-docs.pages.dev Branch alias: https://fix-nominal-resolution-onto.cats-eo-docs.pages.dev Built from commit |
Contributor
Benchmark A/BAllocation (B/op) — authoritative
198 more benchmarks
Timing (ns/op) — directional only, same-VM but shared runner
base_sha: |
This was referenced Sep 18, 2026
kryptt
added this pull request to stack #112
September 18, 2026 13:29
This was referenced Sep 18, 2026
Merged
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.
Fixes the silent field mis-targeting surfaced while investigating #95, plus a second, independent
hazard with the same symptom found while reproducing it.
This does not close #95. That issue asks for an efficient whole-record construction primitive,
and nothing here addresses it. What it does close is the defect its reporter's codec shape exposed:
their schema carries computed/derived columns ("no equivalent of a computed/derived field mechanism"
in the issue body), which is exactly the shape that made every
.field(_.x)past the divergenceread and write the wrong slot.
The hazard
.field(_.x)resolvedxto the schema field at its DECLARATION INDEX and nothing else.AvroWalk.fieldNameAt's whole body wasfields.get(declIdx).name, guarded only against anon-record parent (
AvroWalk.scala:414) and an index past the end (AvroWalk.scala:422). Thefield's name, its type and the record's arity were never consulted.
That is sound exactly while the codec's schema is positionally 1:1 with the case class — true by
construction for every kindlings-derived codec, and not true for a hand-written or
vulcan.Codecfield list, which can add a computed column, drop one, or reorder:Valid Avro bytes, wrong content. Nothing catches it: get-put, put-get, put-put and modify-fusion
all hold on a mis-targeted optic (it is a perfectly lawful
Optionalonto the wrong field),round-trips hold, and every fixture in
AvroSpecFixturesis kindlings-derived, hence 1:1, henceblind. One function, six call sites:
AvroPrism.widenPath←.field(_.x)AvroPrism.scala:249selectDynamicAvroPrismMacro.scala:40AvroPrism.resolveFieldNames←.fields(...)AvroPrism.scala:302AvroTraversal.widenSuffix←.each.field(_.x)AvroTraversal.scala:158AvroTraversal.toFieldsTraversal←.each.fields(...)AvroTraversal.scala:219.union[B]/ a nested.fieldAvroWalk.scala:351-380The mechanism, and why all-or-nothing
One rung above position, firing only when the codec has proved the whole correspondence:
uniquely up to
_/-/.and case — the map is total and injective, so it is the codec's ownanswer, not a guess. Partial or colliding coverage disqualifies the rung for every field.
literal Scala name by construction, so rung 1 cannot have fired.
The totality requirement is the load-bearing part, and it is measured, not argued. The per-field
form of rung 1 ("does THIS field's name appear?") INTRODUCES corruption:
FpVisit(userId, user)against legacy columns
{uid, user_id}is correct by position today, and a per-field rung re-aimsuserIdatuser_id— a currently-working call site silently moved to the wrong column. Requiringtotality makes the rung abstain there (
usermatches nothing) and position stays right.An arity gate was rejected on the same kind of evidence: it refuses 13 of 28 legitimate call
sites — abbreviated wire names plus a trailing
ingested_at, a droppedcachedHash, a trailingchecksum on an element codec, a digest in a nested codec — every one correct today because the
extra field is at the tail and shifts nothing. It buys 3 cells.
False-positive scorecard (
ResolutionFalsePositiveSpec, 28 legitimate call sites)Scored against SLOT TRUTH — which schema slot a write actually touched, never
copy(...), whichcannot tell a wrong slot from a stale derived one.
The single moved cell is
hatch-record-probe-absent—.fieldNamedon a name the reader schemadoes not carry,
Nonebefore and a construction-time refusal now. That is the deliberate changebelow. The two
TRIPWIRE:examples (zero SILENT-WRONG, zero SILENT-MISS) pass both ways and arethe whole safety argument for touching the resolver.
AvroFieldNamingSpec's other 8 examples — issue #35's own snake_case regression suite — passuntouched.
.fieldNamedis checked at constructionThe escape hatch every error message points at appended the literal with no schema lookup at
all, although the prism already holds the schema. A typo — or the Scala field name passed where
the schema name was meant — was a runtime SILENT MISS: reads
None, writes hand back the payloadunchanged and report success. Sending someone from a loud construction failure to a silent runtime
one is the wrong direction.
Two carve-outs: a MAP parent (
.fieldNamedis also how a map KEY is addressed, keys are data,and feature-detecting an absent key is legitimate — three cells pin it) and an unresolvable parent
path (the walk already reports that, and refusing would change the meaning of a prism deliberately
built against a drifted root schema). Record-level feature detection moves to
codec.schema.getField(name).Two existing examples change:
AvroFieldNamingSpec's "a bad explicit.fieldNamedmisses (None),it does not corrupt" pinned the defect and now asserts the refusal, and
AvroBytesSpeckeeps itsPathMissingcoverage by storing thePathStep.Field("name")through the internal constructor thatexample already uses two lines above.
The nested-selector hole (second, independent bug)
MacroSelectors.extractFieldNamematchedLambda(_, Select(_, name))with any receiver. Anested path
_.inner.yisSelect(Select(Ident(_), "inner"), "y"), so it matched, yielded the barename
"y", and every cursor macro resolvedyon the parent:Silent corruption on a perfectly 1:1, kindlings-derived codec, with no schema divergence involved
at all — and the macros' own "nested paths are not yet supported inside a single call; chain them"
abort was unreachable for exactly the shape it was written for. 100% decidable at compile time.
LensMacronever had this (it uses the strictextractSingleFieldNameplus aknownFields.containscheck); the cursor macros simply never got the same treatment.
Both halves land: the extractor now requires the
Selectreceiver to be the lambda parameter(keeping the wrapper tolerance that is the only reason it exists beside the strict form), and
requireCaseField[A]aborts when a single-hop selector names a non-case-field — those used to passthrough as a literal field name and miss at runtime. Five call sites:
AvroPrismMacro.fieldImpl/fieldTraversalImpl,JsonPrismMacro.fieldImpl/fieldTraversalImpl(eo-circe),JsoniterPrismMacro.fieldName(eo-jsoniter, shared by prism and traversal).grepfinds zeronested-selector call sites in the repo; the circe, jsoniter, avro and generics suites all pass.
Documented residual limitations (
ResolutionResidualSpec, pinned as executable examples)The memo this PR implements asserted these by inspecting the rule. They are now run, and every
one behaves as predicted:
Cause 1 — no name signal (names unrecoverable AND the list permuted, so the rung abstains and
position decides, wrongly). Reachable only by a differential probe of the codec or an explicit
declaration.
5a{beta_name, alpha_name}vs{alpha, beta}— readsbetaforalphab2{seq_no, event_ts}vs{occurredAt, seqNo}— reads the sequence number; atimestamp-millislogical type annotates the same physicallong, so it cannot disambiguateb6compensating arity (one case field dropped, one computed column added): counts equal, positionwrong from the insertion point on
at3userId_anduser_idboth normalise touserid— ambiguity is no signal, so positionlands on the stale duplicate
Cause 2 — a MISLEADING name signal (a column bears a case field's name but holds a different
value; the map is total and injective, so it is trusted).
at1{digest, id, raw_ident, payload}—idis a derived public identifierat2{USER_ID, user_ident, balance}—USER_IDis a stale legacy columnBoth cause-2 cells were wrong on 0.15.1 too, with one real cost: the corrupt value is now more
plausible (
pub-REAL-IDrather than a digest length).at1'spayloadis fixed by the change— it read
idbefore..fieldNamedreaches all six, and that is asserted too.Accepted behaviour change: a hand-written codec that permutes the Scala names (writes case
field
ainto a schema field literally namedb, and vice versa) resolved correctly by position andnow resolves by name, i.e. wrongly. No name transform can produce that shape — a transform is a
function of the name alone — but a hand-written field list can.
Cost
Zero per operation, verified from bytecode rather than asserted.
javap -p -cover the compiledavro classes puts every reference to
resolveFieldName/fieldNameAt/requireField*inAvroPrism'swidenPath/widenPathNamed/toFieldsPrism/resolveFieldNamesandAvroTraversal'swidenSuffix/widenSuffixNamed/toFieldsTraversal.AvroFocus,AvroFocus$Leaf,AvroFocus$Fields,AvroRecordPrism,AvroRecordTraversalandAvroBinaryCursorcontain none.Construction time is not free, and the number is reported rather than waved at (best of 5 ×
200 000 builds, same box, same session):
.field(_.name).field(_.clickId).field(_.meta).field(_.performanceSourceId)The gap is inherent to totality: the rung resolves every case field before trusting any one of
them, so a snake_case parent runs a normalised name compare per (case field × schema field) pair
where position ran none. A first cut using
Set[Int]for injectivity cost 316 / 462; the shippedversion scans the filled prefix of the result array instead and adds an early "more case fields than
schema fields ⇒ no total map" precondition. Paid once per drilled prism, strictly off the read/write
path.
Back-compat
Every touched signature is
private[avro], buttransparent inlinebakes the accessor intocaller bytecode (
inline$widenPath$i1gains aList[String]parameter), so downstream mustrecompile, not re-jar. MiMa is off build-wide (
tlMimaPreviousVersions := Set.empty), so this isa release note, not a build gate;
mimaReportBinaryIssuesis green.Gates (re-run on this branch, on top of
main)JDK 25 (Temurin 25.0.4.1), so the
kyomodule was in the root aggregate.sbt '++ 3' compile test(root aggregate)tests, 212 avro, 92 core, 56 jsoniter, 42 circe, plus generics / schemes / schemesLaws / zio / kyosbt avroIntegration/testsbt benchmarks/compile(outside the aggregate)sbt mimaReportBinaryIssuessbt docs/mdoc docs/laikaSitesbt 'scalafixAll; scalafmtAll'scalafmtCheckAll+scalafixAll --check+scalafmtSbtCheck+benchmarks/scalafmtCheck+githubWorkflowCheck[success]Follow-ups NOT in this PR
.fields#102 ontomainonce this merges, so the hearth/kindlings bump carries only the bump.TargetingLaws/TargetingTestsincats-eo-laws— an optic vs. an extrinsic referenceaccessor. The only thing that can catch a resolution mechanism's own mistake, and the hazard
class is carrier-wide (circe/jsoniter resolve by the literal Scala name,
zio.schema'sEoAccessorBuilderand kyo'sRecord.lens[F]("name")are the same shape). The fixtures land here;the reusable ruleset is next.
codec.encode, diff the slots,observe which schema field a case field actually lands in. The only mechanism that reaches residual
cause 1, needs no declaration and no name heuristics, and would turn a wrong hand-written field
mapping from believed into refused. Must degrade to the ladder rather than refuse.
AvroCodec.parseInputUnsafe's fabricated empty record (AvroCodec.scala:329-339) — a parsefailure yields
new GenericData.Record(schema), somodifyUnsafeon 4 junk bytes returns{"items": null}: a valid payload with all data erased. Adjacent silent-corruption hole, unrelatedto resolution, cheap to fix.
AvroVulcan.codec's eagerval schema;.at(i)has no ARRAY check (silent Miss on the whole byteface);
AvroVulcan.codecMappedas a user-side escape for residual cause 1.🤖 Generated with Claude Code
https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V