ADR 0231: A document costs what it writes, not what its rows expand to
Status: Proposed
Context
Section titled “Context”A collection nested in a row takes its own declared initialValue rows in every enclosing row that
writes none. The flatten therefore visits the product of the row counts down each branch, and not
the size of the document. Measured on 2026-10-14 with a group > array(100 rows) > group > array …
shape, d levels deep:
| Reader | d | Document | Result |
|---|---|---|---|
TS parseDynamicForm, lenient |
3 | 1,240 B | 1,000,000 fields, 1.9 s |
| TS, the same shape ending in an empty group | 3 | 1,218 B | 0 fields, 0.4 s |
Java parse, lenient, -Xmx1g |
4 | 1,616 B | OutOfMemoryError in the flatten |
| Rust | any | — | does not expand rows, so it is not exposed; it accepted documents the others cannot carry |
ADR 0043 removed depth and node caps on the grounds that an explicit stack makes nesting cost what it writes. Row lending breaks that premise: a document of a few kilobytes names more nodes than a request can hold, and a reader accepting untrusted documents spends memory and time the document did not pay for.
Decision
Section titled “Decision”- A flatten counts every node it visits — a group, a collection, a leaf, each node of a row
template, a leaf it refuses on the walk — and refuses the document once the visits exceed the
nodes the schema declares (through
childrenanditem, rows not read) by more than 10,000. - Past the bound the whole schema is refused in both modes:
MDY_DYNAMIC_SCHEMA_LIMIT, severityerror, at/schema,ok: false, no fields, and no other finding from the walk. A truncated tree is whatever part the walk happened to reach first, which is not a form the document declared. - The flatten called directly (
flattenDynamicForm,flattenDynamicSchema) throwsMdyDynamicSchemaLimitError, carryingcode,pathandlimit. - Lending is kept. The bound alone closes the cost; stopping it would change the initial values of valid documents.
- In Rust, whose result carries the typed document rather than flattened fields, past the bound the schema is dropped from the form in both modes.
- Rows are counted only once the tree’s shape is sound, in every reader: a document whose shape is
refused reports that, and not the bound as well. A kind nobody declared is a finding about one
field and does not stop the count.
rows-lent-past-the-bound-beside-an-unsafe-row-key.jsonpins it. - Every reader applies the same count, defined as follows. A reader that does not expand rows
computes it from its memo, saturating at the bound.
- Declared is the number of JSON objects reached from
schemathrough a group’schildrenvalues and a collection’sitem, rows not read. A child under an unsafe key and a child that is not a node still count: they are objects the document wrote. - Visits is one per node taken off the walk: the schema, every child a group passes on (safe key, a node), every row of a collection (the rows its enclosing row wrote for it when that is an array, an object for a record, else the rows it declares; for a record, safe keys only), and a field the walk drops for its path or value.
- Templates share the budget. Each distinct
itemis also walked once on its own, with no initial value, the first time its collection is reached; those visits count against the same budget, and a template’s nested collections lend their declared rows there too. - Refused when visits exceed declared + 10,000. Visits equal to it are accepted.
spec/fixtures/dynamic-form/v5/rows-lent-at-the-bound.jsonis exactly at the bound andspec/fixtures/dynamic-form/v5/rows-lent-past-the-bound.jsonone past it, both within a hundred rows per collection.
- Declared is the number of JSON objects reached from
Consequences
Section titled “Consequences”- A table of a hundred rows and twenty cells (about 2,100 visits beyond its declarations) is accepted; a hundred lent rows of a hundred lent rows (about 10,200) is refused. A document as large as what it declares is never refused for its size: 30,000 flat fields parse.
- The bound is on visits, not bytes. A lent cell is shared by reference in TS (a 100,000-item multiselect cell lent to 2,400 rows: 60 ms, 12 MB heap); a reader that copies a cell per row must share it instead, or bytes multiply under a visit count that looks small. A shared value is one instance for every field that takes it, so Java hands it out unmodifiable: a write through one field cannot reach the others.
MdyDynamicSchemaLimitErroris a new export (minor), and a parser that refused nothing here now refuses: a document past the bound that parsed before isok: false.- Every reader refuses more than 100 rows written in a collection’s own
initialValue(ADR 0232), as a finding about the shape at thatinitialValue. Rows one collection writes in its owninitialValuetherefore never reach this bound on their own: 10,001 of them are refused by the row rule, not here. Rows written inside an enclosing row’sinitialValueare not capped by that rule, and they count here like any other written row. Both rules reportMDY_DYNAMIC_SCHEMA_LIMIT; they differ in path (/schemahere, the collection’sinitialValuethere) and in what survives. MDY_DYNAMIC_SCHEMA_LIMITis shared. Java still reports it, at the node reached, for the depth (100) and node (500) caps ADR 0043 removed elsewhere; TS and Rust do not have those caps. A Java finding with this code is therefore not necessarily this bound: only one at/schemawith no fields is. That difference is known and is not settled here.- Written rows count too. A document that writes, explicitly, more than 10,000 visits beyond its declarations is refused although it paid for them in bytes. With a hundred rows per collection, that takes a hundred written rows of a hundred written rows.
- The runtime builder (
buildDynamicFormSchemathencreateForm) does not lend: an outer row’s nested collection starts empty. Lending is therefore a property of the reader and not of the runtime, and stopping it would align the reader to the runtime rather than change what a form holds. It is kept here because it changes the flattened fields of valid documents; whether the reader keeps lending is a separate decision, open, not settled by this one. - This bound does not depend on that decision. Without lending, the visits beyond the declarations are the written rows and the templates, and the bound still holds them; a record that stops lending amends the consequences above, not this decision.
Alternatives rejected
Section titled “Alternatives rejected”- Count leaves only. Falsified: rows ending in empty groups produce no field and cost the same walk (0.4 s at d=3, growing ×100 per level).
- An absolute cap on total visits. Refused a well-formed 99,999-field flat document the adversarial corpus accepts; the cost to bound is what rows multiply, not what is written.
- Stop lending. Changes valid documents’ initial values under a security banner, and does not by itself bound a document that writes its rows at every level.
- Restore a depth or node cap. ADR 0043’s reasoning still holds for written nesting; depth is not what multiplies here.
- Truncate at the bound and accept the rest. The part reached is an artefact of walk order.
Verification
Section titled “Verification”packages/core/test/dynamic-expansion-limit.test.mjs: refusal at d=6 in both modes under 1 s, leafless rows refused, the exact bound accepted and one past it refused, 100×20 accepted, 30,000 flat fields accepted, the typed error from the direct flatten. Removing the budget makes the suite hang; moving the comparison by one turns the boundary test red.battle-tests/adversarial/dynamic-contract: the 99,999-field document stays accepted.- The shared corpus pair at and past the bound runs in every reader’s corpus test, so a reader whose count drifts by one from this definition turns red.
node scripts/audit-type-surface.mjs: the new export is in the baseline.sdk/java/modyra-contract/src/test/java/dev/modyra/contract/MdyDynamicExpansionLimitTest.java: the d=6 shape refused in both modes, leafless rows refused, the shared pair exact in both modes, 100×20 accepted, a 100 KB multiselect cell lent to 2,000 rows held as one value, and a shared Jackson scalar judged under each cell’s own kind. Moving the bound by one, converting the cell per row, or keying the cell by value alone each turns one of them red.sdk/rust/modyra-contract/tests/expansion_limit.rs: the d=6 shape refused in both modes with the schema dropped, d=40 (a product pastu64) refused by saturation, leafless rows refused, a hundred written rows of a hundred written rows refused, 100×20 accepted. The corpus test holds Rust to the shared pair by exact code set; moving the bound by one, dropping the template term, or ignoring written rows each turns it red.a-path-cannot-say-which-it-came-fromflattens every corpus document directly; it skips the one refused for its expansion, which has no paths to classify.
Security and privacy
Section titled “Security and privacy”Trust boundary: any reader of a dynamic document from a CMS, a model or a server. Without the bound an attacker sending about 1.6 KB exhausts a Java reader’s heap and keeps a TS reader busy for seconds per request, with growth ×100 per nesting level. The bound makes the work a constant plus a multiple of the document. No data is exposed or stored. Disclosure: this record and its measurements are published with the fix in all three readers, not before.