Fix availability check identity projection

This commit is contained in:
rcourtman 2026-07-23 23:46:11 +01:00
parent b4877b4b44
commit 8d23529c02
35 changed files with 1461 additions and 177 deletions

View file

@ -1368,9 +1368,14 @@ Target payload fields:
- `linkedResourceId` - Optional resource id hint for attaching the probe facet to an existing resource.
An explicit `linkedResourceId` is authoritative and fails closed when it
cannot resolve. Without it, Pulse attaches only on one exact normalized IP or
hostname match. Zero matches remain standalone and multiple matches remain
ambiguous; Pulse does not guess.
cannot resolve. Without it, Pulse correlates only on one exact normalized IP
or hostname match. Zero matches remain standalone and multiple matches remain
ambiguous; Pulse does not guess. Every configured target remains a distinct
`network-endpoint` in `/api/resources`, including correlated targets. A
correlated target also projects an additive `availability` /
`availabilityChecks` facet onto the matched resource and exposes an outgoing
`checks` relationship from the check resource. The check row remains the owner
of probe status, incidents, evidence, and history.
Example ping-only target:

View file

@ -80,9 +80,12 @@ request per row.
An availability check attaches to an existing unified resource only when an
explicit link resolves or identity correlation yields exactly one candidate.
The relationship carries a stable relationship ID and the evidence ID that
supports it. Ambiguous checks remain standalone. An attached check appears on
the owning platform row and detail; it is not duplicated as another inventory
resource.
supports it. Every configured check remains a source-owned inventory resource,
including an attached check. Correlation additionally projects its bounded
facet onto the matched platform row and detail; it never replaces the check
identity or copies the check's incident and service identity into the machine.
Ambiguous and unresolved checks remain visible with their typed correlation
state.
Availability success is time-bounded. A stale successful observation is
`stale`, not healthy. A failure enters the same alert lifecycle and Patrol

View file

@ -198,11 +198,13 @@ Unified Resources is now the canonical model and endpoint family:
Availability checks now attach to an existing canonical resource when an
explicit `linkedResourceId` resolves or one normalized IP/hostname match is
unambiguous. Attached checks disappear from the standalone Availability checks
inventory and appear on the owning platform row/detail instead. API consumers
should accept the additive `availabilityChecks`, correlation, evidence, and
`checks` relationship fields; the existing singular `availability` field
remains as a compatibility summary. Ambiguous or invalid links stay
unambiguous. Every configured check remains individually visible in the
Availability checks inventory; an attached check also appears as an additive
facet on the owning platform row/detail. API consumers should treat the
source-owned `network-endpoint` as the check identity and accept the additive
`availabilityChecks`, correlation, evidence, and outgoing `checks`
relationship fields; the existing singular `availability` field remains as a
compatibility summary. Ambiguous or invalid links stay visible as
standalone/unresolved and are never guessed.
### License and Entitlements

View file

@ -145,7 +145,8 @@ This specification does not authorize:
6. a universal recoverability score that hides missing evidence
7. automatic merging of ambiguous resources
8. a second alert engine inside Patrol
9. availability checks as duplicate resources when a canonical target is known
9. replacing a configured availability check's source-owned identity with a
correlated target or creating more than one check row for one saved target
10. platform-specific lifecycle states in primary APIs
11. hiding stale, partial-permission, or collection-error states
12. MSP multi-tenancy, fleet-wide delegation, or policy inheritance beyond
@ -780,7 +781,8 @@ exposure, but they must not create two writable sources of truth.
6. recovery evidence changes protection posture and the related attention item
7. notification audit links to the exact transition
8. an action executes once and only closes after verified recovery evidence
9. an attached availability check does not create a duplicate resource
9. an attached availability target keeps exactly one source-owned check
resource and one additive facet projection without duplicate incidents
10. authorization and tenant boundaries hold across evidence and actions
### Browser proof

View file

@ -0,0 +1,83 @@
# Operational Trust: Availability Check Identity Correction
Date: 2026-07-23
## Decision
Every configured availability target is a source-owned canonical
`network-endpoint` resource. Correlation is additive: a uniquely matched VM,
host, workload, or service may also carry the check as an availability facet,
but that projection never replaces or hides the configured check row.
The check resource owns current probe state, incidents, alert lifecycle,
history, canonical evidence, and the outgoing `checks` relationship. A matched
resource receives only the availability facet and a cloned evidence envelope
bound to that resource. Projection does not merge the checked service address,
name, status, incidents, tags, metrics, or last-seen value into the matched
resource.
## Root Cause Corrected
The original registry path used the generic identity merge when correlation
found a resource. That merge mapped the availability source target directly to
the VM or host, copied unrelated service identity and incident state into it,
and returned before creating the source-owned endpoint. The frontend then
excluded `attached` availability resources, codifying the missing row and
incorrect count.
The corrected registry path creates or replaces the deterministic
source-owned endpoint first, projects only its facet onto the match, and stores
the relationship on the check. Rehydration seeds availability source mappings
only from availability-owned endpoints, and manual resource links cannot fold
those endpoints into adjacent resources.
## Runtime Contract
- Two services correlated to one monitored VM produce two availability rows
and two facets on that VM.
- Standalone, ambiguous, unresolved, disabled, and attached targets all retain
one row per configured target.
- REST and websocket payloads preserve both source-owned rows and plural host
projections.
- Atomic refresh, reload, and restart preserve the check ID. Editing replaces
stale endpoint state and moves the projection; deletion removes both the row
and only that target's projection.
- Availability incidents and alerts remain single-owned by the check. The
matched resource does not receive a duplicate incident.
- Check creation, configuration changes, health transitions, and deletion are
recorded against the check ID. Observation timestamps and latency changes
alone do not create history churn.
- Resource registries and browser caches remain tenant-scoped. Identical target
IDs in different tenants resolve only against resources in their own tenant.
## Proof
Backend regression coverage:
- `internal/unifiedresources/availability_link_test.go`
- `internal/unifiedresources/monitor_adapter_read_state_test.go`
- `internal/unifiedresources/change_emission_test.go`
- `internal/alerts/unified_incidents_test.go`
- `internal/monitoring/canonical_guardrails_test.go`
Frontend regression coverage:
- `frontend-modern/src/features/standalone/__tests__/standalonePageModel.test.ts`
- `frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx`
- `frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts`
The governed browser scenario covers two attached services on one monitored
host plus standalone endpoints and verifies that the Availability checks tab
shows the configured total while the host retains both facet details.
## User Evidence
- [#1568: Machines - Availability Checks does not show all checks](https://github.com/rcourtman/Pulse/issues/1568)
- [#1460: Simple ping-based monitoring](https://github.com/rcourtman/Pulse/issues/1460)
- [#1565: UDP/service availability without an agent](https://github.com/rcourtman/Pulse/issues/1565)
## Governance
- Owning lane: L13
- Dependent contracts: monitoring, unified resources, alerts, API contracts,
frontend primitives, and performance/scalability

View file

@ -2,6 +2,12 @@
Date: 2026-07-19
Status: Superseded on 2026-07-23 by
`operational-trust-availability-check-identity-correction-2026-07-23.md`.
This record is retained as historical evidence for the original facet
decision and its browser proof; its rule that attached checks disappear from
primary inventory is not the current contract.
## Decision
Availability is evidence about a canonical resource, not a parallel inventory

View file

@ -28,6 +28,10 @@ smartctl retry modes merge rather than replace evidence, and partial NVMe JSON
must preserve field presence so omitted health counters are not fabricated as
zero. Direct SATA, SAS, NVMe, and controller-member inventory must all survive
one compressed unified-agent report without a collector-side suffix cap.
`internal/monitoring/monitor.go` also serializes shared unified-resource
websocket payloads. Carrying plural availability facets through that serializer
is an adjacent monitoring/API projection and does not change agent enrollment,
report admission, removal, update, profile, or command authority.
## Canonical Files

View file

@ -32,6 +32,11 @@ state must be explicitly true. Exit code 137 alone is only SIGKILL evidence;
explicit false and unavailable/legacy OOM state both fail closed without an OOM
alert. Recovery clears an existing OOM alert when the authoritative predicate
is no longer true.
Availability incident and alert identity belongs to the source-owned
`network-endpoint` check. Correlation may project probe evidence onto a matched
machine, but it must not copy the check incident onto that machine or create a
second alert lifecycle. Failure, recovery, history, acknowledgement, and
notification routing therefore remain stable under relinking and restart.
## Canonical Files

View file

@ -30,6 +30,13 @@ pages until the response's declared `totalPages` is exhausted; no client-side
page ceiling may silently truncate a wide fleet. Disk history lookup resolves
an exact canonical resource ID first and accepts a metrics target, serial, or
WWN only when the match is unique and the hardware identifier is usable.
Unified availability responses preserve one source-owned `network-endpoint`
resource per configured target, including checks correlated to an existing
machine. A correlated machine may carry the same check as an additive
availability facet, but that projection is not availability-source ownership
and must not suppress the endpoint from type filters, totals, REST reloads, or
websocket snapshots. Plural machine projections use `availabilityChecks`; the
singular `availability` field remains only a compatibility projection.
## Canonical Files

View file

@ -5072,8 +5072,11 @@ Plural attached checks render as repeated bounded cards from
`availabilityChecks`, while the row keeps one compatibility summary. Expired
successful evidence must render an amber `Stale` state with no green
`Responding normally` copy, and a never-observed check must render
`Not checked`. A resource whose correlation state is `attached` must not also
appear as a primary row in the Machines `Availability checks` tab.
`Not checked`. A matched machine or service carrying an attached projection
must not appear as a primary row in the Machines `Availability checks` tab;
the distinct source-owned `network-endpoint` for that configured check must
appear there regardless of whether its correlation state is `attached`,
`standalone`, `ambiguous`, or `unresolved`.
Operational navigation for those agentless endpoints belongs to the
frontend-primitives-owned Machines surface as a focused Availability checks tab
rather than a new primary nav item. The page may show availability checks beside

View file

@ -720,17 +720,21 @@ the default low-overhead check, while TCP and HTTP are canonical fallbacks for
devices or runtimes where ICMP is unavailable or the useful signal is a port or
web interface.
Supplemental records carry the saved target's optional `LinkedResourceID`
forward into `AvailabilityData` so the unified-resource registry can attach
the probe facet onto the referenced resource. Monitoring does not perform the
attach decision itself; it only forwards the link hint for the registry to
resolve. Every completed probe also authors an operational-trust
forward into `AvailabilityData` so the unified-resource registry can correlate
and project the probe facet onto the referenced resource. Every saved target
continues to emit its own `network-endpoint` supplemental record regardless of
that correlation outcome; monitoring never substitutes a matched host or
service identity for the configured check. Monitoring does not perform the
correlation decision itself; it only forwards the link hint for the registry
to resolve. Every completed probe also authors an operational-trust
`EvidenceEnvelope` with provider `availability`, collector
`availability-poller`, the saved target as its provider reference, the exact
observation/ingest times, and a validity window of twice the effective polling
interval. Before the first completed probe, evidence is explicitly partial and
unknown with reason `availability_not_observed`; monitoring must never encode
that state as a confirmed failure or a healthy observation. The registry owns
rebinding the envelope subject to a canonical resource after correlation.
binding the source envelope to the check resource and cloning a separately
bound envelope for any matched-resource facet projection after correlation.
Availability target kind is monitoring-owned runtime metadata, not a frontend
guess. Saved targets carry the bounded `targetKind` values `machine`, `service`,
and `device`; monitoring must preserve that value in probe status, supplemental

View file

@ -30,6 +30,11 @@ disk behind a client-side page ceiling. Search includes vendor, WWN, transport,
instance, controller, and member target. Explicit `0%` life remaining is a
known critical value for SSD/NVMe media; absent or negative wearout remains the
neutral unknown state.
The shared `internal/api/resources.go` registry builder may repair
availability-check identity by replaying authoritative supplemental records.
That availability composition remains owned by API contracts and unified
resources; it does not make availability rows recovery points, storage health,
backup evidence, or restore authority.
## Canonical Files

View file

@ -1848,19 +1848,31 @@ normalized IP address or one exact normalized hostname matches exactly one
non-availability-owned canonical resource. Zero matches are `standalone`;
multiple matches are `ambiguous`; neither may be guessed. A genuinely
standalone target keeps its `network-endpoint`. An attached target does not
mint a duplicate endpoint.
collapse into the matched resource: it keeps the same source-owned
`network-endpoint` row and projects an additive facet onto the matched
resource.
The attached resource carries every check in the canonical
The source-owned endpoint is the canonical identity for each configured check.
It owns probe status, incidents, history, evidence, and the outgoing `checks`
relationship. The matched resource carries every correlated check in the canonical
`availabilityChecks` facet, keyed by saved target id, while `availability`
remains an additive singular compatibility summary selected from that set.
Adding a second explicit or unambiguously correlated check must retain both
checks on the same resource, emit one `checks` relationship per target, and
must not force the later check into duplicate standalone inventory. Each
attached check's evidence subject is rebound to the owning canonical resource
and includes the exact correlation rule and matched field. Ambiguous and
unresolved standalone evidence carries a typed reason instead.
source-owned endpoint rows, project both facets onto the same resource, and
emit one `checks` relationship per target from the check to that resource.
The check evidence subject is bound to the source-owned endpoint; the projected
facet carries a cloned envelope rebound to the matched resource. Both include
the exact correlation rule and matched field. Ambiguous and unresolved check
evidence carries a typed reason instead.
Availability projection is deliberately narrower than identity merge: it must
not copy the checked service address, name, status, incident, tags, metrics, or
last-seen value into the matched machine or service. Rehydration may seed
`SourceAvailability` identity only from availability-owned endpoints, never
from a host projection, and manual identity links must not erase a configured
check row.
Frontend resource adapters must preserve that same availability identity on
both REST and realtime paths: a thin `network-endpoint` update with
both REST and realtime paths, including plural `availabilityChecks` on matched
resources: a thin `network-endpoint` update with
availability data is still `platformType=availability`, `sourceType=api`, and
must not regress to a generic platform badge in infrastructure rows or drawers.
Infrastructure row presentation must also consume that availability payload as
@ -1880,8 +1892,9 @@ Frontend primitives owns Machines as the operational presentation for those
same agentless checks; unified resources owns the projection contract consumed
there. `StandalonePageSurface.tsx` must fetch both `agent` and
`network-endpoint` resources, keep standalone machines and availability checks
as separate buckets in `standalonePageModel.ts`, exclude every resource whose
availability correlation state is `attached`, and let
as separate buckets in `standalonePageModel.ts`, include every source-owned
`network-endpoint` regardless of correlation state, exclude matched machine or
service projections from the check inventory by resource type, and let
`AvailabilityChecksTable.tsx` render saved probe method, target, latest result,
check age, failure count, and cadence from the canonical availability payload.
Recent check timing and fuller failure context may stay in tooltip or drawer

View file

@ -264,6 +264,71 @@ describe('StandalonePageSurface', () => {
);
});
it('counts every configured check when two attached services share one monitored host', () => {
mocks.pathname = '/standalone/availability';
mocks.useUnifiedResources.mockReturnValue({
resources: () => [
resource({
id: 'agent-core2026',
type: 'agent',
platformType: 'agent',
sources: ['agent', 'availability'],
availability: freshAvailability({
targetId: 'stats-pv',
correlationState: 'attached',
}),
}),
resource({
id: 'availability:stats-pv',
type: 'network-endpoint',
platformType: 'availability',
sources: ['availability'],
availability: freshAvailability({
targetId: 'stats-pv',
correlationState: 'attached',
}),
}),
resource({
id: 'availability:grafana',
type: 'network-endpoint',
platformType: 'availability',
sources: ['availability'],
availability: freshAvailability({
targetId: 'grafana',
correlationState: 'attached',
}),
}),
resource({
id: 'availability:public-api',
type: 'network-endpoint',
platformType: 'availability',
sources: ['availability'],
availability: freshAvailability({ targetId: 'public-api' }),
}),
resource({
id: 'availability:router',
type: 'network-endpoint',
platformType: 'availability',
sources: ['availability'],
availability: freshAvailability({ targetId: 'router' }),
}),
],
loading: () => false,
error: () => null,
refetch: vi.fn(),
});
render(() => <StandalonePageSurface />);
expect(screen.getByTestId('availability-checks-table')).toHaveAttribute(
'data-resource-count',
'4',
);
expect(screen.getByTestId('standalone-posture-summary')).toHaveTextContent(
'All 4 checks reporting normally',
);
});
it('makes failed availability posture visible before the table', () => {
mocks.pathname = '/standalone/availability';
mocks.useUnifiedResources.mockReturnValue({

View file

@ -148,7 +148,7 @@ describe('standalonePageModel', () => {
expect(model.resources.map((item) => item.id)).toEqual(['mac-mini']);
});
it('does not duplicate an attached resource in the availability-check inventory', () => {
it('shows every source-owned check while keeping attached host facets out of the inventory', () => {
const model = buildStandalonePageModel([
resource({
id: 'agent:docker-trust',
@ -162,19 +162,58 @@ describe('standalonePageModel', () => {
},
}),
resource({
id: 'availability:orphan',
id: 'availability:tower-api',
platformType: 'availability',
type: 'network-endpoint',
sources: ['availability'],
availability: {
targetId: 'orphan',
targetId: 'tower-api',
correlationState: 'attached',
available: true,
},
}),
resource({
id: 'availability:tower-grafana',
platformType: 'availability',
type: 'network-endpoint',
sources: ['availability'],
availability: {
targetId: 'tower-grafana',
correlationState: 'attached',
available: true,
},
}),
resource({
id: 'availability:public-api',
platformType: 'availability',
type: 'network-endpoint',
sources: ['availability'],
availability: {
targetId: 'public-api',
correlationState: 'standalone',
available: true,
},
}),
resource({
id: 'availability:router',
platformType: 'availability',
type: 'network-endpoint',
sources: ['availability'],
availability: {
targetId: 'router',
correlationState: 'standalone',
available: true,
},
}),
]);
expect(model.availabilityChecks.map((item) => item.id)).toEqual(['availability:orphan']);
expect(model.availabilityChecks.map((item) => item.id)).toEqual([
'availability:tower-api',
'availability:tower-grafana',
'availability:public-api',
'availability:router',
]);
expect(model.machines.map((item) => item.id)).toEqual(['agent:docker-trust']);
});
it('treats stale or unobserved availability evidence as attention', () => {

View file

@ -23,8 +23,7 @@ export const isStandaloneMachineResource = (resource: Resource): boolean =>
isPulseAgentPlatformResource(resource);
export const isAgentlessAvailabilityResource = (resource: Resource): boolean =>
resource.availability?.correlationState !== 'attached' &&
(resource.type === 'network-endpoint' || resource.platformType === 'availability');
resource.type === 'network-endpoint' || resource.platformType === 'availability';
const AGENT_REPORT_STALE_AFTER_MS = 5 * 60 * 1000;

View file

@ -307,6 +307,111 @@ describe('useUnifiedResources', () => {
dispose();
});
it('keeps an attached availability check distinct from its host across REST and websocket updates', async () => {
const host = createWsResource({
id: 'agent-core2026',
name: 'core2026',
displayName: 'core2026',
availability: {
targetId: 'stats-pv',
correlationState: 'attached',
available: true,
},
availabilityChecks: [
{
targetId: 'stats-pv',
correlationState: 'attached',
available: true,
},
],
});
const check = createWsResource({
id: 'network-endpoint-stats-pv',
type: 'network-endpoint',
name: 'stats_pv',
displayName: 'stats_pv',
platformId: 'stats_pv',
platformType: 'availability',
sourceType: 'api',
sources: ['availability'],
availability: {
targetId: 'stats-pv',
correlationState: 'attached',
available: true,
},
});
setWsState('resources', reconcile([host, check]));
apiFetchMock.mockResolvedValueOnce(
resourceResponse([
{
...v2Resource,
id: host.id,
name: host.name,
availability: host.availability,
availabilityChecks: host.availabilityChecks,
},
{
...v2Resource,
id: check.id,
type: 'network-endpoint',
name: check.name,
sources: ['availability'],
availability: check.availability,
},
]),
);
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources();
});
await waitForResourceCount(() => result!.resources().length, 2);
expect(
result!
.resources()
.map((resource) => resource.id)
.sort(),
).toEqual(['agent-core2026', 'network-endpoint-stats-pv']);
batch(() => {
setWsState(
'resources',
reconcile([
host,
{
...check,
status: 'offline',
availability: {
...check.availability,
available: false,
consecutiveFailures: 2,
},
},
]),
);
setWsState('lastUpdate', 1);
});
await flushAsync();
expect(result!.resources()).toHaveLength(2);
expect(
result!.resources().find((resource) => resource.id === 'network-endpoint-stats-pv'),
).toEqual(
expect.objectContaining({
status: 'offline',
availability: expect.objectContaining({ available: false }),
}),
);
expect(
result!.resources().find((resource) => resource.id === 'agent-core2026')?.availabilityChecks,
).toHaveLength(1);
dispose();
});
it('keeps comma-separated type filters in the browser-encoded resource query shape', async () => {
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;

View file

@ -75,7 +75,7 @@ func TestSyncUnifiedResourceIncidentsCreatesAndClearsAlerts(t *testing.T) {
assertAlertMissing(t, m, alertID)
}
func TestSyncUnifiedResourceIncidentsRoutesAttachedAvailabilityEvidenceThroughLifecycle(t *testing.T) {
func TestSyncUnifiedResourceIncidentsRoutesAttachedCheckThroughSourceOwnedLifecycle(t *testing.T) {
m := newTestManager(t)
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())
@ -84,25 +84,56 @@ func TestSyncUnifiedResourceIncidentsRoutesAttachedAvailabilityEvidenceThroughLi
Provider: "availability",
Collector: "availability-poller",
}
subject := operationaltrust.EvidenceSubject{ResourceID: "docker-service:api"}
subject := operationaltrust.EvidenceSubject{ResourceID: "availability:energy-meter"}
evidenceID, err := operationaltrust.NewEvidenceID(source, subject, observedAt, "energy-meter")
if err != nil {
t.Fatalf("NewEvidenceID() error = %v", err)
}
validUntil := observedAt.Add(2 * time.Minute)
resource := unifiedresources.Resource{
host := unifiedresources.Resource{
ID: "docker-service:api",
Type: unifiedresources.ResourceTypeDockerService,
Name: "API",
Sources: []unifiedresources.DataSource{unifiedresources.SourceDocker, unifiedresources.SourceAvailability},
Availability: &unifiedresources.AvailabilityData{
TargetID: "api-health",
Address: "192.0.2.45",
Protocol: "http",
Port: 8080,
Enabled: true,
Available: true,
CorrelationState: unifiedresources.AvailabilityCorrelationAttached,
TargetID: "energy-meter",
Address: "192.0.2.44",
Protocol: "icmp",
Enabled: true,
Available: false,
ConsecutiveFailures: 2,
CorrelationState: unifiedresources.AvailabilityCorrelationAttached,
},
}
check := unifiedresources.Resource{
ID: "availability:energy-meter",
Type: unifiedresources.ResourceTypeNetworkEndpoint,
Name: "Energy meter",
Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability},
Availability: &unifiedresources.AvailabilityData{
TargetID: "energy-meter",
Address: "192.0.2.44",
Protocol: "icmp",
Enabled: true,
Available: false,
ConsecutiveFailures: 2,
FailureThreshold: 2,
CorrelationState: unifiedresources.AvailabilityCorrelationAttached,
Evidence: &operationaltrust.EvidenceEnvelope{
ID: evidenceID,
Source: source,
Subject: subject,
ObservedAt: observedAt,
IngestedAt: observedAt,
ValidUntil: &validUntil,
Completeness: operationaltrust.EvidenceComplete,
Confidence: operationaltrust.EvidenceConfirmed,
Permissions: operationaltrust.EvidencePermissionsSufficient,
PayloadRef: &operationaltrust.EvidencePayloadRef{
Kind: "availability-target",
ID: "energy-meter",
},
},
},
AvailabilityChecks: []unifiedresources.AvailabilityData{{
TargetID: "energy-meter",
@ -139,9 +170,9 @@ func TestSyncUnifiedResourceIncidentsRoutesAttachedAvailabilityEvidenceThroughLi
}},
}
m.SyncUnifiedResourceIncidents([]unifiedresources.Resource{resource})
m.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, check})
alertID := unifiedIncidentAlertID(resource, resource.Incidents[0])
alertID := unifiedIncidentAlertID(check, check.Incidents[0])
assertAlertPresent(t, m, alertID)
m.mu.RLock()
@ -151,8 +182,8 @@ func TestSyncUnifiedResourceIncidentsRoutesAttachedAvailabilityEvidenceThroughLi
if alert.Type != "resource-incident" {
t.Fatalf("alert type = %q, want resource-incident", alert.Type)
}
if alert.ResourceID != resource.ID {
t.Fatalf("resource id = %q, want %q", alert.ResourceID, resource.ID)
if alert.ResourceID != check.ID {
t.Fatalf("resource id = %q, want source-owned check %q", alert.ResourceID, check.ID)
}
if alert.Instance != "Availability" {
t.Fatalf("instance = %q, want Availability", alert.Instance)

View file

@ -263,17 +263,33 @@ type resourceContractSnapshot struct {
Type string
}
func TestContract_UnifiedSeedSourcesIncludesPluralAvailabilityFacets(t *testing.T) {
sources := unifiedSeedSources([]unifiedresources.Resource{{
ID: "docker-host:ops",
Type: unifiedresources.ResourceTypeAgent,
func TestContract_UnifiedSeedSourcesUsesSourceOwnedAvailabilityEndpoint(t *testing.T) {
hostProjection := unifiedresources.Resource{
ID: "docker-host:ops",
Type: unifiedresources.ResourceTypeAgent,
Sources: []unifiedresources.DataSource{unifiedresources.SourceDocker, unifiedresources.SourceAvailability},
SourceStatus: map[unifiedresources.DataSource]unifiedresources.SourceStatus{
unifiedresources.SourceAvailability: {},
},
AvailabilityChecks: []unifiedresources.AvailabilityData{{
TargetID: "ops-api",
}},
}})
}
sources := unifiedSeedSources([]unifiedresources.Resource{hostProjection})
if _, ok := sources[unifiedresources.SourceAvailability]; ok {
t.Fatalf("seed sources = %v, host projection must not claim provider ownership", sources)
}
check := unifiedresources.Resource{
ID: "network-endpoint:ops-api",
Type: unifiedresources.ResourceTypeNetworkEndpoint,
AvailabilityChecks: []unifiedresources.AvailabilityData{{
TargetID: "ops-api",
}},
}
sources = unifiedSeedSources([]unifiedresources.Resource{hostProjection, check})
if _, ok := sources[unifiedresources.SourceAvailability]; !ok {
t.Fatalf("seed sources = %v, want availability from plural resource facet", sources)
t.Fatalf("seed sources = %v, want availability from source-owned endpoint", sources)
}
}
@ -12944,6 +12960,66 @@ func TestContract_ResourceListAcceptsBrowserEncodedTypeCSV(t *testing.T) {
}
}
func TestContract_ResourceListCountsEveryConfiguredAvailabilityCheck(t *testing.T) {
now := time.Date(2026, 7, 23, 21, 30, 0, 0, time.UTC)
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
resources := []unifiedresources.Resource{{
ID: "agent-core2026",
Type: unifiedresources.ResourceTypeAgent,
Name: "core2026",
Status: unifiedresources.StatusOnline,
LastSeen: now,
Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent, unifiedresources.SourceAvailability},
AvailabilityChecks: []unifiedresources.AvailabilityData{
{TargetID: "stats-pv", CorrelationState: unifiedresources.AvailabilityCorrelationAttached},
{TargetID: "grafana", CorrelationState: unifiedresources.AvailabilityCorrelationAttached},
},
}}
for _, targetID := range []string{"stats-pv", "grafana", "public-api", "router"} {
correlation := unifiedresources.AvailabilityCorrelationStandalone
if targetID == "stats-pv" || targetID == "grafana" {
correlation = unifiedresources.AvailabilityCorrelationAttached
}
resources = append(resources, unifiedresources.Resource{
ID: "network-endpoint:" + targetID,
Type: unifiedresources.ResourceTypeNetworkEndpoint,
Name: targetID,
Status: unifiedresources.StatusOnline,
LastSeen: now,
Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability},
Availability: &unifiedresources.AvailabilityData{
TargetID: targetID,
Enabled: true,
Available: true,
CorrelationState: correlation,
},
})
}
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: resources,
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=network-endpoint&page=1&limit=100", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var response ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(response.Data) != 4 || response.Meta.Total != 4 {
t.Fatalf("availability response count = data:%d total:%d, want 4", len(response.Data), response.Meta.Total)
}
for _, resource := range response.Data {
if resource.Type != unifiedresources.ResourceTypeNetworkEndpoint {
t.Fatalf("availability filter leaked %q resource %q", resource.Type, resource.ID)
}
}
}
func TestContract_StateAndResourceListShareCanonicalMockResourceContract(t *testing.T) {
setMockModeForTest(t, true)

View file

@ -1026,11 +1026,19 @@ func (h *ResourceHandlers) buildRegistry(orgID string) (*unified.ResourceRegistr
registry := unified.NewRegistry(store)
ownedSources := supplementalSnapshotOwnedSources(supplementalProviders, orgID)
supplementalSources := sortedSupplementalSources(supplementalProviders)
if seed.unifiedSource {
registry.IngestResources(seed.resources)
seedSources := unifiedSeedSources(seed.resources)
for source, provider := range supplementalProviders {
if provider == nil || !sourceOwnedBySupplementalProvider(source, ownedSources) || unifiedSeedIncludesSource(seedSources, source) {
for _, source := range supplementalSources {
provider := supplementalProviders[source]
// Availability records are safe authoritative replacements and
// must replay even when an older unified seed contains standalone
// endpoints. Pre-fix seeds could otherwise hide attached targets
// while falsely looking source-complete.
if provider == nil ||
!sourceOwnedBySupplementalProvider(source, ownedSources) ||
(source != unified.SourceAvailability && unifiedSeedIncludesSource(seedSources, source)) {
continue
}
records := supplementalRecordsForOrg(provider, orgID)
@ -1042,7 +1050,8 @@ func (h *ResourceHandlers) buildRegistry(orgID string) (*unified.ResourceRegistr
} else {
registry.IngestSnapshot(unified.SnapshotWithoutSources(seed.snapshot, ownedSources))
for source, provider := range supplementalProviders {
for _, source := range supplementalSources {
provider := supplementalProviders[source]
if provider == nil {
continue
}
@ -1061,6 +1070,25 @@ func (h *ResourceHandlers) buildRegistry(orgID string) (*unified.ResourceRegistr
return registry, nil
}
func sortedSupplementalSources(
providers map[unified.DataSource]SupplementalRecordsProvider,
) []unified.DataSource {
sources := make([]unified.DataSource, 0, len(providers))
for source := range providers {
sources = append(sources, source)
}
sort.Slice(sources, func(i, j int) bool {
if sources[i] == unified.SourceAvailability {
return false
}
if sources[j] == unified.SourceAvailability {
return true
}
return string(sources[i]) < string(sources[j])
})
return sources
}
func (h *ResourceHandlers) registrySeed(orgID string) (registrySeed, error) {
seed := registrySeed{}
@ -1163,13 +1191,20 @@ func unifiedSeedSources(resources []unified.Resource) map[unified.DataSource]str
sources := make(map[unified.DataSource]struct{})
for _, resource := range resources {
availabilityOwned := unified.CanonicalResourceType(resource.Type) == unified.ResourceTypeNetworkEndpoint
for _, source := range resource.Sources {
if normalized := normalizeDataSourceAlias(source); normalized != "" {
if normalized == unified.SourceAvailability && !availabilityOwned {
continue
}
sources[normalized] = struct{}{}
}
}
for source := range resource.SourceStatus {
if normalized := normalizeDataSourceAlias(source); normalized != "" {
if normalized == unified.SourceAvailability && !availabilityOwned {
continue
}
sources[normalized] = struct{}{}
}
}
@ -1178,7 +1213,8 @@ func unifiedSeedSources(resources []unified.Resource) map[unified.DataSource]str
sources[unified.SourceTrueNAS] = struct{}{}
case resource.VMware != nil:
sources[unified.SourceVMware] = struct{}{}
case len(unified.AvailabilityChecksForResource(resource)) > 0:
case availabilityOwned &&
len(unified.AvailabilityChecksForResource(resource)) > 0:
sources[unified.SourceAvailability] = struct{}{}
}
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
@ -63,6 +64,124 @@ type mockSupplementalRecordsProvider struct {
ownedSources []unified.DataSource
}
func TestResourceListRepairsPreFixAvailabilitySnapshotFromConfiguredTargets(t *testing.T) {
now := time.Date(2026, 7, 23, 22, 0, 0, 0, time.UTC)
hostID := "agent-core2026"
host := unified.Resource{
ID: hostID,
Type: unified.ResourceTypeAgent,
Name: "core2026",
Status: unified.StatusOnline,
LastSeen: now,
Sources: []unified.DataSource{unified.SourceAgent, unified.SourceAvailability},
Availability: &unified.AvailabilityData{
TargetID: "stats-pv",
LinkedResourceID: hostID,
Address: "192.0.2.70",
Protocol: "https",
Enabled: true,
Available: true,
CorrelationState: unified.AvailabilityCorrelationAttached,
},
}
seed := []unified.Resource{host}
for _, targetID := range []string{"public-api", "router", "switch"} {
seed = append(seed, unified.Resource{
ID: "network-endpoint:" + targetID,
Type: unified.ResourceTypeNetworkEndpoint,
Name: targetID,
Status: unified.StatusOnline,
LastSeen: now,
Sources: []unified.DataSource{unified.SourceAvailability},
Availability: &unified.AvailabilityData{
TargetID: targetID,
Address: targetID + ".example.test",
Protocol: "https",
Enabled: true,
Available: true,
CorrelationState: unified.AvailabilityCorrelationStandalone,
},
})
}
record := func(targetID, linkedResourceID string) unified.IngestRecord {
availability := &unified.AvailabilityData{
TargetID: targetID,
LinkedResourceID: linkedResourceID,
Address: targetID + ".example.test",
Protocol: "https",
Enabled: true,
Available: true,
}
return unified.IngestRecord{
SourceID: targetID,
Resource: unified.Resource{
Type: unified.ResourceTypeNetworkEndpoint,
Name: targetID,
Status: unified.StatusOnline,
LastSeen: now,
Sources: []unified.DataSource{unified.SourceAvailability},
Availability: availability,
},
Identity: unified.ResourceIdentity{Hostnames: []string{availability.Address}},
}
}
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
h.SetStateProvider(&mutableResourceUnifiedSeedProvider{
resources: seed,
freshness: now,
})
h.SetSupplementalRecordsProvider(unified.SourceAvailability, mockSupplementalRecordsProvider{
records: []unified.IngestRecord{
record("stats-pv", hostID),
record("public-api", ""),
record("router", ""),
record("switch", ""),
},
ownedSources: []unified.DataSource{unified.SourceAvailability},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=network-endpoint&page=1&limit=100", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var response ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(response.Data) != 4 || response.Meta.Total != 4 {
t.Fatalf("repaired availability count = data:%d total:%d, want 4", len(response.Data), response.Meta.Total)
}
foundAttached := false
for _, resource := range response.Data {
if resource.Availability != nil && resource.Availability.TargetID == "stats-pv" {
foundAttached = resource.Availability.CorrelationState == unified.AvailabilityCorrelationAttached
}
}
if !foundAttached {
t.Fatal("pre-fix attached target was not restored as a source-owned endpoint")
}
}
func TestSortedSupplementalSourcesIngestsAvailabilityLast(t *testing.T) {
provider := mockSupplementalRecordsProvider{}
sources := sortedSupplementalSources(map[unified.DataSource]SupplementalRecordsProvider{
unified.SourceAvailability: provider,
unified.SourceTrueNAS: provider,
unified.SourceAgent: provider,
})
want := []unified.DataSource{
unified.SourceAgent,
unified.SourceTrueNAS,
unified.SourceAvailability,
}
if !reflect.DeepEqual(sources, want) {
t.Fatalf("supplemental source order = %v, want %v", sources, want)
}
}
func (m mockSupplementalRecordsProvider) GetCurrentRecords() []unified.IngestRecord {
out := make([]unified.IngestRecord, len(m.records))
copy(out, m.records)

View file

@ -1174,6 +1174,7 @@ type ResourceConvertInput struct {
TrueNAS json.RawMessage
VMware json.RawMessage
Availability json.RawMessage
AvailabilityChecks json.RawMessage
PlatformData json.RawMessage
}
@ -1254,6 +1255,7 @@ func ConvertResourceToFrontend(input ResourceConvertInput) ResourceFrontend {
TrueNAS: input.TrueNAS,
VMware: input.VMware,
Availability: input.Availability,
AvailabilityChecks: input.AvailabilityChecks,
PlatformData: input.PlatformData,
}

View file

@ -1040,28 +1040,29 @@ type ResourceFrontend struct {
// Identity for deduplication
Identity *ResourceIdentityFrontend `json:"identity,omitempty"`
DiscoveryTarget json.RawMessage `json:"discoveryTarget,omitempty"`
MetricsTarget json.RawMessage `json:"metricsTarget,omitempty"`
Canonical json.RawMessage `json:"canonicalIdentity,omitempty"`
Policy json.RawMessage `json:"policy,omitempty"`
AISafeSummary string `json:"aiSafeSummary,omitempty"`
Capabilities json.RawMessage `json:"capabilities,omitempty"`
Relationships json.RawMessage `json:"relationships,omitempty"`
RecentChanges json.RawMessage `json:"recentChanges,omitempty"`
FacetCounts json.RawMessage `json:"facetCounts,omitempty"`
Incidents json.RawMessage `json:"incidents,omitempty"`
Proxmox json.RawMessage `json:"proxmox,omitempty"`
Storage json.RawMessage `json:"storage,omitempty"`
Agent json.RawMessage `json:"agent,omitempty"`
Docker json.RawMessage `json:"docker,omitempty"`
PBS json.RawMessage `json:"pbs,omitempty"`
PMG json.RawMessage `json:"pmg,omitempty"`
Kubernetes json.RawMessage `json:"kubernetes,omitempty"`
PhysicalDisk json.RawMessage `json:"physicalDisk,omitempty"`
Ceph json.RawMessage `json:"ceph,omitempty"`
TrueNAS json.RawMessage `json:"truenas,omitempty"`
VMware json.RawMessage `json:"vmware,omitempty"`
Availability json.RawMessage `json:"availability,omitempty"`
DiscoveryTarget json.RawMessage `json:"discoveryTarget,omitempty"`
MetricsTarget json.RawMessage `json:"metricsTarget,omitempty"`
Canonical json.RawMessage `json:"canonicalIdentity,omitempty"`
Policy json.RawMessage `json:"policy,omitempty"`
AISafeSummary string `json:"aiSafeSummary,omitempty"`
Capabilities json.RawMessage `json:"capabilities,omitempty"`
Relationships json.RawMessage `json:"relationships,omitempty"`
RecentChanges json.RawMessage `json:"recentChanges,omitempty"`
FacetCounts json.RawMessage `json:"facetCounts,omitempty"`
Incidents json.RawMessage `json:"incidents,omitempty"`
Proxmox json.RawMessage `json:"proxmox,omitempty"`
Storage json.RawMessage `json:"storage,omitempty"`
Agent json.RawMessage `json:"agent,omitempty"`
Docker json.RawMessage `json:"docker,omitempty"`
PBS json.RawMessage `json:"pbs,omitempty"`
PMG json.RawMessage `json:"pmg,omitempty"`
Kubernetes json.RawMessage `json:"kubernetes,omitempty"`
PhysicalDisk json.RawMessage `json:"physicalDisk,omitempty"`
Ceph json.RawMessage `json:"ceph,omitempty"`
TrueNAS json.RawMessage `json:"truenas,omitempty"`
VMware json.RawMessage `json:"vmware,omitempty"`
Availability json.RawMessage `json:"availability,omitempty"`
AvailabilityChecks json.RawMessage `json:"availabilityChecks,omitempty"`
// Platform-specific data (JSON blob)
PlatformData json.RawMessage `json:"platformData,omitempty"`

View file

@ -171,6 +171,22 @@ func TestAvailabilityPollProviderListsOnlyEnabledTargets(t *testing.T) {
if len(got) != 1 || got[0] != "enabled" {
t.Fatalf("ListInstances() = %+v, want [enabled]", got)
}
records := availabilityPollProvider{}.SupplementalRecords(monitor, "org-a")
if len(records) != 2 {
t.Fatalf("SupplementalRecords() length = %d, want every configured target", len(records))
}
foundPaused := false
for _, record := range records {
if record.SourceID == "paused" {
foundPaused = true
if record.Resource.Availability == nil || record.Resource.Availability.Enabled {
t.Fatalf("paused target projection = %+v, want disabled check row", record.Resource.Availability)
}
}
}
if !foundPaused {
t.Fatal("disabled configured target is missing from supplemental records")
}
}
func TestAvailabilityResourceFromTargetOmitsUnsetProbeTimes(t *testing.T) {

View file

@ -5606,6 +5606,7 @@ func monitorResourceToConvertInput(resource unifiedresources.Resource) models.Re
TrueNAS: monitorRawJSON(resource.TrueNAS),
VMware: monitorRawJSON(resource.VMware),
Availability: monitorRawJSON(resource.Availability),
AvailabilityChecks: monitorRawJSON(resource.AvailabilityChecks),
PlatformData: monitorPlatformData(resource, resourceType, platformID),
}

View file

@ -20,6 +20,33 @@ import (
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
)
func TestMonitoringBroadcastCarriesEveryAvailabilityProjection(t *testing.T) {
input := monitorResourceToConvertInput(unifiedresources.Resource{
ID: "agent-core2026",
Type: unifiedresources.ResourceTypeAgent,
Name: "core2026",
Status: unifiedresources.StatusOnline,
Sources: []unifiedresources.DataSource{
unifiedresources.SourceAgent,
unifiedresources.SourceAvailability,
},
AvailabilityChecks: []unifiedresources.AvailabilityData{
{TargetID: "stats-pv", CorrelationState: unifiedresources.AvailabilityCorrelationAttached},
{TargetID: "grafana", CorrelationState: unifiedresources.AvailabilityCorrelationAttached},
},
})
payload := string(input.AvailabilityChecks)
if !strings.Contains(payload, `"targetId":"stats-pv"`) ||
!strings.Contains(payload, `"targetId":"grafana"`) {
t.Fatalf("AvailabilityChecks = %s, want both projected checks", payload)
}
frontend := models.ConvertResourceToFrontend(input)
if string(frontend.AvailabilityChecks) != payload {
t.Fatalf("frontend availabilityChecks = %s, want %s", frontend.AvailabilityChecks, payload)
}
}
func TestApplyHostReportOperationReceiptProtocolReplacesCapabilityAuthority(t *testing.T) {
now := time.Now().UTC()
baseReport := func(version int) agentshost.Report {

View file

@ -82,7 +82,18 @@ func availabilityProbeEvidence(t *testing.T, targetID string, observedAt time.Ti
}
}
func TestAvailabilityExplicitLinkAttachesFacetToKnownResource(t *testing.T) {
func availabilityEndpointByTarget(t *testing.T, rr *ResourceRegistry, targetID string) Resource {
t.Helper()
for _, endpoint := range rr.ListByType(ResourceTypeNetworkEndpoint) {
if endpoint.Availability != nil && endpoint.Availability.TargetID == targetID {
return endpoint
}
}
t.Fatalf("availability endpoint %q missing", targetID)
return Resource{}
}
func TestAvailabilityExplicitLinkRetainsCheckAndProjectsFacetToKnownResource(t *testing.T) {
rr := NewRegistry(nil)
hostID := ingestAgentFixture(t, rr, "host-1", "machine-1")
observedAt := time.Now().UTC()
@ -99,9 +110,10 @@ func TestAvailabilityExplicitLinkAttachesFacetToKnownResource(t *testing.T) {
}),
})
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
t.Fatalf("expected 0 standalone network endpoints, got %d", len(got))
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
t.Fatalf("expected configured check to retain its endpoint row, got %d", len(got))
}
check := availabilityEndpointByTarget(t, rr, "probe-1")
host, ok := rr.Get(hostID)
if !ok || host == nil {
t.Fatalf("host %q missing after ingest", hostID)
@ -125,26 +137,41 @@ func TestAvailabilityExplicitLinkAttachesFacetToKnownResource(t *testing.T) {
host.Availability.Evidence.Correlation.Rule != "explicit_resource_link" {
t.Fatalf("evidence correlation = %+v, want explicit resource link", host.Availability.Evidence.Correlation)
}
if check.Availability == nil ||
check.Availability.Evidence == nil ||
check.Availability.Evidence.Subject.ResourceID != check.ID {
t.Fatalf("check evidence = %+v, want source-owned subject %q", check.Availability, check.ID)
}
if host.Status != StatusOnline || len(host.Incidents) != 0 {
t.Fatalf("host status/incidents were overwritten by check: status=%q incidents=%+v", host.Status, host.Incidents)
}
if len(host.Identity.IPAddresses) != 0 {
t.Fatalf("host identity was polluted by service address: %+v", host.Identity)
}
foundChecksRelationship := false
for _, relationship := range host.Relationships {
for _, relationship := range check.Relationships {
if relationship.Type == RelChecks &&
relationship.SourceID == check.ID &&
relationship.TargetID == hostID &&
relationship.Metadata["targetId"] == "probe-1" {
if relationship.ID == "" {
t.Fatal("availability relationship is missing its stable ID")
}
if relationship.EvidenceID != host.Availability.Evidence.ID {
if relationship.EvidenceID != check.Availability.Evidence.ID {
t.Fatalf(
"availability relationship evidence = %q, want %q",
relationship.EvidenceID,
host.Availability.Evidence.ID,
check.Availability.Evidence.ID,
)
}
foundChecksRelationship = true
}
}
if !foundChecksRelationship {
t.Fatalf("relationships = %+v, want availability checks edge", host.Relationships)
t.Fatalf("relationships = %+v, want availability checks edge", check.Relationships)
}
if len(host.Relationships) != 0 {
t.Fatalf("host must not own the check relationship, got %+v", host.Relationships)
}
}
@ -176,8 +203,8 @@ func TestAvailabilityExactIPMatchAttachesToKnownResource(t *testing.T) {
}),
})
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
t.Fatalf("expected probe to attach (0 endpoints), got %d", len(got))
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
t.Fatalf("expected attached probe to retain one endpoint, got %d", len(got))
}
host, ok := rr.Get(hostID)
if !ok || host == nil || host.Availability == nil || host.Availability.TargetID != "probe-ip" {
@ -210,8 +237,8 @@ func TestAvailabilityExactFullHostnameMatchAttachesToKnownResource(t *testing.T)
record.Identity = ResourceIdentity{Hostnames: []string{"api.example.test"}}
rr.IngestRecords(SourceAvailability, []IngestRecord{record})
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
t.Fatalf("expected hostname probe to attach, got %d standalone endpoints", len(got))
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
t.Fatalf("expected attached hostname probe to retain its endpoint, got %d", len(got))
}
host, ok := rr.Get(hostID)
if !ok || host == nil || host.Availability == nil {
@ -303,7 +330,7 @@ func TestAvailabilityInvalidExplicitLinkFailsClosedBeforeAddressCorrelation(t *t
}
}
func TestAvailabilityKeepsMultipleChecksOnOneCanonicalResource(t *testing.T) {
func TestAvailabilityKeepsEveryConfiguredCheckWithMultipleServicesOnOneHost(t *testing.T) {
rr := NewRegistry(nil)
hostID := ingestAgentFixture(t, rr, "host-1", "machine-1")
@ -316,9 +343,6 @@ func TestAvailabilityKeepsMultipleChecksOnOneCanonicalResource(t *testing.T) {
Available: true,
}),
})
// A second probe explicitly linked to the same host belongs to the same
// canonical resource. The singular facet stays as a compatibility summary
// while the plural facet and relationships retain both checks.
rr.IngestRecords(SourceAvailability, []IngestRecord{
availabilityProbeRecord("probe-b", "203.0.113.11", &AvailabilityData{
LinkedResourceID: hostID,
@ -327,6 +351,8 @@ func TestAvailabilityKeepsMultipleChecksOnOneCanonicalResource(t *testing.T) {
Enabled: true,
Available: true,
}),
availabilityProbeRecord("probe-public-api", "198.51.100.20", nil),
availabilityProbeRecord("probe-router", "198.51.100.21", nil),
})
host, ok := rr.Get(hostID)
@ -344,16 +370,209 @@ func TestAvailabilityKeepsMultipleChecksOnOneCanonicalResource(t *testing.T) {
if !targets["probe-a"] || !targets["probe-b"] {
t.Fatalf("availability targets = %+v, want probe-a and probe-b", targets)
}
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
t.Fatalf("expected both probes to attach (0 endpoints), got %d", len(got))
endpoints := rr.ListByType(ResourceTypeNetworkEndpoint)
if len(endpoints) != 4 {
t.Fatalf("availability endpoint count = %d, want all 4 configured checks", len(endpoints))
}
endpointTargets := map[string]bool{}
for _, endpoint := range endpoints {
if endpoint.Availability == nil {
t.Fatalf("endpoint %q missing availability facet", endpoint.ID)
}
endpointTargets[endpoint.Availability.TargetID] = true
}
for _, targetID := range []string{"probe-a", "probe-b", "probe-public-api", "probe-router"} {
if !endpointTargets[targetID] {
t.Fatalf("availability endpoint targets = %+v, missing %q", endpointTargets, targetID)
}
}
stats := rr.Stats()
if stats.ByType[ResourceTypeNetworkEndpoint] != 4 {
t.Fatalf("network endpoint stats = %d, want 4", stats.ByType[ResourceTypeNetworkEndpoint])
}
checkRelationships := 0
for _, relationship := range host.Relationships {
if relationship.Type == RelChecks {
checkRelationships++
for _, endpoint := range endpoints {
for _, relationship := range endpoint.Relationships {
if relationship.Type == RelChecks {
checkRelationships++
}
}
}
if checkRelationships != 2 {
t.Fatalf("checks relationships = %d, want 2: %+v", checkRelationships, host.Relationships)
t.Fatalf("checks relationships = %d, want 2", checkRelationships)
}
}
func TestAvailabilityEditReplacesEndpointAndMovesProjection(t *testing.T) {
rr := NewRegistry(nil)
hostA := ingestAgentFixture(t, rr, "host-a", "machine-a")
now := time.Now().UTC()
rr.IngestRecords(SourceAgent, []IngestRecord{{
SourceID: "host-b",
Resource: Resource{
Type: ResourceTypeAgent,
Name: "host-b",
Status: StatusOnline,
LastSeen: now,
},
Identity: ResourceIdentity{MachineID: "machine-b"},
}})
var hostB string
for _, host := range rr.ListByType(ResourceTypeAgent) {
if host.ID != hostA {
hostB = host.ID
}
}
if hostB == "" {
t.Fatal("second host missing")
}
failed := availabilityProbeRecord("probe-edit", "192.0.2.50", &AvailabilityData{
LinkedResourceID: hostA,
Address: "192.0.2.50",
Protocol: "http",
Enabled: true,
Available: false,
})
failed.Resource.Status = StatusOffline
failed.Resource.Incidents = []ResourceIncident{{
Provider: string(SourceAvailability),
NativeID: "probe-edit",
Code: "availability_unreachable",
}}
rr.IngestRecords(SourceAvailability, []IngestRecord{failed})
recovered := availabilityProbeRecord("probe-edit", "192.0.2.51", &AvailabilityData{
LinkedResourceID: hostB,
Address: "192.0.2.51",
Protocol: "https",
Enabled: true,
Available: true,
})
rr.IngestRecords(SourceAvailability, []IngestRecord{recovered})
oldHost, _ := rr.Get(hostA)
if len(AvailabilityChecksForResource(*oldHost)) != 0 || hasDataSource(oldHost.Sources, SourceAvailability) {
t.Fatalf("old host retained moved projection: %+v", oldHost)
}
newHost, _ := rr.Get(hostB)
if checks := AvailabilityChecksForResource(*newHost); len(checks) != 1 ||
checks[0].Address != "192.0.2.51" {
t.Fatalf("new host projection = %+v, want edited endpoint", checks)
}
check := availabilityEndpointByTarget(t, rr, "probe-edit")
if check.Status != StatusOnline || len(check.Incidents) != 0 {
t.Fatalf("edited check retained failed state: status=%q incidents=%+v", check.Status, check.Incidents)
}
if check.Availability.Address != "192.0.2.51" || check.Availability.Protocol != "https" {
t.Fatalf("edited check = %+v, want replacement endpoint", check.Availability)
}
}
func TestAvailabilityRehydrateKeepsCheckIdentitySeparateFromProjection(t *testing.T) {
rr := NewRegistry(nil)
hostID := ingestAgentFixture(t, rr, "host-1", "machine-1")
rr.IngestRecords(SourceAvailability, []IngestRecord{
availabilityProbeRecord("probe-restart", "192.0.2.60", &AvailabilityData{
LinkedResourceID: hostID,
Address: "192.0.2.60",
Protocol: "tcp",
Enabled: true,
Available: true,
}),
})
checkBefore := availabilityEndpointByTarget(t, rr, "probe-restart")
restarted := NewRegistry(nil)
restarted.IngestResources(rr.List())
restarted.IngestRecords(SourceAvailability, []IngestRecord{
availabilityProbeRecord("probe-restart", "192.0.2.60", &AvailabilityData{
LinkedResourceID: hostID,
Address: "192.0.2.60",
Protocol: "tcp",
Enabled: true,
Available: true,
}),
})
checkAfter := availabilityEndpointByTarget(t, restarted, "probe-restart")
if checkAfter.ID != checkBefore.ID {
t.Fatalf("check ID changed across rehydrate: %q -> %q", checkBefore.ID, checkAfter.ID)
}
host, _ := restarted.Get(hostID)
if checks := AvailabilityChecksForResource(*host); len(checks) != 1 ||
checks[0].TargetID != "probe-restart" {
t.Fatalf("host projection after rehydrate = %+v", checks)
}
}
func TestAvailabilityManualIdentityLinkCannotEraseConfiguredCheck(t *testing.T) {
initial := NewRegistry(nil)
hostID := ingestAgentFixture(t, initial, "host-1", "machine-1")
initial.IngestRecords(SourceAvailability, []IngestRecord{
availabilityProbeRecord("probe-linked", "192.0.2.80", &AvailabilityData{
LinkedResourceID: hostID,
Address: "192.0.2.80",
Protocol: "https",
Enabled: true,
Available: true,
}),
})
checkID := availabilityEndpointByTarget(t, initial, "probe-linked").ID
store := NewMemoryStore()
if err := store.AddLink(ResourceLink{
ResourceA: checkID,
ResourceB: hostID,
PrimaryID: hostID,
}); err != nil {
t.Fatalf("AddLink(): %v", err)
}
rehydrated := NewRegistry(store)
rehydrated.IngestResources(initial.List())
if _, ok := rehydrated.Get(checkID); !ok {
t.Fatalf("manual link erased configured check %q", checkID)
}
if _, ok := rehydrated.Get(hostID); !ok {
t.Fatalf("manual link erased monitored host %q", hostID)
}
if got := len(rehydrated.ListByType(ResourceTypeNetworkEndpoint)); got != 1 {
t.Fatalf("availability check count = %d, want 1", got)
}
}
func TestAvailabilityIdentityRemainsTenantLocal(t *testing.T) {
buildTenant := func(machineID string) (*ResourceRegistry, string) {
rr := NewRegistry(nil)
hostID := ingestAgentFixture(t, rr, "shared-host", machineID)
rr.IngestRecords(SourceAvailability, []IngestRecord{
availabilityProbeRecord("shared-check", "192.0.2.90", &AvailabilityData{
LinkedResourceID: hostID,
Address: "192.0.2.90",
Protocol: "tcp",
Enabled: true,
Available: true,
}),
})
return rr, hostID
}
tenantA, hostA := buildTenant("tenant-a-machine")
tenantB, hostB := buildTenant("tenant-b-machine")
checkA := availabilityEndpointByTarget(t, tenantA, "shared-check")
checkB := availabilityEndpointByTarget(t, tenantB, "shared-check")
if checkA.ID != checkB.ID {
t.Fatalf("tenant-local source identity changed for same target: %q vs %q", checkA.ID, checkB.ID)
}
if hostA == hostB {
t.Fatalf("tenant fixture hosts unexpectedly share canonical ID %q", hostA)
}
if checkA.Relationships[0].TargetID != hostA || checkB.Relationships[0].TargetID != hostB {
t.Fatalf(
"cross-tenant projection: tenant A=%+v tenant B=%+v",
checkA.Relationships,
checkB.Relationships,
)
}
}

View file

@ -183,6 +183,10 @@ func resourceChangedFields(before, after Resource) []string {
if !reflect.DeepEqual(before.Identity, after.Identity) {
changed = append(changed, "identity")
}
if (isAvailabilityOwnedResource(before) || isAvailabilityOwnedResource(after)) &&
availabilityConfigurationChanged(before.Availability, after.Availability) {
changed = append(changed, "availability.configuration")
}
if resourceIncidentChanged(before, after) {
changed = append(changed, "incidents")
}
@ -205,6 +209,45 @@ func resourceChangedFields(before, after Resource) []string {
return changed
}
func availabilityConfigurationChanged(before, after *AvailabilityData) bool {
type availabilityConfiguration struct {
TargetID string
LinkedResourceID string
Name string
TargetKind string
Address string
Protocol string
UDPMode string
Port int
Path string
Enabled bool
FailureThreshold int
PollIntervalSeconds int
TimeoutMillis int
}
project := func(data *AvailabilityData) availabilityConfiguration {
if data == nil {
return availabilityConfiguration{}
}
return availabilityConfiguration{
TargetID: strings.TrimSpace(data.TargetID),
LinkedResourceID: strings.TrimSpace(data.LinkedResourceID),
Name: strings.TrimSpace(data.Name),
TargetKind: strings.TrimSpace(data.TargetKind),
Address: strings.TrimSpace(data.Address),
Protocol: strings.TrimSpace(data.Protocol),
UDPMode: strings.TrimSpace(data.UDPMode),
Port: data.Port,
Path: strings.TrimSpace(data.Path),
Enabled: data.Enabled,
FailureThreshold: data.FailureThreshold,
PollIntervalSeconds: data.PollIntervalSeconds,
TimeoutMillis: data.TimeoutMillis,
}
}
return project(before) != project(after)
}
// relationshipsEquivalent reports whether two relationship sets describe the
// same edges. Registry rebuilds reconstruct every relationship with fresh
// ObservedAt/LastSeenAt stamps and metadata maps, so comparing with

View file

@ -317,6 +317,51 @@ func TestBuildResourceChange_IgnoresUpdateStatusLastCheckedOnly(t *testing.T) {
}
}
func TestBuildResourceChange_TracksAvailabilityEditsWithoutRecordingPollChurn(t *testing.T) {
checkedAt := time.Date(2026, 7, 23, 20, 0, 0, 0, time.UTC)
before := Resource{
ID: "availability:stats-pv",
Type: ResourceTypeNetworkEndpoint,
Name: "stats_pv",
Status: StatusOnline,
Sources: []DataSource{SourceAvailability},
Availability: &AvailabilityData{
TargetID: "stats-pv",
Address: "192.0.2.70",
Protocol: "https",
Port: 443,
Enabled: true,
Available: true,
LastChecked: &checkedAt,
LatencyMillis: 12,
PollIntervalSeconds: 60,
},
}
polled := before
polledAvailability := *before.Availability
nextCheckedAt := checkedAt.Add(time.Minute)
polledAvailability.LastChecked = &nextCheckedAt
polledAvailability.LatencyMillis = 18
polled.Availability = &polledAvailability
if change := buildResourceChange(before, true, polled, true, nextCheckedAt, nil, SourcePulseDiff, ""); change != nil {
t.Fatalf("poll-only observation emitted history churn: %+v", change)
}
edited := polled
editedAvailability := polledAvailability
editedAvailability.Address = "192.0.2.71"
editedAvailability.Protocol = "http"
editedAvailability.Port = 8080
edited.Availability = &editedAvailability
change := buildResourceChange(before, true, edited, true, nextCheckedAt, nil, SourcePulseDiff, "")
if change == nil || change.Kind != ChangeConfigUpdate {
t.Fatalf("availability edit change = %+v, want config update", change)
}
if !sameStringSet(mustChangedFields(t, change), []string{"availability.configuration"}) {
t.Fatalf("changedFields = %+v, want availability.configuration", mustChangedFields(t, change))
}
}
func TestBuildResourceChange_ClassifiesKubernetesRestartChange(t *testing.T) {
before := Resource{
ID: "pod:1",

View file

@ -629,7 +629,8 @@ func TestAPIResourcesKeepsOwnedSupplementalGapFillAndVMwareAlias(t *testing.T) {
requiredSnippets := []string{
"seedSources := unifiedSeedSources(seed.resources)",
"!sourceOwnedBySupplementalProvider(source, ownedSources) || unifiedSeedIncludesSource(seedSources, source)",
"!sourceOwnedBySupplementalProvider(source, ownedSources)",
"(source != unified.SourceAvailability && unifiedSeedIncludesSource(seedSources, source))",
`case "vmware", "vmware-vsphere":`,
}
for _, snippet := range requiredSnippets {

View file

@ -1,6 +1,7 @@
package unifiedresources
import (
"sort"
"strings"
"sync"
"time"
@ -150,7 +151,21 @@ func (a *MonitorAdapter) replaceRegistry(snapshot models.StateSnapshot, recordsB
rebuilt := NewRegistry(registry.store)
staleThresholds := a.currentStaleThresholds()
rebuilt.IngestSnapshotWithStaleThresholds(snapshot, staleThresholds)
for source, records := range recordsBySource {
sources := make([]DataSource, 0, len(recordsBySource))
for source := range recordsBySource {
sources = append(sources, source)
}
sort.Slice(sources, func(i, j int) bool {
if sources[i] == SourceAvailability {
return false
}
if sources[j] == SourceAvailability {
return true
}
return string(sources[i]) < string(sources[j])
})
for _, source := range sources {
records := recordsBySource[source]
if len(records) == 0 || strings.TrimSpace(string(source)) == "" {
continue
}

View file

@ -414,6 +414,117 @@ func TestMonitorAdapterRecordsSupplementalChangeTimeline(t *testing.T) {
}
}
func TestMonitorAdapterAvailabilityChecksSurviveRebuildAndRecordDeletionByCheckID(t *testing.T) {
store := NewMemoryStore()
adapter := NewMonitorAdapter(NewRegistry(store))
now := time.Date(2026, 7, 23, 20, 0, 0, 0, time.UTC)
hostID := MachineIdentityCanonicalID(ResourceTypeAgent, "machine-core2026")
snapshot := models.StateSnapshot{
Hosts: []models.Host{{
ID: "agent-core2026",
Hostname: "core2026",
MachineID: "machine-core2026",
Status: "online",
LastSeen: now,
}},
LastUpdate: now,
}
records := map[DataSource][]IngestRecord{
SourceAvailability: {
availabilityProbeRecord("stats-pv", "192.0.2.70", &AvailabilityData{
LinkedResourceID: hostID,
Address: "192.0.2.70",
Protocol: "https",
Enabled: true,
Available: true,
}),
},
}
adapter.PopulateSnapshotAndSupplemental(snapshot, records)
checks := adapter.currentRegistry().ListByType(ResourceTypeNetworkEndpoint)
if len(checks) != 1 {
t.Fatalf("check count after rebuild = %d, want 1", len(checks))
}
checkID := checks[0].ID
host, ok := adapter.currentRegistry().Get(hostID)
if !ok || len(AvailabilityChecksForResource(*host)) != 1 {
t.Fatalf("host projection after rebuild = %+v", host)
}
// A reload/restart rebuild must preserve the canonical check row instead
// of seeding the provider mapping from the host projection.
snapshot.LastUpdate = now.Add(time.Minute)
adapter.PopulateSnapshotAndSupplemental(snapshot, records)
checks = adapter.currentRegistry().ListByType(ResourceTypeNetworkEndpoint)
if len(checks) != 1 || checks[0].ID != checkID {
t.Fatalf("check after restart = %+v, want stable ID %q", checks, checkID)
}
// Deleting the configured check means the next atomic replacement carries
// no availability records. Both the row and its host projection disappear.
snapshot.LastUpdate = now.Add(2 * time.Minute)
adapter.PopulateSnapshotAndSupplemental(snapshot, nil)
if got := adapter.currentRegistry().ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
t.Fatalf("check count after deletion = %d, want 0", len(got))
}
host, ok = adapter.currentRegistry().Get(hostID)
if !ok || len(AvailabilityChecksForResource(*host)) != 0 {
t.Fatalf("host retained deleted projection: %+v", host)
}
changes, err := store.GetRecentChanges(checkID, time.Time{}, 10)
if err != nil {
t.Fatalf("GetRecentChanges(%q): %v", checkID, err)
}
changeTypes := map[string]bool{}
for _, change := range changes {
if change.Metadata != nil {
if changeType, _ := change.Metadata["changeType"].(string); changeType != "" {
changeTypes[changeType] = true
}
}
}
if !changeTypes["resource_created"] || !changeTypes["resource_removed"] {
t.Fatalf("check history change types = %+v, want create and remove", changeTypes)
}
}
func TestMonitorAdapterIngestsAvailabilityAfterCorrelatableSupplementalSources(t *testing.T) {
adapter := NewMonitorAdapter(NewRegistry(nil))
now := time.Date(2026, 7, 23, 21, 0, 0, 0, time.UTC)
host := models.Host{
ID: "agent-supplemental",
Hostname: "supplemental-host",
MachineID: "supplemental-machine",
Status: "online",
LastSeen: now,
}
hostID := MachineIdentityCanonicalID(ResourceTypeAgent, host.MachineID)
adapter.PopulateSnapshotAndSupplemental(models.StateSnapshot{LastUpdate: now}, map[DataSource][]IngestRecord{
SourceAvailability: {
availabilityProbeRecord("supplemental-check", "192.0.2.75", &AvailabilityData{
LinkedResourceID: hostID,
Address: "192.0.2.75",
Protocol: "tcp",
Enabled: true,
Available: true,
}),
},
SourceAgent: {HostIngestRecord(host)},
})
check := availabilityEndpointByTarget(t, adapter.currentRegistry(), "supplemental-check")
if len(check.Relationships) != 1 || check.Relationships[0].TargetID != hostID {
t.Fatalf("availability was ingested before its supplemental target: %+v", check.Relationships)
}
projectedHost, ok := adapter.currentRegistry().Get(hostID)
if !ok || len(AvailabilityChecksForResource(*projectedHost)) != 1 {
t.Fatalf("supplemental host projection = %+v", projectedHost)
}
}
func TestMonitorAdapterRecordChangeForwardsToStore(t *testing.T) {
store := NewMemoryStore()
adapter := NewMonitorAdapter(NewRegistry(store))

View file

@ -705,6 +705,12 @@ func (rr *ResourceRegistry) seedSourceMappingsFromResourceLocked(resource *Resou
for _, source := range sources {
if source == SourceAvailability {
// Availability facets projected onto a monitored resource do not
// own the provider identity. Only the source-owned endpoint may
// restore targetID -> resourceID mappings during rehydration.
if !isAvailabilityOwnedResource(*resource) {
continue
}
if _, ok := rr.bySource[source]; !ok {
rr.bySource[source] = make(map[string]string)
}
@ -2420,6 +2426,13 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
resource.parentBySource = make(map[DataSource]string)
rr.setSourceParent(&resource, source, resource.ParentID)
// An availability target is always a first-class, source-owned resource.
// Correlation may additionally project its facet onto a monitored machine
// or service, but it must never replace the configured check row.
if source == SourceAvailability && resource.Type == ResourceTypeNetworkEndpoint {
return rr.ingestAvailabilityCheckLocked(sourceID, resource, identity)
}
// Rehydrated registries seed exact source mappings from the persisted
// unified snapshot. Honor that durable mapping before attempting weaker
// identity correlation, but fail closed if a colliding source key belongs
@ -2446,33 +2459,6 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
candidateID := rr.sourceSpecificID(resource.Type, source, sourceID)
// Agentless availability probes attach as a facet on the known resource
// they monitor instead of minting a parallel network-endpoint. An explicit
// linkedResourceId wins; otherwise an exact, unique normalized IP or full
// hostname may attach. Ambiguous or invalid correlations remain explicit
// on the fallback endpoint and are never guessed.
var availabilityResolution availabilityLinkResolution
if source == SourceAvailability && resource.Type == ResourceTypeNetworkEndpoint {
availabilityResolution = rr.resolveAvailabilityLink(resource)
operationaltrust.GetMetrics().ObserveIdentityCorrelation(string(availabilityResolution.State))
applyAvailabilityResolution(resource.Availability, availabilityResolution)
if linked := availabilityResolution.ResourceID; linked != "" {
if existing := rr.resources[linked]; existing != nil {
bindAvailabilityEvidence(resource.Availability, existing.ID, availabilityResolution)
existing.Relationships = upsertAvailabilityCheckRelationship(
existing.Relationships,
candidateID,
existing.ID,
resource.Availability,
availabilityResolution,
)
rr.mergeInto(existing, resource, source)
rr.bySource[source][sourceID] = existing.ID
return existing.ID
}
}
}
if resource.Type == ResourceTypeAgent || resource.Type == ResourceTypePhysicalDisk {
if match, excluded := rr.findMatch(resource, candidateID); match != nil {
existing := rr.resources[match.ResourceB]
@ -2491,10 +2477,6 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
}
resource.ID = rr.chooseNewID(resource.Type, identity, source, sourceID)
if source == SourceAvailability && resource.Availability != nil {
bindAvailabilityEvidence(resource.Availability, resource.ID, availabilityResolution)
normalizeResourceAvailability(&resource)
}
normalizeResourceRelationships(&resource)
if existing := rr.resources[resource.ID]; existing != nil {
rr.mergeInto(existing, resource, source)
@ -2509,6 +2491,149 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
return resource.ID
}
func (rr *ResourceRegistry) ingestAvailabilityCheckLocked(
sourceID string,
resource Resource,
identity ResourceIdentity,
) string {
checkResourceID := rr.sourceSpecificID(resource.Type, SourceAvailability, sourceID)
if mappedID := CanonicalResourceID(rr.bySource[SourceAvailability][sourceID]); mappedID != "" {
if existing := rr.resources[mappedID]; existing != nil &&
existing.Type == ResourceTypeNetworkEndpoint &&
isAvailabilityOwnedResource(*existing) {
checkResourceID = mappedID
}
}
// Incremental edits can change an explicit link or endpoint identity.
// Remove only this check's prior projection before resolving its new
// target; other checks attached to the same resource remain intact.
rr.removeAvailabilityProjectionLocked(sourceID, checkResourceID)
resolution := rr.resolveAvailabilityLink(resource)
operationaltrust.GetMetrics().ObserveIdentityCorrelation(string(resolution.State))
applyAvailabilityResolution(resource.Availability, resolution)
resource.ID = checkResourceID
resource.Identity = identity
bindAvailabilityEvidence(resource.Availability, checkResourceID, resolution)
normalizeResourceAvailability(&resource)
if targetResourceID := resolution.ResourceID; targetResourceID != "" {
if target := rr.resources[targetResourceID]; target != nil {
resource.Relationships = upsertAvailabilityCheckRelationship(
resource.Relationships,
checkResourceID,
targetResourceID,
resource.Availability,
resolution,
)
rr.projectAvailabilityCheckLocked(target, resource, resolution)
}
}
normalizeResourceRelationships(&resource)
// Availability records are authoritative snapshots for one configured
// target. Replacing the source-owned row clears recovered incidents and
// removes stale endpoint identity after edits.
rr.resources[checkResourceID] = &resource
rr.bySource[SourceAvailability][sourceID] = checkResourceID
rr.matcher.Add(checkResourceID, identity)
return checkResourceID
}
func (rr *ResourceRegistry) projectAvailabilityCheckLocked(
target *Resource,
checkResource Resource,
resolution availabilityLinkResolution,
) {
if target == nil || checkResource.Availability == nil {
return
}
projected := cloneAvailabilityData(checkResource.Availability)
if projected == nil {
return
}
bindAvailabilityEvidence(projected, target.ID, resolution)
target.AvailabilityChecks = mergeAvailabilityChecks(
target.AvailabilityChecks,
target.Availability,
nil,
projected,
)
target.Availability = primaryAvailabilityCheck(target.AvailabilityChecks)
target.Sources = addSource(target.Sources, SourceAvailability)
if target.SourceStatus == nil {
target.SourceStatus = make(map[DataSource]SourceStatus)
}
target.SourceStatus[SourceAvailability] = SourceStatus{
Status: sourceSightingStatus(checkResource.LastSeen),
LastSeen: checkResource.LastSeen,
}
}
func (rr *ResourceRegistry) removeAvailabilityProjectionLocked(
targetID string,
checkResourceID string,
) {
targetID = strings.TrimSpace(targetID)
checkResourceID = CanonicalResourceID(checkResourceID)
if targetID == "" {
return
}
for _, resource := range rr.resources {
if resource == nil || isAvailabilityOwnedResource(*resource) {
continue
}
checks := AvailabilityChecksForResource(*resource)
filteredChecks := make([]AvailabilityData, 0, len(checks))
removed := false
for _, check := range checks {
if strings.TrimSpace(check.TargetID) == targetID {
removed = true
continue
}
filteredChecks = append(filteredChecks, check)
}
if removed {
resource.AvailabilityChecks = filteredChecks
resource.Availability = primaryAvailabilityCheck(filteredChecks)
if len(filteredChecks) == 0 {
resource.Sources = removeDataSource(resource.Sources, SourceAvailability)
delete(resource.SourceStatus, SourceAvailability)
}
}
filteredRelationships := resource.Relationships[:0]
for _, relationship := range resource.Relationships {
relationshipTargetID, _ := relationship.Metadata["targetId"].(string)
if relationship.Type == RelChecks &&
(CanonicalResourceID(relationship.SourceID) == checkResourceID ||
strings.TrimSpace(relationshipTargetID) == targetID) {
continue
}
filteredRelationships = append(filteredRelationships, relationship)
}
resource.Relationships = filteredRelationships
// Builds before the source-owned check contract merged availability
// incidents into the correlated host. Remove that legacy copy while
// retaining incidents from all other checks and providers.
filteredIncidents := resource.Incidents[:0]
for _, incident := range resource.Incidents {
if strings.EqualFold(strings.TrimSpace(incident.Provider), string(SourceAvailability)) &&
strings.TrimSpace(incident.NativeID) == targetID {
continue
}
filteredIncidents = append(filteredIncidents, incident)
}
resource.Incidents = filteredIncidents
}
}
func (rr *ResourceRegistry) mergeLinkedKubernetesNode(
sourceID string,
resource Resource,
@ -3705,6 +3830,12 @@ func (rr *ResourceRegistry) applyManualLinks(thresholds map[DataSource]time.Dura
if other == nil || otherID == primaryID {
continue
}
// Availability checks retain their source-owned identity even when an
// operator links them to another resource. Correlation is represented
// by RelChecks plus the additive facet projection.
if isAvailabilityOwnedResource(*primary) || isAvailabilityOwnedResource(*other) {
continue
}
// A manual link records operator intent to unify identities, but its
// direction must not change the semantic resource shape. In particular,
@ -4733,6 +4864,16 @@ func addSources(sources []DataSource, more []DataSource) []DataSource {
return out
}
func removeDataSource(sources []DataSource, source DataSource) []DataSource {
out := make([]DataSource, 0, len(sources))
for _, existing := range sources {
if existing != source {
out = append(out, existing)
}
}
return out
}
func mergeMetrics(
target *Resource,
existing *ResourceMetrics,

View file

@ -130,8 +130,8 @@ func TestResourceRegistryAvailabilityLinkedResourceResolvesSourceReference(t *te
}),
})
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
t.Fatalf("expected source-referenced probe to attach (0 endpoints), got %d", len(got))
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
t.Fatalf("expected source-referenced probe to retain its endpoint row, got %d", len(got))
}
services := rr.ListByType(ResourceTypeDockerService)
if len(services) != 1 {
@ -5200,8 +5200,8 @@ func TestRegistryAvailabilityLinkAttachesFacetToKnownResource(t *testing.T) {
Identity: ResourceIdentity{IPAddresses: []string{"192.0.2.10"}},
}})
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
t.Fatalf("expected 0 standalone network endpoints, got %d", len(got))
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
t.Fatalf("expected attached availability check to retain its endpoint row, got %d", len(got))
}
host, ok := rr.Get(hostID)
if !ok || host.Availability == nil || host.Availability.TargetID != "probe-1" {

View file

@ -40,10 +40,12 @@ const DOCKER_HOST_ID = "docker-host:operational-trust";
const DOCKER_HOST_NAME = "Operational Trust Docker Host";
const ATTACHED_TCP_TARGET_ID = "ops-api";
const ATTACHED_HTTPS_TARGET_ID = "ops-web";
const ATTACHED_TCP_RESOURCE_ID = "network-endpoint:ops-api";
const ATTACHED_HTTPS_RESOURCE_ID = "network-endpoint:ops-web";
const CANONICAL_AVAILABILITY_SPEC_ID =
"alertspec:provider-incident:22f0f1f19599cd71";
const AVAILABILITY_EVIDENCE_ID = "evidence_2825048c9470f82ba5490f8f8496813a";
const ATTENTION_ID = `${DOCKER_HOST_ID}::${CANONICAL_AVAILABILITY_SPEC_ID}`;
const ATTENTION_ID = `${ATTACHED_TCP_RESOURCE_ID}::${CANONICAL_AVAILABILITY_SPEC_ID}`;
type RouteResource = Record<string, unknown>;
@ -85,6 +87,7 @@ const evidenceEnvelope = ({
validUntil,
completeness = "complete",
confidence = "confirmed",
matchedResourceId = resourceId,
}: {
id: string;
resourceId: string;
@ -93,6 +96,7 @@ const evidenceEnvelope = ({
validUntil?: string;
completeness?: "complete" | "partial" | "unavailable";
confidence?: "confirmed" | "inferred" | "unknown";
matchedResourceId?: string;
}) => ({
id,
source: {
@ -101,8 +105,6 @@ const evidenceEnvelope = ({
},
subject: {
resourceId,
providerRef: targetId,
providerScope: "availability-target",
},
observedAt,
ingestedAt: observedAt,
@ -115,9 +117,9 @@ const evidenceEnvelope = ({
id: targetId,
},
correlation: {
rule: "explicit-linked-resource",
rule: "explicit_resource_link",
matchedFields: {
linkedResourceId: resourceId,
linkedResourceId: matchedResourceId,
},
candidateCount: 1,
},
@ -160,7 +162,7 @@ const availabilityCheck = ({
pollIntervalSeconds: 60,
timeoutMillis: 5_000,
correlationState: "attached",
correlationRule: "explicit-linked-resource",
correlationRule: "explicit_resource_link",
correlationCandidates: 1,
evidence: evidenceEnvelope({
id: `evidence_${targetId}`,
@ -171,6 +173,34 @@ const availabilityCheck = ({
}),
});
const attachedCheckResource = ({
id,
name,
check,
}: {
id: string;
name: string;
check: ReturnType<typeof availabilityCheck>;
}) => ({
id,
type: "network-endpoint",
name,
status: check.available ? "online" : "offline",
lastSeen: check.lastChecked,
sources: ["availability"],
availability: {
...check,
evidence: evidenceEnvelope({
id: `evidence_${check.targetId}_resource`,
resourceId: id,
targetId: check.targetId,
observedAt: check.lastChecked,
validUntil: check.evidence.validUntil,
matchedResourceId: DOCKER_HOST_ID,
}),
},
});
test.describe("Operational trust availability resource facet", () => {
test.setTimeout(180_000);
@ -265,7 +295,7 @@ test.describe("Operational trust availability resource facet", () => {
await expect(httpsCard).toContainText("Checked");
});
test("keeps attached checks out of standalone inventory and does not infer health from stale or missing observations", async ({
test("keeps every attached check in inventory and does not infer health from stale or missing observations", async ({
page,
}, testInfo) => {
test.skip(
@ -278,6 +308,24 @@ test.describe("Operational trust availability resource facet", () => {
const staleValidUntil = new Date(now - 10 * 60_000).toISOString();
const freshObservedAt = new Date(now - 30_000).toISOString();
const freshValidUntil = new Date(now + 2 * 60_000).toISOString();
const attachedTCP = availabilityCheck({
targetId: ATTACHED_TCP_TARGET_ID,
address: "192.0.2.18",
protocol: "tcp",
port: 8007,
observedAt: freshObservedAt,
validUntil: freshValidUntil,
latencyMillis: 12,
});
const attachedHTTPS = availabilityCheck({
targetId: ATTACHED_HTTPS_TARGET_ID,
address: "ops.example.test",
protocol: "https",
path: "/health",
observedAt: freshObservedAt,
validUntil: freshValidUntil,
latencyMillis: 23,
});
await routeResources(page, [
{
@ -287,27 +335,19 @@ test.describe("Operational trust availability resource facet", () => {
status: "online",
lastSeen: freshObservedAt,
sources: ["docker", "availability"],
availability: {
targetId: ATTACHED_TCP_TARGET_ID,
linkedResourceId: DOCKER_HOST_ID,
address: "192.0.2.18",
protocol: "tcp",
port: 8007,
enabled: true,
available: true,
lastChecked: freshObservedAt,
latencyMillis: 12,
pollIntervalSeconds: 60,
correlationState: "attached",
evidence: evidenceEnvelope({
id: "evidence_attached_tcp",
resourceId: DOCKER_HOST_ID,
targetId: ATTACHED_TCP_TARGET_ID,
observedAt: freshObservedAt,
validUntil: freshValidUntil,
}),
},
availability: attachedTCP,
availabilityChecks: [attachedTCP, attachedHTTPS],
},
attachedCheckResource({
id: ATTACHED_TCP_RESOURCE_ID,
name: "Operations API",
check: attachedTCP,
}),
attachedCheckResource({
id: ATTACHED_HTTPS_RESOURCE_ID,
name: "Operations Web",
check: attachedHTTPS,
}),
{
id: "network-endpoint:standalone-switch",
type: "network-endpoint",
@ -397,6 +437,18 @@ test.describe("Operational trust availability resource facet", () => {
const standalonePage = page.getByTestId("standalone-page");
await expect(standalonePage).toBeVisible({ timeout: 30_000 });
await expect(standalonePage.getByText(DOCKER_HOST_NAME)).toHaveCount(0);
await expect(standalonePage.getByText("Operations API")).toBeVisible();
await expect(standalonePage.getByText("Operations Web")).toBeVisible();
await expect(
standalonePage.locator(
`[data-availability-check-row="${ATTACHED_TCP_RESOURCE_ID}"]`,
),
).toBeVisible();
await expect(
standalonePage.locator(
`[data-availability-check-row="${ATTACHED_HTTPS_RESOURCE_ID}"]`,
),
).toBeVisible();
await expect(
standalonePage.getByText("Standalone lab switch"),
).toBeVisible();
@ -422,9 +474,9 @@ test.describe("Operational trust availability resource facet", () => {
await expect(unobservedRow).not.toContainText("Healthy");
const posture = standalonePage.getByTestId("standalone-posture-summary");
await expect(posture).toContainText("1 healthy");
await expect(posture).toContainText("3 healthy");
await expect(posture).toContainText("2 need attention");
await expect(posture).not.toContainText("All 3 checks reporting normally");
await expect(posture).not.toContainText("All 5 checks reporting normally");
});
test("routes an attached availability failure into Patrol with canonical lifecycle evidence", async ({
@ -441,10 +493,10 @@ test.describe("Operational trust availability resource facet", () => {
const item = {
id: ATTENTION_ID,
operationalRecordId: ATTENTION_ID,
subjectResourceId: DOCKER_HOST_ID,
subjectResourceName: DOCKER_HOST_NAME,
subjectResourceType: "docker-host",
title: `Availability check failed for ${DOCKER_HOST_NAME}`,
subjectResourceId: ATTACHED_TCP_RESOURCE_ID,
subjectResourceName: "Operations API",
subjectResourceType: "network-endpoint",
title: "Availability check failed for Operations API",
plainLanguageSummary:
"The attached TCP availability check failed twice and reached its alert threshold.",
severity: "critical",
@ -479,9 +531,7 @@ test.describe("Operational trust availability resource facet", () => {
collector: "availability-poller",
},
subject: {
resourceId: DOCKER_HOST_ID,
providerRef: ATTACHED_TCP_TARGET_ID,
providerScope: "availability-target",
resourceId: ATTACHED_TCP_RESOURCE_ID,
},
observedAt,
ingestedAt,
@ -503,7 +553,7 @@ test.describe("Operational trust availability resource facet", () => {
operationalRecord: {
id: ATTENTION_ID,
canonicalSpecId: CANONICAL_AVAILABILITY_SPEC_ID,
subjectResourceId: DOCKER_HOST_ID,
subjectResourceId: ATTACHED_TCP_RESOURCE_ID,
state: "open",
severity: "critical",
firstObservedAt: observedAt,