From c61597d13726157a8c68c8e60bdafffb1a9426d7 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 14:46:13 +0000 Subject: [PATCH] Retain matched resolution evidence in history --- docs/roadmap/implementation.md | 2 ++ internal/engine/history_resolution.go | 19 ++++++++++++++----- internal/engine/load_result.go | 2 +- internal/engine/resolution_compare_test.go | 18 +++++++++++++++++- internal/engine/resolution_correspondence.go | 11 +++++++++++ .../engine/resolution_correspondence_test.go | 8 ++++++++ 6 files changed, 53 insertions(+), 7 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c95059b..31e1c2f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -190,6 +190,8 @@ moved afterward and no longer matches its declared final address. ## Stage 5 — Retain correspondence in the shared history owner +**Status:** Complete. Focused history, correspondence, and retained-budget checks passed. + **Scope:** evidence transport, ownership, and retained-node accounting. 1. Extend `historyResolutionPayload` to own the compact correspondence and paired diff --git a/internal/engine/history_resolution.go b/internal/engine/history_resolution.go index 723dbd1..238be6e 100644 --- a/internal/engine/history_resolution.go +++ b/internal/engine/history_resolution.go @@ -12,15 +12,16 @@ type resolutionReplayGroup struct { } type historyResolutionPayload struct { - snapshots []resolutionSnapshot - replayGroups []resolutionReplayGroup + snapshots []resolutionSnapshot + replayGroups []resolutionReplayGroup + correspondence resolutionCorrespondence } // observeResolutionEvidence retains audited records in producer order and // indexes the concrete shapes in every original before/after subtree. Snapshot // roots are already detached by runResolution and are shared with the replay // group that refers to their covering record. -func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot) { +func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot, correspondence ...resolutionCorrespondence) { builder.requireOpen() if len(snapshots) == 0 { return @@ -36,10 +37,14 @@ func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionS snapshots: make([]resolutionSnapshot, len(snapshots)), replayGroups: coalescedResolutionSnapshots(snapshots), } + if len(correspondence) != 0 { + payload.correspondence = correspondence[0].clone() + } attributions := make([]historyAttributionRef, len(snapshots)) lookup := buildDocumentLookup(builder.scratch) for recordID, snapshot := range snapshots { snapshot.change = cloneChange(snapshot.change) + snapshot.entryPath = snapshot.entryPath.clone() payload.snapshots[recordID] = snapshot ref := builder.nextPayload(historyPhaseResolution) @@ -48,8 +53,12 @@ func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionS } attribution := builder.addAttribution(snapshot.change.Origin, snapshot.change.Reasons, snapshot.change.Dependencies) attributions[recordID] = attribution - beforePath, beforeFound := historyConcretePathAt(builder.scratch, snapshot.change.Path, lookup) - if beforeFound { + entryPath, hasEntry := snapshot.change.Path, true + if len(correspondence) != 0 { + entryPath, hasEntry = snapshot.entryPath, snapshot.hasEntry + } + beforePath, beforeFound := historyConcretePathAt(builder.scratch, entryPath, lookup) + if hasEntry && beforeFound { observeResolutionSubtree(builder, snapshot.before, beforePath, snapshot.change.Path, ref, attribution, true, false) } } diff --git a/internal/engine/load_result.go b/internal/engine/load_result.go index f77cbae..3402e3d 100644 --- a/internal/engine/load_result.go +++ b/internal/engine/load_result.go @@ -62,7 +62,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], final = resolved.final resolutionSnapshots = resolved.snapshots if builder != nil && len(resolutionSnapshots) != 0 { - builder.observeResolutionEvidence(resolutionSnapshots) + builder.observeResolutionEvidence(resolutionSnapshots, resolved.correspondence) } applyResolutionMetadata(&metadata, resolved, prepared.resolution) finalizeEffectiveMetadataPaths(&metadata, final) diff --git a/internal/engine/resolution_compare_test.go b/internal/engine/resolution_compare_test.go index 7944218..8892f77 100644 --- a/internal/engine/resolution_compare_test.go +++ b/internal/engine/resolution_compare_test.go @@ -117,10 +117,26 @@ func TestResolutionAuditsRegisteredCollectionChanges(t *testing.T) { t.Fatal(err) } if test.move { + correspondence := result.history.resolutionPayload.correspondence + entry, ok := correspondence.entryPath(Keys("items").Index(1).Key("value")) + if !ok || entry.String() != "/items/0/value" { + t.Fatalf("retained entry = %s, %t", entry.String(), ok) + } for _, snapshot := range result.history.resolutionPayload.snapshots { - if snapshot.change.Path.String() == "/items/1/value" && (snapshot.before == nil || snapshot.before.Text != "old") { + if snapshot.change.Path.String() == "/items/1/value" && (snapshot.before == nil || snapshot.before.Text != "old" || !snapshot.hasEntry || snapshot.entryPath.String() != "/items/0/value") { t.Fatalf("paired before = %#v", snapshot.before) } + if snapshot.before != nil { + found := false + for _, root := range result.history.documentRoots() { + if root == snapshot.before { + found = true + } + } + if !found { + t.Fatal("paired snapshot omitted from retained roots") + } + } } } return diff --git a/internal/engine/resolution_correspondence.go b/internal/engine/resolution_correspondence.go index 5ecd32c..0678c8c 100644 --- a/internal/engine/resolution_correspondence.go +++ b/internal/engine/resolution_correspondence.go @@ -16,6 +16,17 @@ type resolutionCorrespondence struct { collections map[string]collectionCorrespondence } +func (c resolutionCorrespondence) clone() resolutionCorrespondence { + result := resolutionCorrespondence{collections: make(map[string]collectionCorrespondence, len(c.collections))} + for key, collection := range c.collections { + collection.path = collection.path.clone() + collection.entryToFinal = append([]int(nil), collection.entryToFinal...) + collection.finalToEntry = append([]int(nil), collection.finalToEntry...) + result.collections[key] = collection + } + return result +} + func buildResolutionCorrespondence(entry, final *document.Node, policy sequencePolicy) resolutionCorrespondence { result := resolutionCorrespondence{collections: make(map[string]collectionCorrespondence)} var indexes mappingIndex diff --git a/internal/engine/resolution_correspondence_test.go b/internal/engine/resolution_correspondence_test.go index 80bfcbd..8b3a917 100644 --- a/internal/engine/resolution_correspondence_test.go +++ b/internal/engine/resolution_correspondence_test.go @@ -51,6 +51,14 @@ func TestResolutionCorrespondenceDynamicCompoundIdentities(t *testing.T) { before := parseCompositionDocument(t, "before", "groups: {alpha: {items: [{id: 1, kind: x}, {id: '1', kind: x}]}, empty: {items: []}}") after := parseCompositionDocument(t, "after", "groups: {alpha: {items: [{id: '1', kind: x}, {id: 1.0, kind: x}]}, empty: {items: []}}") matching := buildResolutionCorrespondence(before, after, policy) + retained := matching.clone() + changed := matching.collections["/groups/alpha/items"] + changed.finalToEntry[0] = 0 + matching.collections["/groups/alpha/items"] = changed + if entry, ok := retained.entryPath(Keys("groups", "alpha", "items").Index(0)); !ok || entry.String() != "/groups/alpha/items/1" { + t.Fatalf("retained correspondence shares mutable indexes: %s, %t", entry.String(), ok) + } + matching = retained if empty := matching.collections["/groups/empty/items"]; !empty.eligible || empty.structural { t.Fatalf("empty = %#v", empty) }