Hash relationship identity, not observation stamps, into plan resource versions

Docker adapters restamp relationship ObservedAt/LastSeenAt on every
~15s report, and the action planner folded those stamps into the plan's
resource version, so any reviewed action against a relationship-bearing
container (start, stop, restart, and the restored update) drifted to a
409 action_plan_drift before a human could read the review dialog and
click approve. Relationship edges now count by identity (source,
target, type, active, discoverer, metadata), the same
identity-versus-timestamp boundary change emission drew for issue
#1496. Found live: the UI update journey failed with plan drift on
every attempt slower than one report cycle.
This commit is contained in:
rcourtman 2026-07-14 12:22:02 +01:00
parent 3c778e2b26
commit 098ba4eaa9
3 changed files with 42 additions and 4 deletions

View file

@ -2070,6 +2070,12 @@ a new API state machine, queue contract, or verification-accounting field.
lifecycle evidence is appended, and the shared `action.completed` SSE bridge
publishes the terminal failure so agents do not poll a stale plan into
execution.
The resource version that anchors that comparison hashes semantic state
only: relationship edges count by identity (source, target, type, active),
never by their `ObservedAt`/`LastSeenAt` observation stamps, because
adapters restamp those on every report cycle and a version that churns with
observation time makes every human-reviewed plan drift before it can be
approved and executed.
Dry-run-only plans are not executable plans and must fail closed before any
`executing` mutation. If no API executor is registered, the endpoint must
fail closed without mutating the approved audit record or appending execution

View file

@ -658,6 +658,11 @@ type normalizedCapabilityForResourceHash struct {
Params []unified.CapabilityParam `json:"params,omitempty"`
}
// normalizedRelationship hashes edge identity only. Observation timestamps
// are deliberately excluded: adapters restamp ObservedAt/LastSeenAt on every
// report cycle (Docker every ~15s), which made every plan against a
// relationship-bearing resource drift before a human could review and approve
// it. Same identity-versus-timestamp boundary as change emission (#1496).
type normalizedRelationship struct {
SourceID string `json:"sourceId"`
TargetID string `json:"targetId"`
@ -665,8 +670,6 @@ type normalizedRelationship struct {
Confidence float64 `json:"confidence"`
Active bool `json:"active"`
Discoverer string `json:"discoverer"`
ObservedAt time.Time `json:"observedAt,omitempty"`
LastSeenAt time.Time `json:"lastSeenAt,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
@ -769,8 +772,6 @@ func normalizeRelationships(relationships []unified.ResourceRelationship) []norm
Confidence: relationship.Confidence,
Active: relationship.Active,
Discoverer: strings.TrimSpace(relationship.Discoverer),
ObservedAt: relationship.ObservedAt.UTC(),
LastSeenAt: relationship.LastSeenAt.UTC(),
Metadata: relationship.Metadata,
})
}

View file

@ -325,3 +325,34 @@ func TestExportedValidationMatchesPlanningExactly(t *testing.T) {
t.Fatal("undeclared parameter must fail")
}
}
func TestResourceVersionIgnoresRelationshipObservationTimestamps(t *testing.T) {
base := unified.Resource{
ID: "app-container:1",
Type: unified.ResourceTypeAppContainer,
Name: "api",
Status: unified.StatusOnline,
Relationships: []unified.ResourceRelationship{{
SourceID: "app-container:1", TargetID: "docker-network:1",
Type: unified.RelationshipType("attached_to"), Confidence: 1, Active: true,
Discoverer: "docker_adapter",
ObservedAt: time.Date(2026, 7, 14, 11, 0, 0, 0, time.UTC),
LastSeenAt: time.Date(2026, 7, 14, 11, 0, 0, 0, time.UTC),
}},
}
restamped := base
restamped.Relationships = []unified.ResourceRelationship{base.Relationships[0]}
restamped.Relationships[0].ObservedAt = base.Relationships[0].ObservedAt.Add(15 * time.Second)
restamped.Relationships[0].LastSeenAt = base.Relationships[0].LastSeenAt.Add(15 * time.Second)
if ResourceVersion(base) != ResourceVersion(restamped) {
t.Fatal("resource version drifted on relationship observation restamp alone")
}
rewired := base
rewired.Relationships = []unified.ResourceRelationship{base.Relationships[0]}
rewired.Relationships[0].TargetID = "docker-network:2"
if ResourceVersion(base) == ResourceVersion(rewired) {
t.Fatal("resource version ignored a real relationship change")
}
}