mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-16 19:32:24 +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:
parent
3c778e2b26
commit
098ba4eaa9
3 changed files with 42 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue