Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-until on 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 into list/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.

  1. Frontmatter is the source of truth (disposable-safe). On a correction that actually changes the value, update_status appends the prior value to a reserved x-field-history list in the entity’s frontmatter: {field, value, valid_from, valid_until} where valid_from is the field’s prior start (the entity’s x-valid-from or created) and valid_until is the correction’s belief-time. The live frontmatter field holds the current value over [valid_until, ∞). Nothing is deleted; history lives in the Markdown, so syn reindex rebuilds it (disposability, BR-1.4).
  2. Index projection. Migration 0009 adds entity_field_history(type, id, field, value, valid_from, valid_until). Reindex parses x-field-history into it (a pure function of the Markdown, so INV-1/verify stays honest).
  3. Read overlay. A read with --as-of D overlays the field: if a history row covers D (valid_from <= D < valid_until), its value replaces the current column; otherwise the current value stands. The entity’s own row keeps valid_from = created, valid_until = NULL: visibility is unchanged.
  4. Scope. Applies to the fields update_status governs (the indexed status, extensible to other corrected core fields). Full per-field temporal tables for arbitrary custom fields are out of scope; this closes the headline status gap 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) and verify-honest (history is a pure function of the Markdown). Composes with syn 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-of read path gains an overlay step (only for entities that have history). Custom non-status field 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).