ADR 0229: A diagnostic path is a JSON Pointer into the document as written
Status: Accepted
Context
Section titled “Context”Every finding a dynamic form reader returns carries a path naming where in the document it applies.
The paths look like JSON Pointers (/schema/children/email/field) and the TypeScript, Java and Rust
readers build them the same way, but two sites did not keep that promise:
- Child keys were written raw. A tree names its children by key, and
/and~are legal in a key. Childa/bwas reported at/schema/children/a/b, which reads as childa, then a memberbthat does not exist. - The version gate for
integerpointed at a name, not a place. A field usingintegerin a document older than the version it arrived with was reported at/fields/<name>/validators/integer.fieldsis an array, so a name there addresses nothing, and a field from aschematree was reported under/fields, which the document does not have. Rust named the flat case the same way and gave a tree leaf a path without its/fieldsegment. - The same gate missed a template nobody had filled. TypeScript and Java checked the fields a
tree flattens to, and a collection with no rows flattens to none, so an
integerin itsitemtemplate passed in a v4 document. Rust walks the declarations and refused it.
A path that does not resolve cannot be used to highlight the offending member in an editor, and two distinct members that print the same path cannot be told apart.
Decision
Section titled “Decision”- A diagnostic
pathis an RFC 6901 JSON Pointer that resolves in the document as it was given to the reader. Every key-built segment is escaped as a pointer token:~becomes~0, then/becomes~1, in that order. One exception is kept: a bare array is read as thefieldslist of a version 1 document, and every reader addresses its entries as/fields/<index>, so a path into a bare array resolves against{ "fields": <the array> }. - A finding about a field points where that field was written. In a flat list that is its entry by
index — the first object carrying the name, since every reader claims a name before any other check
and a later entry with it is the refused duplicate. An accepted field always has that entry; if
none were found the finding would fall back to
/fields, the list itself. In a tree it is the leaf’s declaration,…/field, reached by key through groups and throughitemfor a collection. - A tree is checked by its declarations, not by the rows it flattens to. A collection’s
itemis the one template every row is read from, so a finding about it is reported once, at the template, whether the collection starts with three rows or none. - The three readers produce the same path, and the same verdict, for the same document.
Consequences
Section titled “Consequences”- A path can be resolved against the document to find the member it names, and two members never share a path.
- Paths for child keys containing
/or~change spelling, and theintegerversion gate moves from/fields/<name>/…to/fields/<index>/…or the leaf’s tree path. A host matching those paths as strings has to follow; the changeset states it. - A v4 or older document with
integerin the template of an empty collection is now refused by TypeScript and Java, as Rust already refused it. That is a verdict change, not only a spelling change. - The gate walks the raw tree, so it reports a leaf the reader drops for another reason too. Such a document is already refused; it gains a finding, not a verdict.
- Each reader carries its own escape helper. They are three copies of a two-line rule, held equal by each reader’s tests rather than by shared code.
Alternatives rejected
Section titled “Alternatives rejected”- Refuse
/and~in names. Smaller in code, but it turns accepted documents into refused ones to fix a reporting defect in documents that are valid today. - Keep the gate’s path by name and only escape it. Escaped,
/fields/a~1bis still a name in an array position: well-formed and still pointing at nothing. - Report the gate once per row of a collection. Every row points at the same template word, so the duplicates add no information and make one mistake look like many.
- Recover a tree field’s path by retracing its flattened name. It gives the right path for every field that has a row, and nothing for a template with none — the gap above.
- A second, structured location field beside
path. Adds public surface to repair a promise the existing field already makes.
Verification
Section titled “Verification”packages/core/test/a-diagnostic-path-is-a-json-pointer.test.mjs: keysa/bandx~yreport at…/a~1band…/x~0y; theintegergate points at the entry index in a flat list and at the leaf in a tree, once per collection template, including a template of an array or record with no rows.MdyDynamicFormParserTestinsdk/java/modyra-contractholds the same cases for Java;a_finding_points_where_the_field_is_writteninsdk/rust/modyra-contract/tests/contract.rsfor Rust, run in CI.- Falsified on 2026-10-13 by mutation: dropping the escape, escaping
/before~, dropping the per-template dedupe, taking the last entry with the name instead of the first, and omitting theitemsegment each turn the TypeScript tests red; the Java equivalents of the first four turn its tests red. For the declaration walk: misspelling theitemsegment, reporting onlyinteger: true, and (in Java) not descending a record’s template each turn the tests red.
Security and privacy
Section titled “Security and privacy”A path is reported data. Nothing in this repository parses one beyond matching a fixed top-level
prefix — /layout/, /rules/, /validations/, /schema — which escaping a key segment never
changes. Escaping makes a path harder to forge, not easier — a key
can no longer produce segments that impersonate a sibling member in a host’s error display. No user
data is involved.