Commit Graph

591 Commits

Author SHA1 Message Date
rcourtman 4f14ad6ab9 Use one primary CPU temperature rule everywhere
Three rules chose a node's single CPU temperature. Node history took
CPUMax first, agent history took cpu_package first, and the node drawer
recomputed the maximum of package, CPUMax and every core in the
browser. On an agent-fed node CPUMax is built from cores alone and can
sit below the package, and elsewhere a core can run above the package,
so the drawer's CPU temperature headline could disagree with the line
drawn beneath it and with the History tab. Node alerts and the CPU
low/record tracking used yet another package-first rule.

models.PrimaryCPUTemperatureCelsius is now the one rule: the hottest
usable package, CPU-maximum or core reading, with a sensor-map form for
agents. Node and agent history, the router's live fallbacks, the
unified resource temperature, node temperature alerts and the CPU
low/record tracking all call it. The drawer reads the resource value
through a new Node.cpuTemperature field instead of recomputing it, and
getCpuTemperature is removed.

Mock mode wrote a second package reading under a cpu.package key into
every agent host, including node-linked ones, from a series unrelated
to the node's static reading. A node and its linked agent now carry one
reading, sampled from the agent's series under the agent's real keys,
and both histories seed from that series, so the current reading
continues the line.
2026-10-13 20:15:36 +01:00
rcourtman 49b7abc860 Honour recurring and descendant maintenance in Patrol findings
Patrol's operator-state projection dereferenced the one-shot
MaintenanceStartAt/EndAt whenever IsInMaintenanceAt was true. A
recurring window also reports active but leaves those fields nil, so
the first finding Patrol evaluated during a recurring occurrence
panicked the provider. The projection now takes its bounds from the
active occurrence.

Alerts already silence guests under a host window scoped to
resource_and_descendants, but Patrol read only the exact resource, so
the same planned work dismissed a guest's alerts and still raised its
Patrol findings. Both now resolve maintenance through one rule,
unifiedresources.ActiveMaintenanceCoverageAt: own window plus
descendant-scoped ancestor windows, latest active end wins. Only
maintenance is inherited; monitoring mode, lifecycle and remediation
locks stay exact-resource state. An inherited dismissal says the window
is on a parent resource and names it on the lifecycle event, and the
Manage help now says descendants' new Patrol findings are dismissed
too.
2026-10-13 12:58:17 +01:00
rcourtman 88338e9a7c Preserve canonical resource ownership in Assistant
Keep selection on the registry ID through context, sessions and command
routing so same-name resources cannot cross provider boundaries.
Retain native executor coordinates, legacy inputs that resolve uniquely,
policy redaction and paid execution modes.

Refs #2681
2026-10-13 01:56:59 +01:00
rcourtman 8cff7de026 Restore Patrol's Safe auto-fix and Autopilot modes
Reverts #2863 before any release ships it. The live Pro pricing and
Patrol pages, in English, German and Spanish, sell both modes ("applies
safe fixes in the mode you choose: Ask first, Safe auto-fix, or
Autopilot"), and paying installs chose them. Shipping the retirement
would have silently downgraded those installs while the sales page still
promised the modes.

If the modes are retired, that needs a dated notice: stop selling them
first, warn installs on those modes in-app, and then re-ship the change.
It should not arrive through an engineering cleanup.

The receipt #2863 landed stays in place; this commit adds the receipt
for the restored content.
2026-10-12 14:13:47 +01:00
rcourtman db57faf1c0 Retire Patrol's Safe auto-fix and Autopilot modes (#2863)
Patrol now has two modes: **Watch only** reports findings, and **Ask
first** investigates and proposes fixes. Every fix waits in Actions for
an operator to approve and run it. Safe auto-fix (`assisted`) and
Autopilot (`full`) are retired.

Why: schema-v17 telemetry for the week to 2026-10-11 counts 1 assisted
and 2 full installs out of 612 reporting Patrol, and 4 executed Patrol
actions in 30 days, all on one install. No GitHub issue asks for more
autonomy. Meanwhile the automatic path carried its own policy leases,
acknowledgement store, per-resource allowlist, release gate and Colima
lab. Investigation is what paid installs use, so it stays.

Behaviour
- A saved `assisted`, `full` or `autonomous` level reads as `approval`.
Both autonomy update routes refuse those levels with `400
invalid_autonomy_level`. The acknowledgement routes are gone.
- The core Patrol action broker only plans: `Plan` and `Submit` persist
the plan and return. `TestContract_PatrolActionBrokerOnlyPlans` forbids
execution calls in its source.
- Removed:
- `ExecuteUnderPolicy`, the policy authorization lease, and the policy
execution store methods;
  - Autopilot acknowledgement persistence, its routes and its dialog;
- the per-resource automatic-action allowlist: type, API field and
drawer editor. The store column is written as NULL.
- Kept for compatibility:
- `GetPatrolFullModeUnlocked` stays on the extension contract and
reports false;
- historical action records keep their lease and reason codes decodable,
labelled as retired modes.
- The Patrol inbox no longer has a "continuing without a decision"
bucket: every current issue is an operator decision.

Governance
- RG-06 now proves Patrol never executes without an operator decision
(integration tier).
- RG-02/RG-04 and the RA35/RA39/RA40 proof commands point at surviving
tests. Every `-run` name was checked against `git grep '^func Test'`.
- Pro plan copy, AI docs, privacy telemetry wording and the subsystem
contracts describe the two modes.

Verification
- Go: vet is clean. config, ai, ai/qualification, telemetry,
actionlifecycle, unifiedresources, aicontracts and api pass on
pulse-dev.
- Frontend: type-check and lint are clean; 217 related test files pass.
- Governance: release_control Python suites, status/registry audits and
the docs mirror pass.
- Browser: a pulse-enterprise build of this tree with a Pro licence, run
before and after merging onto main:
  - the Patrol selector has exactly two modes, and each save reads back;
  - retired levels get 400, and the acknowledgement route returns 404;
  - the drawer Manage tab has no allowlist editor and still saves;
  - phone width is fine; the plan page shows no retired modes.
2026-10-12 12:50:34 +01:00
rcourtman afc4c9275d Retire Patrol's Safe auto-fix and Autopilot modes
Patrol could run some fixes on its own in two paid modes: assisted (Safe
auto-fix) and full (Autopilot, behind a server-owned acknowledgement).
Schema-v17 telemetry for the week to 2026-10-11 counts 1 assisted and 2
full installs out of 612 reporting Patrol, 4 executed Patrol actions in
30 days, all on one install, and no GitHub request for more autonomy,
while the automatic path carried its own policy leases, acknowledgement
store, per-resource allowlist, release gate and Colima lab. Investigation
is what paid installs use, so it stays.

Patrol now has two modes. Watch only reports findings. Ask first
investigates and proposes fixes, and every fix waits in Actions for an
operator to approve and run it.

- A saved assisted, full or autonomous level reads as approval, and the
  update routes refuse those levels with 400 invalid_autonomy_level.
- The core Patrol action broker only plans: Plan and Submit persist the
  plan and return. ExecuteUnderPolicy, the policy authorization lease,
  the policy admission store methods, the Autopilot acknowledgement
  persistence, routes and dialog, and the per-resource automatic-action
  allowlist (store column now written as NULL) are gone.
  GetPatrolFullModeUnlocked stays on the extension contract and reports
  false.
- Historical action records keep their policy lease and tenant/resource
  reason codes decodable, labelled as retired modes.
- The Patrol inbox no longer splits issues into "continuing without a
  decision"; every current issue is an operator decision.
- Release gate RG-06 now proves Patrol never executes without an operator
  decision (integration tier) instead of limited unattended autonomy,
  and RG-02/RG-04 point at the surviving human-admission proofs.
- Pro plan copy, AI docs, privacy telemetry wording and the subsystem
  contracts describe the two modes.
2026-10-12 11:52:11 +01:00
rcourtman ce8c8f918c Share demo generation and stabilize workload parent groups
Demo fixture assembly duplicated freshness and operator-link ordering.
Reuse the live monitor builder, retain explicit in-memory links and
capture indexed metrics targets without persisting fixture identities.

vSphere group keys also changed when display metadata arrived, remounting
open drawers and discarding History selection. Keep groups on canonical
parent IDs and use explicit cluster metadata instead of a VM's own name.
2026-10-12 11:26:29 +01:00
rcourtman 39f988b220 Remove AI Discovery
AI Discovery ran a separate model-backed service scanner beside Patrol
and the Assistant: its own store, scheduler, model route, API, tool and
drawer UI. No telemetry counter ever measured its use (the telemetry
discovery_enabled flag OR'd it with network subnet discovery), the
maintainer judged it a flop, and the context it produced is already
available to the models through pulse_query, pulse_read and the
unified-resource context sections.

This deletes internal/servicediscovery, internal/ai/infradiscovery, the
pulse_discovery tool, the /api/discovery routes, the mock Discovery
fixtures, the Discovery tabs in the resource, guest, node and Docker host
drawers, the identified-service cards, readiness badges, provenance
markers, suggested web-interface URLs, the Workloads AI Context column,
the chat "Discovery is off" hint and the AI Service Context settings page.
The old settings route redirects to Assistant settings. The model-bound
sanitizer allow-list existed only to pass Discovery context through, so it
goes too, along with the CopyableCodeRow primitive that only the
Discovery tab used.

Network subnet discovery (/api/discover, the Infrastructure discovery
settings) and the discoveryTarget agent coordinates are separate features
and stay. Stored discovery_enabled, discovery_interval_hours and
discovery_model AI settings are ignored on load; the settings API decoder
is lenient, so older frontends saving those fields still succeed.
2026-10-11 22:20:25 +01:00
rcourtman fc667c2ae3 Merge remote-tracking branch 'origin/main' into pulse/sidecar-live 2026-10-11 12:35:42 +01:00
rcourtman 5d53fa7a3d Remove the Assistant mutation handlers nothing calls
pulse_control only plans typed resource actions into Pulse Actions
(executeControlResource -> typedActionPlanner). The handlers that sat
behind the retired model-authored command, file and container paths
stayed in the tree with no production caller: run command, guest
control, app-container control, Docker control and container update,
Kubernetes scale/restart/delete-pod/exec, and file append/write.
registerFileTools was never called either, so pulse_file_edit is not
registered at all; calls to it are denied by the mutation registry
before any tool lookup.

A whole-program reachability run over cmd/... lists each of them as
unreachable, and so is everything only they used: approval-record and
preflight authoring, the command and native-action audit lanes with
their remediation-lock gate and plan-hash helpers, the Docker host
resolvers, and the file write verification. The executor's autonomous
flag, whose only readers were those handlers, goes with them (the
executor's SetAutonomousMode and the flag argument of SetContext).

Delete them with their tests and the constants, types and test helpers
they stranded. The contract prose that described the removed broker now
says that plan freshness and the operator remediation lock are enforced
by internal/actionlifecycle. Two source pins that named deleted code go
(the publishActionCompleted dispatch sites and the pulse_file_edit
declaration), the standards tests drop their expectations of an
ActionPlan construction in action_audit.go and of the legacy app_container
rejection in the deleted file-edit test, and the RA35 evidence entry for
the deleted run-command test goes with them.

Left alone on purpose: the saved autonomous control level and its
entitlement and approval-admission checks, which stay as legacy
compatibility, Service.SetAutonomousMode and the chat clamp,
SetOnActionCompleted and its router wiring (no dispatcher is left, the
contract prose says so), and the protected-guests plumbing.

Rebuilt on current main: the first version had 213 commits of drift.
Two conflicts, both on code this change deletes. tools_control.go takes
the deletion over the Protected guests cleanup that landed inside
executeRunCommand. action_audit_remediation_lock_test.go goes with the
audit lanes, including the test added for the dispatch gate's read
through a succession; that gate is deleted here, and
TestLockMovedBySuccessionStillRefusesTheIDThePreviousGenerationLists in
internal/actionlifecycle covers the same refusal on the live path.
2026-10-11 12:35:28 +01:00
pulse-triage[bot] 7c66d124b1 test(resources): check the captured timeline change path
Keep the existing platform activity contract assertion bound to the corrected store/provider ownership path. Consume-once destination and refused-publication behaviour remain covered by the deterministic monitoring controls.

Contract-Neutral: Update an internal source-path assertion for the ownership repair; timeline schemas, adapters, event identity and persistence are unchanged.
Change-source: pulse-maintainer
2026-10-10 23:37:11 +01:00
rcourtman f983b8c08f Record why a disk's operator state does not follow its ID
A physical disk keyed by a serial that two machines report is re-keyed
in place to a machine-scoped ID, and the unscoped ID returns on the
rebuild after the other copy goes away. No operator-state row moves with
those changes: after the split the disk reads no posture (unless a row
already sits on its scoped ID), and after the return its original row
applies again, over anything written on the scoped ID in between.

Whether to carry the row has been open since the remediation-lock fix
chipped it. The registry cannot tell one disk seen from two machines
(right to carry to every copy) from two disks behind one fixed serial
(wrong: a carried retirement or mute can hide an unrelated disk's
failure alerts), and a copy that keeps the original would bring back a
row the operator cleared unless a durable record says it was made. No
report of a posture lost this way is known, and a row left behind makes
the disk eligible to alert again, so the rows stay where the operator
put them.

Pin that in registry_test.go on both stores, driving the monitor
adapter's rebuild and reading as alert intent does, with rows compared
on every persisted field and the second machine's copy checked for a
stray row at every step. Copying the row to the incoming copy, on
re-key or into empty scoped IDs at the rebuild, copying a re-keyed
sibling's scoped row onto it, moving the row, carrying a single field
back (its note, its SetBy) or an active window forward, and letting the
scoped row win on return all fail it. Replace the contract's "not
carried" with the decision, its reason, the quiet outcome it accepts and
what would reopen it, keeping the proof lead-in line unchanged so a
peer's addition to the proof list above it does not conflict.
2026-10-10 18:03:39 +01:00
rcourtman 3c813bf793 Stop pin succession erasing an operator split (#2810)
## What

An operator split of a reporter from a sibling that reports the same
machine could be erased by pin succession. A source the operator split
(unlink or report-merge records the exclusion `(sibling's ID, source's
source-specific ID)`) pins the shared machine key on its own
source-specific ID while the sibling is present. With the sibling
absent, the source mints the sibling's ID, and pin succession read the
source's pin as that ID's earlier era. Applying the pair rewrote the
exclusion into one of the ID from itself, so the sibling's return merged
the two.

Unforced rebuild sequence {agent + Docker host, Docker host only, agent
+ Docker host}: 2, 1, 1 agents in 37 of 40 trials on `main`, 2, 1, 2 in
40 of 40 with this change.

## Change

`PersistIdentityPins` (`internal/unifiedresources/canonical_id_pins.go`)
now:

- refuses a succession whose retired ID the operator excluded from its
successor (the registry owns the check, beside the observed-ID check, so
a rebuild that could not read its decisions guards the split from the
exclusions it carried);
- drops the successions of one batch that would retire two IDs the
operator split from each other onto one successor
(`withoutSplitFanInLocked`; only each predecessor's first declaration
counts, as in both stores).

A split that pairs the retired ID with a third resource still follows
the succession. The stores are unchanged; the reporter-era skip from
#2800 and record-declared eras are unchanged.

## Boundary

With the sibling absent the split source is listed under the sibling's
ID, and an operator lock set on its candidate ID does not cover it until
the sibling returns. Before this change the lock was carried onto the
sibling's ID and the split erased; in the other pin-holder state the gap
already existed, and the reporter-era skip in #2800 accepts the same
boundary. The unified-resources contract records it, with the limits of
the check (chains through stale carried decisions, a split recorded
after the registry read its decisions, an already-recorded predecessor
counting toward a fan-in). Keeping a split reporter on its candidate ID
in the sibling's absence would close the gap but renames every currently
split reporter whose sibling is absent; that is a separate decision.

## Proof

`TestPinSuccessionKeepsASplitSourceApart` (both stores): key held by the
split source or by the sibling, a rebuild that cannot read its decisions
(with a control that records the succession through the same store), the
fan-in batch with controls (no split; a stored self-pair exclusion), the
narrowness control (a split of a superseded ID paired with a third
resource still follows), and the filter's own table. Mutation controls
run on pulse-dev: pair check removed, blanket rule, wrong pair, previous
exclusions not carried, fan-in filter not applied, self pair counted,
suppression when decisions were unreadable (with and without an
exclusion), later declarations weighed, whole group dropped, every batch
dropped. Each fails its intended subtests. Full
`internal/unifiedresources` and `internal/monitoring` suites pass on
pulse-dev on the rebased tree, `internal/alerts` on the tree before the
last rebase. `internal/api` there had two failures that are not this
change: `TestServerInfoEndpointReportsDevelopment` (the rsynced tree has
no `.git`) and `TestContainerBootstrapFailuresStopAndClean/hangup` (a
load flake: 5 of 5 alone, and the `configapi` package 2 of 2).
2026-10-10 15:54:14 +01:00
rcourtman b329730e6b Keep a remediation lock readable on an ID a succession retired
A rebuild applies canonical-ID successions to the store, which moves the
predecessor's operator row to the successor, before it publishes the
registry generation that lists the successor. For the length of the
rebuild the previous generation keeps serving the old ID, and the planner,
the Patrol broker and both dispatch gates read the row of that ID. The row
had gone, so a lock the operator set did not refuse an action planned or
dispatched against it. Every kind of succession reaches this window:
pin-driven, Proxmox guest eras, and the host and Docker reporter eras.

GetResourceOperatorState now follows the recorded successions when an ID
holds no row, in both stores, through one walker. It answers as the ID the
chain ends on would, with that row's remediation block alone, so a lock
the operator lifted on the successor stays lifted, a chain that loops
ends, and nothing is written. The SQLite walk is one transaction, because
a succession that commits between two of its statements can move the lock
onto an ID the walk has already passed.

Delaying the move was rejected: later steps of the same rebuild read the
store, and plans already issued against the old ID would not be
protected. Seven tests that used a missing read to prove a row had moved
now read the rows directly. An operator edit to the retired ID inside the
window still does not reach the successor; the contract names it as a
limit.
2026-10-10 14:47:45 +01:00
rcourtman b4cd2f5c9a Stop pin succession erasing an operator split
A source the operator split from a sibling that reports the same machine
(unlink or report-merge) lands on its own source-specific ID and pins the
shared machine key there. The store keeps one pin per key, so with the
sibling absent the source mints the sibling's ID and pin succession read
its pin as that ID's earlier era. Applying the pair rewrote the exclusion
(sibling, source) into (sibling, sibling), and the sibling's return merged
the two: the agent count across {both, source only, both} rebuilds was
2, 1, 1 instead of 2, 1, 2 (37 of 40 trials of the unforced sequence).

PersistIdentityPins now refuses a succession whose retired ID the operator
excluded from its successor, and drops the successions of one batch that
would retire two IDs the operator split from each other onto one
successor. A split that pairs the retired ID with a third resource still
follows the succession. Both checks read the registry's decisions, so a
rebuild that could not read them guards the split from the exclusions it
carried.

While the sibling is absent the split source is listed under the sibling's
ID, so a remediation lock set on its candidate ID does not cover it until
the sibling returns; before, the lock was carried onto the sibling's ID
and the split erased. The unified-resources contract records that boundary
and the limits of the check.
2026-10-10 14:46:41 +01:00
rcourtman 9aa7ce2c92 Build the mock linked view at the rebuild's link boundary (#2799)
## Why

PR 2793 made the monitor's rebuild judge chains of operator links once,
over the assembled estate: it holds the link pass back until the
snapshot and every record source except availability are ingested. The
mock-mode fixture build (`FixtureGraph.unifiedResourceSnapshot`) still
ran a link pass per ingest call, which 2793's contract named as a gap.
For a chain whose answer depends on the order its members' sources
arrive in, mock mode could settle on a different row than the live
rebuild. A Proxmox storage, a TrueNAS VM and a vSphere VM linked in a
cycle is the test estate: mock mode kept the vSphere VM where the
rebuild keeps the TrueNAS VM. PR 2732's invariant is that the mock
staged build projects like live.

## What

- `ResourceRegistry.DeferManualLinks()` and
`ApplyDeferredManualLinks(thresholds)` are exported from
`manual_links.go`. Deferral is opt-in, and a registry with no links
defers nothing, so the shared memoized (unlinked) snapshot is built
exactly as before. The apply takes the stale thresholds the ingests were
given (nil takes the registry's own), like the `Ingest*` calls.
- The fixture build defers, ingests the snapshot and the TrueNAS and
vSphere records, runs the held-back pass once, then ingests
availability, so a check linked to a folded resource still projects onto
its primary.
- `TestFixtureGraphAppliesManualLinksAtEachIngestStage` is renamed
`TestFixtureGraphAppliesManualLinksAtTheRebuildsBoundary` (the old name
would now state the opposite). It keeps the availability projection as a
subtest and adds the cycle on real fixture data, compared against an
actual `MonitorAdapter` rebuild. A per-call control in the test fails
loudly if a fixture change stops separating the two.
- Two unit tests in `registry_merge_policy_test.go` pin the exported
pair: the snapshot and record ingests wait, the pass runs once, and it
judges freshness by the thresholds it is given.
- Contracts: the mock-mode section of unified-resources.md, the clause
2793 wrote about the mock build, the monitoring.md test reference, and a
performance-and-scalability.md note. A stale line is dropped there too:
a check that names a folded resource by canonical ID follows the fold to
the primary
(`TestAvailabilityLinkToALinkFoldedResourceFollowsItsPrimary` pins it).

## Evidence

On pulse-dev against 2793's head:

- Negative control: with `internal/mock/platform_fixtures.go` back at
the base, the cycle subtest fails (`mock build kept [vm vm-aceb...
[proxmox truenas vmware]], want ... [vm vm-7f6e... ...]`), the
availability subtest and the shared-snapshot test still pass; restored,
all pass.
- Second control: ignoring the thresholds argument fails
`TestRegistryDeferredLinkPassJudgesFreshnessByTheCallersThresholds` (a
3-minute-old vSphere disk reading survives under 5-minute thresholds and
loses to the fresh agent under the default).
- Whole `internal/mock` and `internal/unifiedresources` packages pass,
plus the `internal/monitoring` mock-view tests.

Cost, measured with a scratch timing test on the default fixture estate:
the unlinked memoized build is unchanged (about 52 ms and 65 MB with or
without the change). A linked build, which only runs when the fixture
data version or the link list changes, pays one extra refresh round
after the deferred pass: about 3 ms and 5 MB more, the same round the
rebuild runs.

## Not in this change

The mock view's cache key (`sameManualLinks`,
`internal/monitoring/monitor.go`) ignores a link's `CreatedAt`, which
2793's chain precedence now reads, so a timestamp-only replacement of a
link would keep serving the cached row. That file is held by other work;
it is a separate follow-up.
2026-10-10 14:11:43 +01:00
rcourtman 1ae63559b5 Keep a remediation lock when a reporter gains an identity key (#2800)
Follow-up to #2783. That change made an operator's remediation lock
follow a resource through manual links and
`ApplyCanonicalIDSuccessions`, and named two canonical-ID changes it did
not cover. Both had been read in code but not reproduced, so each was
reproduced (or not) against `main` before anything changed.

## What was found

**A host or Docker reporter that gains an identity key: reproduced,
fixed here.** A host agent or Docker host with no machine ID, DMI UUID
or cluster slot is keyed by its source-specific ID and holds no identity
pin (a pin needs a strong key). When a later report carries a key the
resource mints a new ID, and `identityPinIndex.successionsFor` has no
predecessor pin to name, so nothing re-keys the operator's rows. The
lock stays on an ID the registry no longer lists, while the dispatch
gates read the listed ID's row. On `main`, the new registry test fails
for all 12 variants (host agent, Docker host and Swarm join; memory and
SQLite stores; never-auto-remediate and retired). Host agents advertise
action capabilities, so this one is a real way past the lock.

**A physical disk re-keyed in place: reproduced as an orphaned row, but
not a way past the lock.** `rekeyPhysicalDiskLocked` moves a disk from
the unscoped serial ID to a machine-scoped one when a second machine
reports the serial, and nothing moves its operator-state row. A probe
confirmed it (the disk's ID changes, the row stays behind, and the stale
row reappears when the other machine's copy goes away). But a dispatch
needs a capability the resource advertises: the planner plans only
`resource.Capabilities`, the Assistant's control candidates exclude
disks, and no producer gives a physical disk a capability (a
registry-built disk had 0 in the probe). So the lock guards nothing on a
disk today, and a carry for it would be dead weight with a half-covered
return path. What is lost on a disk is its other operator settings
(retirement, maintenance, notes), which is a different, smaller question
and is left out of this change.

## What changed

- `ingestRecord` notes the source-specific ID of each host agent and
Docker host reporter that lands on another ID with a key
(`noteReporterEraLocked`); the snapshot and record ingests hand the
batch to the existing `applyRecordSuccessions` (which skips an ID still
observed), exactly as Proxmox guests' retired IDs already go. The stores
then carry the row, or the lock onto an existing row, once per
predecessor.
- Left out on purpose: Proxmox nodes (they borrow their linked agent's
key, which a link or split can move either way), saved-host continuity,
a source-specific ID that holds a pin (pin succession's decision), and
one an operator split names (a succession would rewrite the pair into an
exclusion of the resource from itself the generation the other side is
absent).
- `SQLiteResourceStore.ApplyCanonicalIDSuccessions` now skips a
predecessor that is already recorded to any successor, as `MemoryStore`
does. The table is keyed by the predecessor, so a different successor
could only be refused by the insert, after a write transaction and a
pin-cache invalidation on every rebuild.
- The physical-disk premise is pinned:
`TestPhysicalDiskResourcesAdvertiseNoActionCapability` (Proxmox, agent
SMART, Unraid and record-fed disks) and
`TestProviderDiskRecordsAdvertiseNoActionCapability` (the TrueNAS
provider's own records). A producer that gives a disk a capability fails
them with a pointer to the contract.
- Contract: the two "not covered here" items in the remediation-lock
paragraph are replaced by what is carried, what is left out and why.

## Limits stated in the contract

- The first rebuild after an upgrade declares every keyed reporter's era
and also restores a lock or row an earlier keyless-to-keyed change left
behind, onto the keyed ID's existing row too, even where the operator
has edited it since. A lock the operator wants gone can be cleared on
the keyed ID afterwards and stays cleared.
- The row moves while the previous generation is still being served (as
for every succession), so an action planned against the old ID in that
window finds no row there. A link the succession rewrites takes effect
in the next generation.
- An ID a reporter held under a report ID it no longer sends is not
recomputable. A change from one machine key to another is a different
machine, not an era.
- An operator-split reporter's rows are left alone by this declaration.
Separately, on `main`, a pin-driven succession can already erase such a
split (the split Docker source's own pin holds the shared machine key
and `successionsFor` names it); that is not touched here.

## Verification

Run on pulse-dev:

- `TestReporterGainingAStrongKeyKeepsItsRemediationLock` (host agent and
Docker host gaining a machine ID, a Docker host joining a Swarm) fails
on `main` and passes with the change;
`TestReporterEraIsDeclaredOnlyForAKeyTheReporterGains` (keyless stays
put, continuity moves nothing, record ingest, node borrow, linked
keyless agent, split exclusion, pinned ID) and
`TestCanonicalIDSuccessionRekeysAPredecessorOnce` pass.
- Whole `internal/unifiedresources` and `internal/truenas` packages pass
on the merged tree.
- Mutation controls, one break per rule against the new tests, all
caught: host agent not declared, Docker host not declared, continuity
declares too, Proxmox node declares too, keyless reporter not filtered,
pin guard dropped, exclusion guard dropped, record epilogue not drained,
snapshot epilogue not drained.
- `internal/monitoring` passes on an earlier revision of this change
(the later revisions only add the guards above); `internal/api` did not
finish inside 40 minutes at load ~80 on pulse-dev, so CI is its
authority.
- Two independent review passes before pushing found: the split-erasure
case (now guarded and tested), a contradictory-pin case (guarded and
tested), a TrueNAS tripwire that did not exercise the provider (now
does), a steady-state SQLite cost (fixed), and several contract
sentences that overclaimed (reworded).

No demand-ledger entry: this is a defect, and neither
`FEATURE_REQUESTS.md` nor GitHub issues carry a report of it.
2026-10-10 13:39:21 +01:00
rcourtman f74fa7b2fb Carry the PBS-reported node name into the connections ledger (#2794)
A PBS machine that was configured by IP or DNS alias still showed in
connected systems as an API row plus a separate host-agent row, even
though the PBS node and the agent report the same hostname.

## Why

The PBS poll records the hostname the node reports about itself
(`PBSInstance.NodeName`) so `/api/connections` can alias the PBS
connection by it and group the agent running on that machine into the
PBS row. The name reaches the unified PBS resource (`PBSData.NodeName`),
but `Monitor.PBSInstancesSnapshot()` rebuilds each instance from the
read-state view, and the view had no accessor for it. The snapshot
therefore always carried an empty `NodeName`, the ledger's reported-name
map was always empty, and the alias never reached `HostAliases`.

The node name was added on 2026-08-01 (397cad802c), after
`pbsInstanceFromReadStateView` already existed, so it has never worked
on this path. The existing tests missed it because they hand the
aggregator its inputs instead of going through the monitor.

## Change

- `PBSInstanceView.NodeName()` exposes `PBSData.NodeName`, distinct from
`Hostname()` (the host of the configured endpoint).
- `pbsInstanceFromReadStateView` copies it. This is the only place a
`models.PBSInstance` is rebuilt from the view.

## Proof

- `TestMonitorPBSInstancesSnapshotCarriesReportedNodeName`: the unified
resource keeps the name (control assertion) and the snapshot must too.
- `TestConnectionsLedger_GroupsHostAgentWithPBSByReportedNodeName`: a
real monitor through `buildAggregatorInputsWithRuntimeSources`,
`buildConnections` and `buildConnectionSystems`. The PBS connection's
`HostAliases` include the reported name and a host agent whose hostname
equals it attaches to the PBS system.
- `TestView_PBSAndPMGInstanceViewAccessors` pins the accessor.
- Negative control: with the one-line conversion change reverted, the
snapshot test fails (`NodeName = ""`) and the ledger test fails (`pbs
host aliases = [backup 192.0.2.40]`). With it, all pass, alongside the
existing alias and grouping tests.

## Limits

- Only direct `root@pam` sessions can read the node name (API-token and
non-superuser connections get none, see the monitoring contract), so
this restores grouping for password-auth PBS connections only.
Token-auth connections still match on what they were configured with.
- The PBS resource's identity hostnames are deliberately unchanged; this
does not alter registry linking.
- The pre-push frontend lint and type-check did not run in the private
clone used for this change (no `node_modules`); the diff touches no
frontend files.
2026-10-10 13:06:59 +01:00
rcourtman ff11b6fd99 Keep a remediation lock when a reporter gains an identity key
A host agent or Docker host that reported no machine ID, DMI UUID or
cluster slot is keyed by its source-specific ID and holds no identity pin,
because a pin needs a strong key. When a later report carried one the
resource minted a new ID, no pin named the old one, and pin-driven
succession declared nothing. The operator's rows, a remediation lock
included, stayed on an ID the registry no longer lists, while the dispatch
gates read the row of the listed ID. Pulse could then act on a host the
operator had told it never to touch.

The retired ID is recomputable from the report itself, as a Proxmox
guest's retired IDs are. ingestRecord now notes the source-specific ID of
each host agent and Docker host reporter that lands on another ID with a
key, and the snapshot and record ingests hand the batch to
applyRecordSuccessions, which skips an ID still observed. The stores
already carry the row, or its lock onto an existing row, once per
predecessor. Proxmox nodes are left out on purpose: they borrow their
linked agent's key, and a link or split can move that borrow either way.

The other uncovered change named in the contract, a physical disk re-keyed
in place to a machine-scoped ID, is not a way around the lock today: the
planner plans only capabilities a resource advertises and no producer gives
a physical disk one. TestPhysicalDiskResourcesAdvertiseNoActionCapability
pins that for the Proxmox, agent, Unraid and TrueNAS producers, and the
contract now says so, including that the disk's other operator settings are
not carried.
2026-10-10 12:38:39 +01:00
rcourtman 55443c7095 Build the mock linked view at the rebuild's link boundary
The monitor's rebuild holds the operator-link pass back until the
snapshot and every record source except availability are ingested, then
judges each chain once, so a fold cannot be locked in before a later
source brings a member that outranks it. The mock-mode fixture build
still ran a pass per ingest call, so a chain whose answer depends on the
order its members' sources arrive in (a Proxmox storage, a TrueNAS VM
and a vSphere VM linked in a cycle) could settle on a different row than
the live rebuild: mock mode kept the vSphere VM where the rebuild keeps
the TrueNAS VM.

Export the registry's deferral as DeferManualLinks and
ApplyDeferredManualLinks (the latter takes the stale thresholds the
ingests were given, as the Ingest* calls do) and have the fixture build
use them: defer, ingest the snapshot and the TrueNAS and vSphere
records, run the held-back pass once, then ingest availability so a check linked
to a folded resource still projects onto its primary. A registry with no
links defers nothing, so the shared memoized snapshot is built exactly as
before; a linked build pays one extra refresh round (about 3 ms of 52 ms
on the default estate), the same round the rebuild runs.

TestFixtureGraphAppliesManualLinksAtTheRebuildsBoundary replaces the
per-ingest-stage test: it keeps the availability projection and pins the
cycle against a real MonitorAdapter rebuild, with a per-call control so a
fixture change cannot silently stop separating the two. The unifiedresources
tests pin the exported pair, including that the deferred pass judges
freshness by the thresholds it is given.

The contract also drops a stale line: a check that names a folded
resource by canonical ID follows the fold to the primary, as
TestAvailabilityLinkToALinkFoldedResourceFollowsItsPrimary pins.
2026-10-10 12:37:23 +01:00
rcourtman 4471dcaaba Fold chained manual links the same way in any link order (#2793)
Operator links that share a member form a chain, and `applyManualLinks`
used to apply them one at a time in store order, looking each member up
only among listed rows. Once an earlier link folded a member into
another row, a later link naming that member skipped. So a chain
depended on the order its links were stored in:

- Agent A linked into VM V, and V linked into storage W. A-V first folds
A into V and V into W. V-W first folds V into W, and A-V then finds V
gone, so A stays its own row.
- A saved host (continuity-only row) whose partner the published
registry had already folded into a third row answered for itself instead
of being held under that row.

Standing the row that holds a folded member in for it (an earlier
attempt) fixed neither: that row then outranked the link's own primary,
and the agent-into-guest direction was lost once the guest was folded.

## What changes

Links now apply as chains
(`internal/unifiedresources/manual_link_chains.go`).

- A link member resolves to its own row or the row a link folded it
into, and is judged by its own recorded shape (agent, hypervisor-managed
guest), not the shape of the row holding it. Shapes ride on the fold
records.
- Every connected set of observed members folds into one root.
Candidates are the members no link folds (every member on a direction
cycle; the chain's guests when every candidate is an agent). Candidate
rows compete by their own identity: guest, then non-agent over agent,
then the earliest-created link, then canonical ID. A single link and an
agreeing chain keep their last primary, as before.
- The spanning tree follows the fold records rows already carry before
any other link, so a refresh that recreates folded members folds them
back along their records. A row folding into another orients its records
away from an anchor (the surviving row's ID when its records name it,
else the linking member), so the surviving row is never recorded as
folded and every folded ID keeps resolving.
- Every link of the chain is recorded on the root (cycle-closing links
included, with each side's own sources), so report-merge can cut a
reported member from every path.
- Saved members never join a chain; they are held under the row their
partner survives in, found after the folds, by the same precedence.
- A joined Proxmox node+agent row records the fold of the node's own ID
before links resolve, so a second link naming it joins the chain in
either store order. A link a newer operator split separates is dropped
before the chain forms (both behaviours were already in the loop and are
kept).
- The monitor's rebuild holds the link pass back until the snapshot and
every record source except availability are ingested, then folds each
chain once over the assembled estate. Availability still comes last.
Live supplemental refresh and the read-state overlay still run a pass
per call.

## Evidence

New proofs rebuild each estate in every link order and compare the
monitor, its read-state overlay and a registry seeded from that read
state, then refresh each record source. Negative controls on pulse-dev
(each reverted):

- On main, 10 of 12 chain cases, the refresh case, the joined-node case,
the changed-link seed cases, the surviving-row hold case, the rebuild
case and the report-merge cycle case all fail; the other two chain cases
guard behaviour that already worked.
- A plain breadth-first tree, a path-only rehang, ranking a pre-fold row
for holds, no shapes on the joined-pair fold, recording the joined-pair
fold during link discovery, forcing the rebuild back to per-source
passes, dropping cycle-link records, and not carrying own sources each
fail the specific test written for them.
- Full `internal/unifiedresources` passes on the final tree (326s under
load); `internal/monitoring`, `internal/api/resourceapi`, `internal/api`
link/continuity/report tests, `internal/mock`, `internal/vmware` and a
full build passed on the tree before the last rebase. Two single
failures under machine load (`TestRetentionLoop_RunsInitialPrune`,
`TestJSONRPCSnapshotContinuesAfterTelemetryTimeout`) passed 6/6 and 8/8
in isolation, with main's link files as well.

## Not in this change

- The mock-mode fixture build (`internal/mock`) and the resources API's
snapshot-seeded fallback still run a pass per source, so a chain whose
answer depends on the order its members' sources arrive in can settle
differently there than in the monitor's rebuild. The contract says so; a
follow-up aligns the mock build.
- Production reaches the resources API's snapshot fallback only when the
monitor has published no unified listing.
2026-10-10 12:30:27 +01:00
rcourtman 884694a046 Carry the PBS-reported node name into the connections ledger
The PBS poll records the hostname the node reports about itself on
PBSInstance.NodeName so /api/connections can alias the PBS connection by
it and group the host agent running on that machine into the PBS row.

The name reaches the unified PBS resource (PBSData.NodeName), but
Monitor.PBSInstancesSnapshot() rebuilds each instance from the read-state
view, and the view had no accessor for it, so the snapshot always carried
an empty NodeName. The ledger's reported-name map was therefore always
empty and a PBS machine configured by IP or DNS alias still showed as an
API row plus a separate agent row. The unit tests missed it because they
hand the aggregator its inputs instead of going through the monitor.

Add PBSInstanceView.NodeName() and copy it in pbsInstanceFromReadStateView.
The new ledger test drives a real monitor through the input builder, the
connection aliases and the system grouping: with the conversion line
removed it fails on the missing alias, with it the agent attaches to the
PBS system.

Only direct root@pam sessions can read the node name (API-token
connections get none), so this restores grouping for password-auth PBS
connections; token-auth ones still match only on what they were
configured with.
2026-10-10 12:06:38 +01:00
rcourtman 95ff7f2804 Fold chained manual links the same way in any link order
applyManualLinks applied operator links one at a time in stored order and
looked each member up only among listed rows. Once an earlier link folded a
member away, a later link naming it skipped, so a chain depended on store
order: an agent linked into a VM that a second link folds into storage
joined the chain in one order and stayed its own row in the other. A saved
host whose partner the published registry had already folded answered for
itself instead of being held under that row.

Links now apply as chains. A link member resolves to its own row or the row
a link folded it into, judged by its own recorded shape (agent,
hypervisor-managed guest), so the agent-into-guest direction survives a
folded guest. Every connected set of observed members folds into one root:
members no link folds are the candidates (all members on a direction
cycle, the chain's guests when every candidate is an agent), ranked by the
row each would leave: guest, then non-agent, then earliest-created link,
then canonical ID. A single link and an agreeing chain keep their last
primary. The spanning tree follows the fold records the rows already carry
before any other link, so a refresh that recreates folded members folds them
back along their records and keeps every folded ID resolving; every link of
the chain is recorded, so a report-merge can undo a cycle. Saved members
never join a chain and are held under the row their partner survives in,
by the same precedence.

The monitor's rebuild now holds the link pass back until the snapshot and
every record source except availability are in, so a chain is judged over
the whole estate rather than folded as its members' sources arrive.

The proofs rebuild each chain in every link order and compare the monitor,
its read-state overlay and a registry seeded from that read state, then
refresh each record source. The mock-mode fixture build and the resources
API's snapshot-seeded fallback still run a pass per source, which the
contract states.
2026-10-10 11:50:41 +01:00
rcourtman e63e50fe13 Carry operator decisions across a rebuild that cannot read them
A registry whose store read for manual links or exclusions failed loaded
none of them, and the monitor adapter published that registry anyway. For
that generation a pair the operator had split listed as one machine again,
manual links unfolded, and the node/agent split decider answered
"undecided", so monitoring relinked a split pair until the next rebuild
that could read the store.

A rebuild now takes the links and exclusions of the generation it replaces
when either read failed, and so does the host-continuity overlay. The
resources still come from the new snapshot: stale resource data is worse
than stale decisions. The next readable rebuild loads the store's current
decisions, so a split recorded or lifted during the outage still lands.

The resources API rebuilds its own registry from the store and is not
covered here.
2026-10-10 11:47:26 +01:00
rcourtman ec50758974 Keep a split PVE disk off the agent's SMART row
Splitting a merged physical disk (the drawer's "Split merged resource",
POST /api/resources/{id}/report-merge) recorded its exclusions and the
registry kept two rows, but the PVE disk poller had already paired the
linked agent's SMART row with the Proxmox disk by WWN, serial or path
before the registry saw either, and read no exclusions. The split Proxmox
row still carried the agent's attributes, I/O, health, any serial, WWN,
type or temperature it filled, and AgentSMARTReported, which hands the
disk's temperature alert to the agent's CheckHost. #2748 listed this as
the part of the split it did not reach.

mergeHostAgentSMARTIntoDisks now asks the resource store
(MonitorAdapter.ProxmoxDiskAgentSMARTSplit) once per matched row. The
answer is physicalDiskSplitLocked's, applied to the agent's disk as the
registry generation holds it and to the Proxmox observation under its own
ID, so the exclusions that keep the registry's rows apart keep the
poller's pair apart: an exclusion naming only the agent's candidate
splits nothing, a replacement in the slot inherits no split recorded
against the disk it replaced, and a manual link that leaves the
generation holding both observations as one disk pairs them again.

A split disk takes nothing from the row. That includes the node-sensor
temperature the agent supplied: the node poll takes its SMART list from a
reporting agent whose report carries a usable reading, so that reading is
the agent's row of the other disk; an SSH-supplied reading stays and the
PVE disk check judges it. The record is marked AgentSMARTSplit, so the
poll's evidence retention restores nothing into it, and the
Proxmox-query-failure fallback gives it no stand-in row.

The split applies on the first full disk poll that collects the node from
Proxmox and matches the agent's row; the unified-resources contract names
that lag and the other limits.
2026-10-10 10:39:59 +01:00
rcourtman 81013369e9 Stop monitoring linking a node to an agent the operator split off (#2786)
## Problem

#2741 made an operator's split of a Proxmox node and its pulse-agent
(report-merge / unlink in the resources API) split the registry's rows.
The monitor's own link (`Node.LinkedAgentID`, `Host.LinkedNodeID`)
stayed in place, so everything that reads it kept treating the two
machines as one:

- the physical disk poll used the agent's SMART inventory as the node's
disk fallback, and applied the agent's `--disk-exclude` patterns to the
node's disks
- `linkedHostForNode` fed the node's disk sources from the agent
- guests inherited the agent for discovery and commands
- deployment reported the node as already running an agent
- service discovery routed through the agent
- the agent's container filesystem readings were applied to the node's
containers
- the node's temperatures came from the agent, through the link or the
exact-hostname fallback
- `sharedSystemAlertCorrelationForHost` tied the node and the agent
together

## Decision

An operator split should stop the monitor treating the pair as linked.
Two stores were recording one fact: the resource store's exclusions and
the monitor's link. The resource store's exclusion is now the one
record, and the monitor follows it:

- `MonitorAdapter.ProxmoxNodeAgentSplit` implements a new
`models.NodeAgentSplitDecider`. It applies the registry's own rule for a
node's link (`splitProxmoxNodeLink`), also under the ID the registry
holds the agent under, and confirms a split it finds against the store's
latest decisions, so a relink takes effect on the next poll.
- `models.State` holds both sides of a split link back on every link
write: the node poll (for the polled nodes and for other connections'
retained nodes, before their links can steer where views fold), the
reconcile after manual intent, `UpsertHost` and `LinkNodeToHostAgent`.
Report ingest drops a split link before it applies it, uses the link the
state kept, and clears the agent's cached container readings. Those
readings are also not shown for a node that no longer names the agent.
The temperature read skips a split agent in its exact-hostname fallback.
- Reverse case: the agents API's node link (`/api/agents/agent/link`) is
the operator's newer "same machine" decision. It now removes that pair's
split from the store (`TakeExclusion`, which returns the rows it
actually removed) before it records manual intent, and puts them back
with `RestoreExclusion` if the intent fails to persist. A restore never
replaces a decision the operator recorded in between.
- A split recorded after a manual link ends that intent on the agent's
next report that finds the split confirmed in the store, so host
continuity stops reserving the node for that agent. An unreadable store
confirms nothing and keeps the intent.
- The agents API's unlink needs no store write: it clears monitoring's
link, and the registry only joins a node and an agent through a link one
of them declares or one inferred from another declared link.

Limits (recorded in the contracts): a rebuild that cannot read the
store's decisions loads none, in the registry as in monitoring, which
needs a change in `registry.go` and `monitor_adapter.go` and is left for
a follow-up; a carried temperature reading from an agent that was tied
to its node by address stays until the ordinary carry window ends; mock
mode's fixture graph keeps its own links.

## Tests

- `TestMonitorStateHoldsBackAnOperatorSplitNodeAgentLink` runs #2741's
six split requests over four link shapes, through a `models.State`
holding the adapter's decisions. Each step runs three poll-and-rebuild
cycles and checks every view (monitor listing, broadcast coalesce,
resources API from listing and from snapshot), the state's links, that a
report alone keeps a split pair apart, a relink, an agent-first rebuild
that persists its pins, and the request again.
- `TestMonitorStateHoldsBackSplitLinksItStartsWith`,
`TestMonitorStateKeepsASplitWhenTheAgentStopsReportingItsMachineID` and
the two `internal/models` tests (a split agent can no longer fold two
connections' node views, for the polled and for the retained node).
- `TestOperatorSplitStopsMonitoringTreatingNodeAndAgentAsLinked` follows
report-merge through the real readers listed above, with a negative
control (an unrelated exclusion keeps every one of them linked).
`TestSplitAgentSensorsDoNotReachTheNodeThroughItsName`,
`TestAgentContainerReadingsAreNotShownForANodeThatNoLongerNamesTheAgent`
and `TestReportUsesTheLinkTheStateKeptWhenASplitLandsMidReport` cover
the temperature fallback, the cached readings and a split landing
mid-report.
- `TestManualNodeLinkReplacesAnOperatorSplit`,
`TestManualNodeLinkKeepsTheSplitWhenItsIntentCannotPersist`,
`TestManualNodeLinkSurvivesAnUnreadableSplitStore` and
`TestConfirmedSplitOfAManualLinkClearsTheAgentsContainerReadings` cover
the agents API link before and after a split, a failing intent, an
unreadable store and the intent ending.
- Store tests for `TakeExclusion` / `RestoreExclusion` in SQLite and
memory, including a removal that fails part way, an operator replacing
the pair between listing and removal, rows stored in the reversed order,
and a stale older link beside the exclusion.
- Mutation controls: I disabled each of the 26 rules above in turn
(state and proposal hold-back, retained-node hold-back, the `UpsertHost`
checks, the report-ingest checks, ending and keeping manual intent, the
lift, the restore, the store confirm, the held agent ID, the temperature
filter, the cached-reading checks, and so on). Each made at least one of
these tests fail.
- The full `models`, `unifiedresources`, `monitoring`, `config`,
`alerts`, `servicediscovery` and `resourceapi` suites pass on pulse-dev;
`TestGuestMetadataWriterFailuresAreOwnedWithoutSpinRetry`,
`TestDeliveryEpisodeCoalescingSurvivesRestart` and
`TestTrueNASPollerUnresponsiveRPCDoesNotFreezeOtherConnections` failed
once under load average ~80 in packages this change does not touch, and
each passes alone.
2026-10-10 10:27:33 +01:00
rcourtman 7d02f9f04e Stop monitoring linking a node to an agent the operator split off
An operator's split of a Proxmox node and its pulse-agent (report-merge
or unlink) split the registry's rows in #2741, but the monitor's own
link (Node.LinkedAgentID, Host.LinkedNodeID) stayed. Everything that
reads that link kept treating two machines as one: the agent's SMART
inventory as the node's disk fallback, its --disk-exclude patterns on
the node's disks, guest discovery through the agent, deployment
reporting the node as already running an agent, service discovery, the
agent's container filesystem readings, the node's temperatures and the
node/agent alert correlation.

The resource store's exclusion is now the one record of the split.
MonitorAdapter decides it with the registry's own rule for a node's link
and confirms a split against the store's latest decisions, and
models.State holds both sides of a split link back on every link write,
before a link can steer where node views fold. The agents API's node
link is the operator's newer decision, so it removes the pair's split
from the store (TakeExclusion) before recording manual intent, and
puts it back, unless the operator decided again meanwhile, if the
intent fails to persist (RestoreExclusion). A split recorded after a
manual link ends that intent on the agent's next report that finds the
split confirmed, so the node is no longer reserved for that agent.

A rebuild that cannot read the store's decisions still loads none, in
the registry as in monitoring; that needs a change in registry.go and
monitor_adapter.go and is left for a follow-up.
2026-10-10 10:00:09 +01:00
rcourtman 395e9016b2 Merge origin/main into fix/operator-lock-survives-identity-change 2026-10-10 09:28:22 +01:00
rcourtman d70c60e5c4 Settle succession lock groups in application order
The sharing groups a succession batch saves were mapped through the
batch's re-keys with one map, ignoring the order they applied in. A
batch declared against its direction collapsed its groups to singletons
and left the surviving ID unlocked, and a batch that returned an ID to
its start gave a row to the ID it had just left.

A saved group now follows only the re-keys applied after it, in order.
The contract also says unlinking frees a member to be edited on its own
only once no other link still joins it to the resource.
2026-10-10 09:22:37 +01:00
rcourtman b557813677 Keep a remediation lock across links and ID successions
An operator's "never auto-remediate" or retired lock lives in the
resource_operator_state row of one canonical ID, and the planner, the
action broker and the dispatch gate read the row of the ID a resource
currently has. Two identity changes left that row behind:

- A manual link folds one resource into another and only the surviving
  ID is ever acted on, so a lock on the folded ID was never consulted
  again.
- ApplyCanonicalIDSuccessions re-keyed operator state with UPDATE OR
  IGNORE, so a successor row that already existed, even an unlocked
  one, won and the predecessor's locked row was left behind.

Pulse could therefore act on a resource the operator had told it never
to touch. The members of a live link component now share the block:
AddLink writes it to every member in the link's own transaction (the
store cannot tell which side the registry keeps), AddExclusion does the
same just before an unlink splits the pair, and a succession carries the
predecessor's block onto an existing successor row and across the links
of the IDs it joined. A one-shot upgrade carry restores the locks that
earlier links and already-recorded successions dropped, then records
itself in a new resource_store_migrations table so a lock the operator
lifts afterwards is not put back on every start.

Only the block crosses, as NeverAutoRemediate. Retired also silences
alerts, which is monitoring posture rather than a safety property, so a
retired donor does not retire the survivor. Every other setting the
survivor holds is left alone, and a succession into an ID with no row
still moves the predecessor's whole row.
2026-10-10 09:08:02 +01:00
rcourtman 70ed7488a4 Undo only the links that hold a reported source in report-merge
A source-filtered report-merge chose the links to undo by the sources
each fold's folded side took in, which spans that side's whole subtree.
Naming the source of a leaf in a chain (a Docker host linked into an
agent linked into a VM) also split the leaf's holder from the VM, and
where recorded links form a cycle a reported member stayed joined
through the link whose fold did not name it.

Folds now record what each side is on its own (HolderOwn, FoldedOwn),
read before the other side's sources are merged in, and
ReportedManualLinkFolds undoes the links that join a member carrying a
reported source to the part that stays or to another reported member.
A member hanging below a reported one goes with it and its link stays.
A report naming every source or none still undoes every link, so the
drawer's Split is unchanged; only API callers with a real sources
filter see a difference.
2026-10-10 08:10:56 +01:00
pulse-triage[bot] a721b3a8e0 Preserve guest memory evidence across AI queries and local observers
Carry selected memory source, state and original time through existing queries, Assistant context and condensed facts. Missing usage remains unknown, measured zero stays numeric, and resource refreshes cannot renew a local observer's retained memory sample. Historical numeric percentages retain explicit source limitations. Shared qualification keeps the existing Patrol, collector, threshold and guest-command contracts intact.

Change-source: pulse-maintainer
2026-10-10 00:46:16 +01:00
pulse-triage[bot] a5baa7563b Keep Patrol guest memory evidence distinct from pressure
Carry selected memory source, original observation and absence through full and condensed seeds, deterministic flags, forecasts, anomalies and recovery verification. Cache-inclusive hypervisor estimates and retained readings cannot prove guest pressure or recovery. Keep measured zero, real pressure and unrelated platform readings, and use native guest runtime state for eligibility.

Add connected parent-comparable controls for issue #2762 and preserve existing collection, safety admission, thresholds and History. This is source interpretation repair, not a claim of installed cause or recovery.

Change-source: pulse-maintainer
2026-10-10 00:02:05 +01:00
pulse-triage[bot] 3407978164 Require a current useful fallback read before claiming QGA availability
A recent metadata cache remains valid eligibility evidence, not a new successful query. Exercise metadata-only and measured-zero filesystem recovery without renewing cached identity, and hold the independent adapter construction clock fixed for the public-wire comparison.

Change-source: pulse-maintainer
2026-10-09 16:42:57 +01:00
pulse-triage[bot] eb2424037d Keep QGA fallback eligibility bound to its original observation
Retained guest identity can survive a cancelled detail cycle or failed metadata refresh. Keep its original eligibility time through canonical snapshots instead of borrowing each VM poll timestamp, and preserve ordinary current-read recovery and the existing guest-command safety fences.

Change-source: pulse-maintainer
2026-10-09 16:36:19 +01:00
pulse-triage[bot] 1070667a67 Bound QEMU disk fallback to its original filesystem observation
Retain recent filesystem-only evidence across safe deferrals without renewing its ten-minute age from VM polls or cached identity. Carry internal source provenance through canonical replacement, preserve independent linked-agent precedence and keep expired values unavailable until a new ordinary read succeeds.

Change-source: pulse-maintainer
2026-10-09 15:33:19 +01:00
pulse-triage[bot] 68f80d6c0f Keep display address preference separate from MAC identity metadata
Retain the legacy MAC ordering while applying the Podman hint only to displayed IPs, with a discriminating identity control. Use the LXC interface API's actual address wire type in the DHCP fallback fixture.

Change-source: pulse-maintainer
2026-10-09 13:10:15 +01:00
pulse-triage[bot] 5c3d18e248 Keep guest primary addresses associated with their interfaces
Prefer named guest interfaces over local container networks instead of globally sorting flattened IP text. Preserve all secondary and unknown addresses, management interfaces, warm metadata caches, explicit agent report IPs and machine identity. Cover LXC status/config/runtime fallback, QEMU metadata and canonical list/broadcast projection for issue #2757.

Change-source: pulse-maintainer
2026-10-09 13:07:08 +01:00
pulse-triage[bot] 65bf9e8260 Compose retained Core qualification candidate with current main
Preserve the exact prior Core history alongside current canonical Patrol resource-owner resolution and recovery cleanup. Runtime source retains the TrueNAS alert-policy repair and sequential repeated-name Docker assertions; current bytes require isolated qualification.

Change-source: pulse-maintainer
2026-10-09 08:10:32 +01:00
pulse-triage[bot] fcfeba39e0 Integrate batch-start upstream main
Merge exact upstream 7f466fc183 without re-parenting its reviewed history. Retain both independent contract sections in the two documentation conflicts; no frontend resolution or runtime correction.

Change-source: pulse-maintainer
2026-10-09 08:04:32 +01:00
rcourtman 19d016d7df fix(patrol): resolve disk alerts through canonical resource owners
Host filesystem alerts identify a disk child that Patrol does not collect as
a separate resource. Bind an exact collected alert to its current canonical
owner through the shared inventory contract, including linked agents, and
retain its filesystem, threshold and breach evidence in the scoped prompt.

Keep missing owners, ambiguous source identities and uncollected child
subjects fail-closed. Add evaluator-to-handler, repeated scope resolution
and browser admission coverage.
2026-10-08 17:22:29 +01:00
pulse-triage[bot] ad972dd629 Compose preserved Core runtime repairs with current main
Preserve d525ef6583 and reviewed 7f026a40 ancestry. Keep current main node-pair source attribution and holder-bound folds, add the preserved API seed and snapshot-threshold lifecycle controls, and retain upstream disk-exclusion semantics. No frontend source changed.

Change-source: pulse-maintainer
2026-10-08 11:26:30 +01:00
pulse-triage[bot] 39615aa987 Merge commit '7606b72a8b85e70696e8bfa52a0792fbb146a34d'
Change-source: pulse-maintainer
2026-10-08 11:03:49 +01:00
pulse-triage[bot] 31c7bf598c Compose reviewed operator link and split repair with current source
Preserve 7f026a40ce ancestry and upstream physical-disk exclusions. Retain mock restart/unlink/relink controls and clarify that physical disks now honour their own splits after #2748.

Change-source: pulse-maintainer
2026-10-08 11:01:37 +01:00
rcourtman 7606b72a8b Let an operator split override a Proxmox node's agent link (#2741)
## Problem

The resource drawer's **Split merged resource** (`POST
/api/resources/{id}/report-merge`), and `POST
/api/resources/{id}/unlink`, on a Proxmox node merged with its
pulse-agent recorded exclusions and answered "Resource split applied",
but the pair stayed merged on every rebuild, in the monitor and in the
resources API.

`ResourceRegistry.ingestRecord` joined the pair through the node↔agent
link in `resolveLinkedResource` (both sides declared, or a node-side
link the agent's hostname corroborates) before `findMatch`, and that
path never read `rr.exclusions`. The linked node had also already taken
the agent's identity in `resourceFromProxmoxNode`, so it minted the
agent's machine-derived canonical ID before the agent was ingested.

## Decision

The operator's split wins. The node↔agent link is Pulse's own inference
from endpoint addresses and hostnames, the evidence cloned machines and
reused short hostnames defeat, and report-merge exists precisely for
wrong merges. A later `POST /api/resources/{id}/link` between the split
rows joins them again.

## Change

- `nodeAgentSplit` (`proxmox_node_links.go`) decides the pair from the
operator's decisions read across the IDs each side can hold: the store
records them against the merged ID and the `SourceTargets` candidates,
while a split node holds its own ID. The latest decision across those
sets wins (registries now keep each exclusion's `CreatedAt`). An
exclusion naming only the agent's candidate against the agent's
machine-derived ID does not count, because report-merge records that
same pair when it splits another source (a Docker host) off the agent.
- Every path that joined the pair applies it: the snapshot ingest no
longer lends a split node the agent's identity (`splitProxmoxNodeLink`);
identity-pin completion refuses the agent's machine key from a
joined-era pin; `resolveLinkedResource` refuses the declared and the
one-sided link; `applyManualLinks` skips an older link between split
rows; and the presentation filter keeps split rows apart when no single
exclusion names their IDs.
- A split node names the agent only where the monitor's node record
does, so guest discovery, deploy and service discovery are not handed
the rejected agent.
- A relink of the split rows is recorded as a folded ID, so pin
succession keeps it instead of re-keying it onto the joined ID.

Builds on #2725 (now merged). This PR's own changes are 4a97d2225f, plus
two repairs after `main` moved: 5b5026fafd records a relinked row as a
`ManualLinkFold` (#2721 replaced `linkMergedIDs`), and e4519ec5bc passes
thresholds to `CoalesceForPresentation` (#2745). The rest of the branch
is merges of `main`.

## Verification

- `TestOperatorSplitOverridesProxmoxNodeAgentLink`
(`registry_merge_policy_test.go`): seven link shapes (declared both
ways, node-side only, agent-side only, unclustered and clustered, an
agent hostname in another form, an agent reporting no machine key) × six
requests (report-merge, unlink naming the node's candidate, unlink
naming the split rows, an exclusion between the two candidates, an
exclusion naming only the agent's candidate, an unrelated exclusion).
Each step runs three rebuilds of a SQLite-backed `MonitorAdapter`,
checked in the monitor listing, the broadcast coalesce, and the
resources API's registry seeded from the listing and from the snapshot.
The test also covers agent-first ingests with and without the host, a
relink (plus an agent-first rebuild that persists its pins), and the
same request again. The unrelated exclusion and the agent-only exclusion
under a machine-derived ID are the negative controls: those pairs stay
merged.
- `TestOperatorSplitFromOneAgentKeepsAnotherAgentsPin`: a node split
from a second agent still completes its own linked agent's boot-window
pin. `TestOperatorSplitHoldsWhenTheAgentStopsReportingItsMachineID`: a
split holds when the agent comes back without its machine ID.
- 15 mutation controls, one per rule. Each makes these tests fail.
- Full `./internal/unifiedresources/...`,
`./internal/api/resourceapi/...` and `./internal/monitoring/...` pass,
as does `./internal/api/` filtered to resource, link, unlink, merge and
broadcast tests. The targeted tests also pass on the pushed head, which
includes current `main` (#2719).

## Not covered (contract "What the split does not reach")

- The browser's realtime coalesce (`mergeCanonicalResourceSnapshot`)
receives no exclusions and still joins a Proxmox-only row and an
agent-only row that share a hostname. A split pair, from this change or
from an identity-match split, therefore still renders as one row in the
UI.
- The monitor's own node record keeps its link. The SMART fallback,
disk-exclude patterns, guest discovery, deploy and service discovery
still follow the agent.
- `resolveLinkedPhysicalDisk` still joins disks inside one host without
reading exclusions.
- The agents API's node link does not replace a resource split.
- Exclusions keyed by cluster- or hostname-derived IDs carry no source.
2026-10-08 10:59:13 +01:00
pulse-triage[bot] 8ae2a7e08e Merge commit 'bdc7ec2d3c3febc0e5fa1a6ccccebe30401c88b4'
Change-source: pulse-maintainer
2026-10-08 10:48:51 +01:00
rcourtman bdc7ec2d3c Honour operator splits in the linked physical disk join (#2748)
## Problem

`resolveLinkedPhysicalDisk` joins an agent SMART disk and the Proxmox
disk under one linked host by hardware identity or, for SAS and
identity-less disks, by device path. `ingestRecord` merges into that
match before `findMatch`, and nothing on that path read the operator's
exclusions. So `POST /api/resources/{id}/report-merge` on such a disk
(the drawer's **Split merged resource**) recorded its exclusions and
answered 200, while the disks merged again on every monitor rebuild and
in the resources API.

Reproduced on main before the change for a SAS disk (Proxmox serial =
transport address), matching NVMe serials and an identity-less USB disk:
after report-merge's exclusions, three rebuilds of a SQLite-backed
monitor adapter, the REST registry seeded from its listing and the REST
registry built from the snapshot all kept one merged disk.

## Change

- `resolveLinkedResource` refuses the linked-disk match when an
exclusion separates the observation's candidate ID from the disk it
matched. The match itself is computed as before, so a refused candidate
still counts toward the path join's ambiguity rule.
- `physicalDiskSplitLocked` decides that one way for the linked join,
`findMatch` and `physicalDiskIDForMachineLocked`: the exclusion may name
the existing disk's current ID, or the unscoped hardware-keyed or
machine-scoped ID either disk's identity mints. So a split also holds
across the re-key a same-serial disk on another machine causes, and
while the agent reports its disk without a serial (smartctl skips a disk
in standby). `findMatch` used to read only the current ID.
- Keying: the agent's disk keeps the merged ID (agent disks are ingested
first); the Proxmox disk takes the ID it holds alone, or its
source-specific ID when both share one hardware identity, as
`findMatch`'s excluded branch already did. An exclusion naming only the
agent's candidate splits nothing, since the agent's row holds the merged
ID.

## Not covered (documented in the unified-resources contract)

- Monitoring's `mergeHostAgentSMARTIntoDisks` still copies the linked
agent's SMART data into the Proxmox disk record before the registry sees
it, so a split Proxmox row keeps those values.
- Proxmox keys a disk by its slot, so a split disk that Proxmox reports
under another device path joins again until split again.
- Two split rows sharing a serial read one serial-keyed history, and a
PVE disk alert row matching both stays under its PVE reference (the
owner never breaks a tie by path).
- A split recorded while the agent's disk reported no serial, or against
an agent disk whose identity Proxmox does not carry, stops applying when
that changes, as every exclusion keyed by a canonical ID does.
- A report-merge filtered to the agent source leaves the disks merged;
the drawer sends every merged source.

## Verification

- `TestOperatorSplitSeparatesDisksJoinedInsideALinkedHost`: four disk
shapes × four requests (report-merge, Proxmox candidate only, agent
candidate only, unrelated), three rebuilds each across the monitor
listing and both REST builds, then relink, a second split, the agent's
disk in standby and waking, a same-serial disk appearing on another
machine, and a split recorded while it was present that holds after it
leaves.
- Negative controls, each failing the test: drop the linked-join check;
drop the `findMatch` arm; drop the unscoped-ID arm; drop the scoped-ID
arm; drop the incoming disk's IDs (standby step).
- Full `internal/unifiedresources` and `internal/api/resourceapi` suites
pass on the rebased tree.
2026-10-08 10:39:15 +01:00
pulse-triage[bot] 7f026a40ce Reconcile operator links and splits with generation freshness
Retain PR2732 cccb43ecdf and PR2741 e2f2a6138a behaviours against current monitor-owned freshness and holder-bound fold bookkeeping. Preserve mock restart/unlink/relink, seven-shape split controls and canonical fold provenance.

Recreate the exact final source tree of rejected candidate d73e56086f4da1e0325c1e7311cdea1625f62a5d over the recorded assignment base. Its imported proposal commits lacked maintainer committer provenance. Original proposal history remains unchanged and retained as evidence; no tests or source-proof receipts are rebound to this new commit.

Change-source: pulse-maintainer
2026-10-08 10:22:34 +01:00
rcourtman 8464939716 Merge remote-tracking branch 'origin/main' into fix/declared-node-link-honours-split 2026-10-08 10:14:07 +01:00
pulse-triage[bot] 32f63980fb Preserve reviewed monitoring repairs with published mock-view manual links
Additively incorporate current upstream 0d79f1cdda without rewriting the reviewed fe246963e0 history.

Change-source: pulse-maintainer
2026-10-08 10:12:53 +01:00