mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
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:
@@ -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
|
lifecycle evidence is appended, and the shared `action.completed` SSE bridge
|
||||||
publishes the terminal failure so agents do not poll a stale plan into
|
publishes the terminal failure so agents do not poll a stale plan into
|
||||||
execution.
|
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
|
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
|
`executing` mutation. If no API executor is registered, the endpoint must
|
||||||
fail closed without mutating the approved audit record or appending execution
|
fail closed without mutating the approved audit record or appending execution
|
||||||
|
|||||||
@@ -658,6 +658,11 @@ type normalizedCapabilityForResourceHash struct {
|
|||||||
Params []unified.CapabilityParam `json:"params,omitempty"`
|
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 {
|
type normalizedRelationship struct {
|
||||||
SourceID string `json:"sourceId"`
|
SourceID string `json:"sourceId"`
|
||||||
TargetID string `json:"targetId"`
|
TargetID string `json:"targetId"`
|
||||||
@@ -665,8 +670,6 @@ type normalizedRelationship struct {
|
|||||||
Confidence float64 `json:"confidence"`
|
Confidence float64 `json:"confidence"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
Discoverer string `json:"discoverer"`
|
Discoverer string `json:"discoverer"`
|
||||||
ObservedAt time.Time `json:"observedAt,omitempty"`
|
|
||||||
LastSeenAt time.Time `json:"lastSeenAt,omitempty"`
|
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,8 +772,6 @@ func normalizeRelationships(relationships []unified.ResourceRelationship) []norm
|
|||||||
Confidence: relationship.Confidence,
|
Confidence: relationship.Confidence,
|
||||||
Active: relationship.Active,
|
Active: relationship.Active,
|
||||||
Discoverer: strings.TrimSpace(relationship.Discoverer),
|
Discoverer: strings.TrimSpace(relationship.Discoverer),
|
||||||
ObservedAt: relationship.ObservedAt.UTC(),
|
|
||||||
LastSeenAt: relationship.LastSeenAt.UTC(),
|
|
||||||
Metadata: relationship.Metadata,
|
Metadata: relationship.Metadata,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,3 +325,34 @@ func TestExportedValidationMatchesPlanningExactly(t *testing.T) {
|
|||||||
t.Fatal("undeclared parameter must fail")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user