v1 ships **display only** — values are persisted, rendered on the form, and shown read-only on entry views. There is no filter UI, no group-by, no aggregation. Under that constraint, the join table earns its complexity nowhere: every read path is "load the entry, render the values," which JSON serves with zero ceremony.
-**Field IDs are stable.** Renaming or editing constraints does not touch stored values. Orphaned keys (left behind if a deletion sweep is ever missed) are harmless: the UI ignores keys without a current definition.
and the field definition. Strictly correct, makes filter/group-by ("hours where Category = 'Design'") a straightforward join, and the field-deletion cascade is a single FK rule.
`
**
(chosen). One column, no joins on read, no migration when types are added. Field-deletion cascade is
`
UPDATE time_entries SET custom_values = custom_values - $fieldId
`
over the affected scope.
`
custom_values->>'fld_xyz'
`
and will be unindexed unless we add per-field expression indexes. At a sufficiently large scale, the right move is to migrate to a normalized table. The trigger is product, not engineering — when filtering ships, revisit.