0019 named this as the second cut and #317 landed the first: facts and derivations now rewind on the recording axis, but entities do not. merged_into says a merge happened, not when, so two entities merged in March are still one node at an as_of in February — and the facts that moved with the merge show up under a name that did not hold them yet.
The clock exists already: entity_merges carries created_at and reverted_at, which is the same shape record_axis::held_at reads on every other table.
Two halves
Which nodes exist at T. An entity is swallowed at T when a merge with source_id = e was in effect at T — created_at <= T AND (reverted_at IS NULL OR reverted_at > T). Reads currently ask merged_into IS NULL, which answers only for now. An entity created after T should also be absent (entities.created_at).
Which entity a fact belonged to at T. A merge rewrites rows in place — UPDATE facts SET subject_id = target WHERE subject_id = source — and records exactly which rows moved, in moved_subject_facts and moved_object_facts. So before that merge, those facts belonged to the source, and the row cannot say so on its own.
The set to undo is merges currently in effect (reverted_at IS NULL) whose created_at > T. A reverted merge is not in it: revert_merge already moved those rows back, so undoing them a second time would move them somewhere they never were. The symmetric case is the same arrays read the other way — a reverted merge whose window contains T means the facts were on the target then, and the move has to be applied rather than undone.
Chains resolve in one step: with A→B and then B→C, a fact that started on A appears in both merges' arrays, and the earliest not-yet-made merge that moved it names the entity that held it at T.
Already free
The facts a merge invalidates because they would become self-loops carry invalidated_at like any other retraction, so #317's predicate brings them back at an as_of before the merge with nothing further to do. Same for whatever reconcile_moved_facts closed on the way.
Out of scope for this cut
The target's own type and profile at T. entity_merges keeps target_type_before and target_profile_before, so it is answerable later; the node's label is unaffected, since a merge leaves the target's canonical_name alone.
Shape
The predicate belongs in record_axis beside the others, and the undo mapping belongs in one CTE that the node and edge queries join, not spread across each read — the same discipline 0019 asked for and for the same reason. Database-backed tests should assert both directions: two nodes before the merge with their own facts, one node after, and a reverted merge's window reading as merged from inside and separate from either side of it.
0019 named this as the second cut and #317 landed the first: facts and derivations now rewind on the recording axis, but entities do not.
merged_intosays a merge happened, not when, so two entities merged in March are still one node at anas_ofin February — and the facts that moved with the merge show up under a name that did not hold them yet.The clock exists already:
entity_mergescarriescreated_atandreverted_at, which is the same shaperecord_axis::held_atreads on every other table.Two halves
Which nodes exist at T. An entity is swallowed at T when a merge with
source_id = ewas in effect at T —created_at <= T AND (reverted_at IS NULL OR reverted_at > T). Reads currently askmerged_into IS NULL, which answers only for now. An entity created after T should also be absent (entities.created_at).Which entity a fact belonged to at T. A merge rewrites rows in place —
UPDATE facts SET subject_id = target WHERE subject_id = source— and records exactly which rows moved, inmoved_subject_factsandmoved_object_facts. So before that merge, those facts belonged to the source, and the row cannot say so on its own.The set to undo is merges currently in effect (
reverted_at IS NULL) whosecreated_at > T. A reverted merge is not in it:revert_mergealready moved those rows back, so undoing them a second time would move them somewhere they never were. The symmetric case is the same arrays read the other way — a reverted merge whose window contains T means the facts were on the target then, and the move has to be applied rather than undone.Chains resolve in one step: with A→B and then B→C, a fact that started on A appears in both merges' arrays, and the earliest not-yet-made merge that moved it names the entity that held it at T.
Already free
The facts a merge invalidates because they would become self-loops carry
invalidated_atlike any other retraction, so #317's predicate brings them back at anas_ofbefore the merge with nothing further to do. Same for whateverreconcile_moved_factsclosed on the way.Out of scope for this cut
The target's own type and profile at T.
entity_mergeskeepstarget_type_beforeandtarget_profile_before, so it is answerable later; the node's label is unaffected, since a merge leaves the target'scanonical_namealone.Shape
The predicate belongs in
record_axisbeside the others, and the undo mapping belongs in one CTE that the node and edge queries join, not spread across each read — the same discipline 0019 asked for and for the same reason. Database-backed tests should assert both directions: two nodes before the merge with their own facts, one node after, and a reverted merge's window reading as merged from inside and separate from either side of it.