Retain matched resolution evidence in history

This commit is contained in:
2026-11-02 14:46:13 +00:00
parent d120fe907b
commit c61597d137
6 changed files with 53 additions and 7 deletions

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}