Accepted
ADR 0022 — Document per-field data shape
Per-field document values live in one JSON column on the document row, versioned through the document's own snapshot path
When a collection defines fields — a Recipe with a cooking time, a published date, and an ingredients list — the document editor renders one input per field and persists the values. This decision pins where those values live and how they version.
Decision: a JSON column, not a normalised table
Per-field values live in two columns on the documents row: data_json and data_r2_key. data_json holds a JSON object of the shape { [fieldSlug]: value }. Both columns are nullable; null is the canonical no-per-field-data sentinel, so collections with no fields (the seeded Posts and Pages) leave them null and the hydrate path surfaces an empty object to callers. Past the 200 KiB threshold the value spills to r2://main/documents/{id}/data.json, with the key stored in data_r2_key — the same isOversized helper and the same pattern as blocks_json and draft_blocks_json.
A normalised entity_field_values table (one row per document, field, value) was considered and rejected. The column keeps the document load at one D1 query plus at most one R2 read; a table forces a JOIN or a second SELECT per document, fans versioning out across N rows per save, and demands a cascade-delete shape on field deletion. Headless field-level filtering is the only scenario where the table wins cleanly, and that has a local solution when it is needed — a typed column or an indexed JSON projection — without pre-shaping the storage now. Filtering and sorting against data_json today resolve to JSON1 expressions (json_extract) rather than a separate table.
Versioning through the document snapshot, not entity_field_values
Documents version through the same path as every other document mutation: each manual save writes through versionedWrite under the document entity type, snapshotting the whole CuspDocument — including its per-field data — into entity_versions. Restoring a version writes the snapshot back and produces a new version; history is never rewritten. The documents row is in the lint allowlist, so direct UPDATE statements against it are rejected. The revisions table carries a reserved data_json / data_r2_key column pair for a future per-revision writer; the live versioning path is the document snapshot.
The DocumentSession DO does not touch per-field data. Block-level body editing is high-frequency, fine-grained, and conflict-prone — exactly what the op log absorbs. Per-field structured values are the opposite: low-frequency, coarse-grained, low-conflict. They save through the manual PATCH path with last-write-wins, and the op-log machinery would be disproportionate to the shape it serves.
Multiple block fields: a primary, denormalised to blocks_json
A collection can define more than one block-typed field. The DocumentSession DO is keyed by document ID and operates on one block sequence, so one block field per collection is designated primary and gets the collaborative DO session. Its value is written to both data_json (the generic schema-driven read path) and blocks_json (the public renderer's hot-read shape), and those two must agree. Secondary block fields save only into data_json with a non-collaborative editor and manual save. There is no draft copy of data_json: per-field values save directly into the live row, and publish picks up the latest persisted data.
Consequences
The dynamic admin form and the headless REST surface inherit one column that round-trips opaquely through JSON, matching the existing blocks pattern across read, write, hydrate, and spillover. Posts and Pages are unchanged; there is no backfill. The known cost is the primary-block denormalisation — the body is written to two columns on save and the two must stay in agreement, an invariant the tests assert. Multi-block collaborative editing on secondary fields and per-field op-granular history are out of scope for v1; both gaps are documented rather than hidden.
Related: the block model and wire protocol (ADR 0018) defines the body shape that the primary block field carries.