ADR-046: Bitemporal field corrections (update_status valid-time history)
Date: 2026-07-07 Status: Accepted (SYN-315 / #289) Author: Rômulo Febasi
Fixes #289. Extends ADR-027 (bitemporal facts: store valid-time, supersession = invalidation-not-deletion) and ADR-031 (belief-time axis). Surfaced by the SYN-298 benchmark scenario B.
Context
A field correction via update_status (both doors) overwrites the field in
place: apply_update_status_in_tx calls set_fields_kind_in_tx with the new
value only. The entity’s index row keeps valid_from = created,
valid_until = NULL, so the valid-time --as-of axis cannot reconstruct the
pre-correction value:
capture aurora (status=active, created 2026-01-01)
update_status → archived (accepted 2026-02-01)
syn list project --as-of 2026-01-10 # a date BEFORE the correction
# → aurora with status=archived (the CURRENT value), not active
The old value survives only in git + the audit trail (syn at, syn blame,
syn diff), not in a valid-time-queryable form. This scopes the “correct it and
--as-of <before> returns the old value” story narrower than the pitch implies.
The existing bitemporal machinery (ADR-027) works only for supersession: a
new fact F2 (separate entity, its own id) carries x-supersedes: F1 and closes
F1’s interval. An update_status is an in-place, same-entity, single-field
edit: there is no F2, and the field, not the whole entity, changes over time.
Rejected shapes
- Naïve patch: stamp
x-valid-untilon the corrected entity. Breaks visibility: the entity now holds the new value but its interval closes at the correction, so--as-of <after>(the common case) drops it entirely, and--as-of <before>still returns the new value. Wrong on both axes. - Shadow-entity supersession: write the prior value as a derived-id
companion (
aurora~<until>). Pollutes the id namespace, leaks snapshots intolist/search, and breaks “one entity, referenced once” (BR-1). Rejected. - Reframe only: document that in-place corrections are transaction-time
(
syn at), not valid-time. Honest but under-delivers: #289 asks for a working--as-of, and the entity (not just its history) stays valid throughout, so a whole-entity interval is the wrong model anyway.
Decision
Record valid-time history at the FIELD level, not the entity level. The
entity stays visible for every date >= created (it existed throughout); only
the field value is reconstructed per valid-time interval.
- Frontmatter is the source of truth (disposable-safe). On a correction
that actually changes the value,
update_statusappends the prior value to a reservedx-field-historylist in the entity’s frontmatter:{field, value, valid_from, valid_until}wherevalid_fromis the field’s prior start (the entity’sx-valid-fromorcreated) andvalid_untilis the correction’s belief-time. The live frontmatter field holds the current value over[valid_until, ∞). Nothing is deleted; history lives in the Markdown, sosyn reindexrebuilds it (disposability, BR-1.4). - Index projection. Migration 0009 adds
entity_field_history(type, id, field, value, valid_from, valid_until). Reindex parsesx-field-historyinto it (a pure function of the Markdown, so INV-1/verify stays honest). - Read overlay. A read with
--as-of Doverlays the field: if a history row coversD(valid_from <= D < valid_until), its value replaces the current column; otherwise the current value stands. The entity’s own row keepsvalid_from = created,valid_until = NULL: visibility is unchanged. - Scope. Applies to the fields
update_statusgoverns (the indexedstatus, extensible to other corrected core fields). Full per-field temporal tables for arbitrary custom fields are out of scope; this closes the headlinestatusgap without a schema explosion.
Consequences
- Improves:
--as-of <before>reconstructs the pre-correction value deterministically; the entity stays visible on both sides of the correction; the model is disposable (reindex rebuilds it) andverify-honest (history is a pure function of the Markdown). Composes withsyn at(transaction-time) and supersession (whole-fact valid-time) rather than duplicating them. - Worsens / limits: a correcting write now carries a small frontmatter list
and one extra index table; the
--as-ofread path gains an overlay step (only for entities that have history). Custom non-statusfield corrections are not yet reconstructable (documented; follow-up if demanded). - Migration: additive (new table). Existing corrections made before this ADR are not retro-reconstructable (their prior value is only in git): documented.
Alternatives considered
See Rejected shapes above (naïve interval stamp, shadow entity, reframe-only).
A general per-field temporal table for every field was rejected as a schema/query
explosion disproportionate to the one real gap (status).