FROZEN — immutable history. Superseding/current decisions live in
docs/decisions/. Do not edit.
ADR 0003 — Indexed fieldPath normalization (accept, don't reject)#
Status: Accepted (2026-07-02). Original decision record for
extract()'s proposal normalization step.
§1 Context#
targetSchema declares array fields in the un-indexed bracket form, e.g.
"schedules[].startDate" — TargetFieldSchema.path is caller-owned, but
Traverse's own fixtures and prior docs already used this convention (see ADR
0001 §3, §4). In practice, extraction providers do not always echo that exact
string back. A real pilot adjudication (Anthropic-compatible endpoint, model
glm-5.2) observed a provider emitting indexed paths instead —
"schedules[0].startDate", "schedules[1].startDate", etc. — one concrete
index per array item, which is a natural (arguably more informative) way for a
model to describe "the start date of the first schedule entry."
Before this change, normalizeProposals() checked fieldPath against
knownFieldPaths with an exact Set.has() lookup. An indexed path never
matched the un-indexed declared path, so every one of these proposals was
dropped as an "unknown fieldPath" — silently, save for a warning nobody was
necessarily reading. On at least one real source in that pilot, this dropped
every date/age proposal extracted from a repeating schedule — a systematic
loss, not an edge case, because the model consistently indexed that field.
§2 Decision#
extract()'s normalization step now treats an indexed fieldPath as a
recoverable, unambiguous variant of a declared array path, not as an
invalid one:
- If
fieldPathmatchestargetSchemaexactly, behavior is unchanged. - Otherwise, strip every
[n](integer) segment infieldPathdown to[]— consistently at every level of nesting ("a[2].b[0].c"->"a[].b[].c"). If the normalized path matches a declaredtargetSchemapath, the proposal is accepted: itsfieldPathis rewritten to the declared (normalized) form, and the stripped index/indices are recorded on the newExtractionProposal.pathIndices?: number[]field, in left-to-right (outermost-first) source order. - If the normalized path still doesn't match anything in
targetSchema, the proposal is dropped with the same "unknown fieldPath" warning as before — this is not a fallback that widens what "unknown" means, only a narrow, unambiguous recovery for the one specific shape (integer array indices) that a declared array path can always be recovered from mechanically. - Every other normalization rule (non-empty
extractor, a provenanceexcerptthat occurs verbatim in the prepared content, a finite/clampedconfidence) still runs, unchanged, against the normalized proposal — normalizing the path recovers eligibility to be checked, it does not bypass any other check. Seesrc/extract.tsand ADR 0001 §4.
§3 Why accept-and-normalize rather than reject#
Rejecting indexed paths is defensible in the abstract — targetSchema is
caller-owned (ADR 0001 §3) and Traverse could simply hold providers to the
caller's exact string. But the observed failure mode makes the strict reading
actively harmful in practice: the caller's schema and the provider's answer
disagree only in a superficial, totally unambiguous way (an index the
provider chose to include is information, not noise), yet the strict check
turned that into losing the answer entirely, for every array item, with no
recovery path available to the caller short of pre-processing every provider
response. That is a worse outcome than the thing the strict check was trying
to prevent (accepting a path the caller didn't declare) — there is no
plausible interpretation of "schedules[0].startDate" against a schema that
only declares "schedules[].startDate" other than "the start date of
schedule item 0." Traverse is a proposer, not a resolver (ADR 0001 §2): a
normalized fieldPath and a preserved pathIndices do not resolve anything
on the caller's behalf — they still hand the caller a reviewable proposal
against a path the caller declared, plus the one extra fact (which array
item) needed to make use of it. Silently or loudly dropping the proposal
instead would have destroyed that fact rather than surfaced it.
pathIndices exists specifically so a downstream consumer can regroup
proposals by source array item (e.g. multiple "schedules[].*" proposals
that all came from the same schedules[N] entry) without re-parsing
fieldPath strings itself. Recovery is silent (no warning) on the happy
path — a proposal that normalizes cleanly is not a defect to report, it is
supported input. A fieldPath that still doesn't match after normalization
remains dropped with a warning, unchanged from before this ADR: normalization
recovers one specific, unambiguous shape, it does not loosen the schema
membership check in general.
§4 Consequences#
ExtractionProposal.fieldPathis now guaranteed to be atargetSchema-declared path even when the provider emitted an indexed one; consumers never see the raw indexed string.ExtractionProposal.pathIndices?: number[]is new, optional API surface — present only when normalization occurred. Existing consumers that ignore it are unaffected.- The excerpt/locator provenance contract (ADR 0001 §4) is enforced identically
before and after this change; normalization only affects the
fieldPathmembership check.