Carry sensitivity across matched records

This commit is contained in:
2026-11-02 14:56:38 +00:00
parent c296eba6c1
commit 3b10f97424
4 changed files with 113 additions and 1 deletions

View File

@@ -339,6 +339,8 @@ unknown. Use both whole-list-only and overlapping list/field records.
## Stage 10 — Classify matched and removed sensitivity evidence
**Status:** Complete. Focused sensitivity, secret, and dependency checks passed.
**Scope:** existing classification and dependency closure.
1. Extend history sensitivity candidates to include actual entry/final shapes

View File

@@ -533,3 +533,58 @@ func TestRegisteredResolutionExplainsRemovedFieldOnMovedRecord(t *testing.T) {
}
t.Fatalf("removed field history = %#v", explanation.History)
}
func TestRegisteredResolutionTransfersSensitivityFromEntry(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
Derived string `yaml:"derived"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Secret: func(path Path) bool { return path.String() == "/items/0/value" },
Resolve: func(value *config, r *Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
value.Derived = value.Items[1].Value
r.RecordNormalization(Keys("items"), "reorder")
r.RecordNormalization(Keys("derived"), "derive", Keys("items").Index(1).Key("value"))
return nil
},
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: confidential}, {id: b, value: visible}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("items").Index(0).Key("value"), Keys("items").Index(1).Key("value"), Keys("derived")} {
if !pathIsSecret(result.Inspection.secrets, path) {
t.Errorf("sensitivity lost at %s", path.String())
}
}
}
func TestRegisteredResolutionKeepsRemovedSensitivity(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Secret: func(path Path) bool { return path.String() == "/items/0/value" },
Resolve: func(value *config, r *Resolution) error {
value.Items = value.Items[1:]
r.RecordNormalization(Keys("items"), "remove record")
return nil
},
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: confidential}, {id: b, value: visible}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if !pathIsSecret(result.Inspection.secrets, Keys("items").Index(0).Key("value")) {
t.Fatal("removed historical sensitivity lost")
}
}

View File

@@ -124,3 +124,34 @@ func (c resolutionCorrespondence) entryPath(path Path) (Path, bool) {
}
return path.clone(), true
}
// finalPath translates historical record descendants without matching removals.
func (c resolutionCorrespondence) finalPath(path Path) (Path, bool) {
if !path.valid() {
return Path{}, false
}
for depth := 0; depth < len(path.segments); depth++ {
prefix := Path{segments: path.segments[:depth]}
collection, found := c.collections[prefix.String()]
if !found || !collection.eligible {
continue
}
for i, segment := range collection.path.segments {
if segment.kind == pathSegmentKey && path.segments[i].kind == pathSegmentIndex {
return path.clone(), true
}
}
index, ok := schemaIndex(path.segments[depth])
if !ok || index >= len(collection.entryToFinal) {
continue
}
current := collection.entryToFinal[index]
if current < 0 {
return Path{}, false
}
translated := path.clone()
translated.segments[depth] = pathSegment{kind: pathSegmentIndex, index: current}
return translated, true
}
return path.clone(), true
}

View File

@@ -80,7 +80,31 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa
classifyPath(resolved)
}
}
return closeSecretDependencies(paths, secret, dependencies, inherited)
classified := closeSecretDependencies(paths, secret, dependencies, inherited)
if model := metadata.historyEvidence(); model != nil && model.resolutionPayload != nil {
// Project from the completed entry classification into a separate map so
// one side of a swap cannot influence the other while it is being read.
projected := make(map[string]Path)
for _, path := range paths {
if !pathIsSecret(classified, path) {
continue
}
if target, matched := model.resolutionPayload.correspondence.finalPath(path); matched {
projected[target.String()] = target
}
}
for pointer, path := range projected {
paths[pointer] = path
secret[pointer] = true
}
for pointer, sensitive := range classified {
if sensitive {
secret[pointer] = true
}
}
return closeSecretDependencies(paths, secret, dependencies, inherited)
}
return classified
}
func addSecretPath(paths map[string]Path, pointer string) {