Commit Graph

6895 Commits

Author SHA1 Message Date
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 02e4ce2bcc Require page-owned state on WorkloadsSurface (#2802)
`WorkloadsSurface` built its own workloads state when no `state` prop
arrived, and its props type extended every `useWorkloadsState` option.
Both production mounts (the Proxmox and vSphere overviews) already build
that state themselves, since it also drives their one `WorkloadsFilter`,
and pass it in. So the fallback only ran in tests, and the mounts passed
`vms`, `containers`, `nodes`, `useWorkloads` (and on Proxmox the
app-container exclusion) that the surface ignored once it had a state.

### What changed

- The surface requires `state` and accepts only `forcedPlatform`,
`forcedViewMode`, the empty-state copy and `tableTitle`. The hook's
options type is now `WorkloadsStateOptions`, so it no longer shares a
name with the surface's props.
- Both pages drop the ignored mount props. The hook-call options
(`excludedWorkloadTypes`, `showNestedExcludedWorkloads`, ...) stay where
they act.
- `WorkloadsSurface.k8s` and `.performance.contract` tests build state
through a `PageOwnedWorkloadsSurface` harness, as the pages do.
Stub-state renders just lose the dead props.
- New pins: the Proxmox page contract test asserts the exact props its
mount passes; the overview guardrails check that each surface repeats
the platform and view scope its page gave the state, and that the
surface imports only the state's type (AST, so formatting and aliasing
cannot get past it).
- `ProxmoxBackupsTable.test.tsx` loses its two pins on the removed JSX
attributes; the hook-call pins that protect backup coverage stay.
- Contract prose in performance-and-scalability, storage-recovery,
unified-resources and frontend-primitives.

### Bundle budget

Once the surface imports only the state's type, Rollup names the chunk
the Proxmox and vSphere pages share after its last module (`rollup`
`getFallbackChunkName` takes `orderedModules[last]`), which is now
`useWorkloadsState`, and the bundle check reported the tracked
`WorkloadsSurface` key as `MISSING`. It is the same code at the same
size, so `.bundlesize.json` renames the key and leaves the baseline and
limit alone. I did not pin the name through `manualChunks`: that would
change chunk composition for a naming concern.

### Evidence (pulse-dev, origin/main 9a32396fe2 vs this change)

- Unminified builds with the renamed chunk mapped back: the shared chunk
is identical except three lines (two minifier import-alias lines and
`props.state ?? useWorkloadsState(props)` -> `props.state`);
`Proxmox.js` and `Vmware.js` shrink by exactly the removed JSX props
(183 and 80 bytes); every other differing chunk differs in one import
line of minifier aliases, from a reordered export list of the same 608
names.
- Bundle check passes on both trees: 57.94 kB before, 57.63 kB after,
limit 70.23 kB.
- Browser pass of both overviews at 1440 and 390 wide against the mock
estate: 90 of 90 readings identical, in two repeated pairs (row and
group counts, titles, headers, filtered-empty copy, Back to top, inline
drawer, one toolbar outside the surface). My first probe could not open
the phone drawer on either build (a backup-status button overlaps the
chevron); the second one dispatches the click and covers it.
- `tsc` clean (pulse-dev, and in the pre-push hook on the merged tree).
The commit's pre-commit lint chain passed; the pre-push `eslint` was
cancelled by the local workload gate, so CI's Frontend job is the lint
of record for the merged tree. The pin tests (9 files) pass on both
trees. A full sweep of the Workloads, Proxmox, vSphere, platform-page,
shared, utils, hooks and Storage suites showed only load timeouts at
pulse-dev load ~120; every failing file passes alone on both trees (23
files, 718 tests).
- Negative controls: the new Proxmox test fails against the unchanged
page (extra props), and the new guardrail fails against the unchanged
tree and against the fallback surface restored over this change.
- Second opinion (Sol, read-only) in four rounds; each round's findings
are fixed: a spelling-based ratchet replaced by the AST check,
overclaiming contract sentences narrowed, and the `MISSING` remedy
limited to a confirmed rename.

### Notes

- `useWorkloadsState` itself never reads `vms` or `containers`, and both
pages pass `nodes: []` and `useWorkloads: true`. Main's `retains
separate supplied tuples sharing a native ID` test does pass real
`nodes`, so removing those options needs a deliberate decision; that is
a separate follow-up.
- Observed, not changed here: in the phone-width probe, a tap at the
centre of a Proxmox row's expand chevron was intercepted by the 44px
backup-status button next to it (Playwright reported that button as
intercepting pointer events), on both builds.
2026-10-10 14:08:56 +01:00
rcourtman 93aafe9e1e Drop the unread postures response meta from the frontend (#2801)
The Proxmox Backups tab reads the rows and the policy of `GET
/api/recovery/postures` and nothing else, but the frontend response type
still declared the endpoint's pagination `meta`, and
`useProtectionPostures` wrote it twice: an empty one in the initial
value, and one it invented after joining its 200-ID batches (`page: 1`,
the batch size as `limit`, the row count as `total`, the request count
as `totalPages`). This removes it, and `RecoveryResponseMeta` with it,
the last response type to carry it now that #2787 dropped the points
response's. The backend and the wire shape are unchanged.

## What changed

- `types/recovery.ts`: `meta` leaves `ProtectionPosturesResponse`;
`RecoveryResponseMeta` is deleted.
- `useProtectionPostures.ts`: the initial value and the joined response
are `data` and `policy` only.
- `ProxmoxBackupsTable.test.tsx`: the four postures fixtures stop
sending `meta` (the `meta` blocks of the unrelated `/api/backups/*`
payloads stay).
- `useProtectionPostures.test.ts`: a type-level pin that the response is
`data | policy`, and a runtime test that joins a 201-ID fleet into two
requests and checks the batch sizes, the rows (in the backend's order,
which the mock deliberately makes non-sorted), the policy and the joined
response's keys. Nothing covered the join or the policy flow before:
dropping `policy` from the joined response passed every existing test.
- `storage-recovery.md`: one paragraph next to the points-decode rule
from #2787.

`frontend-modern/browser-tests/composed-backups.cjs` still has a `meta`
in a route mock of the postures response. Nothing references that script
and the mock mirrors the unchanged wire, so I left it.

## Evidence

- **No readers.** A whole-program scan with the TypeScript checker over
`src` and its tests (object-literal keys resolved through the contextual
type, destructuring, element access, indexed-access types, whole-object
uses) found zero reads of `ProtectionPosturesResponse.meta` and of the
four `RecoveryResponseMeta` members, only the two writers. Controls:
`data` and `policy` show their reads, and a planted file with eight
reader forms (property access, destructuring, chained access,
`['meta']`, a `RecoveryResponseMeta` member read, spread, `Object.keys`,
`T['meta']`) was detected in every form. The whole-object uses are the
declaration and `initialValue`, and `createNonSuspendingQuery` never
inspects the value's shape. Nothing in `tests/integration` reads it
either. After the change the scan finds no declaration to report and
`tsc` is clean.
- **`limit` and `totalPages`.** Nothing reads them; the URL's
`limit=200` comes from `MAX_PROTECTION_POSTURE_BATCH_SIZE`, which stays.
- **Controls on the new tests.** Re-adding `meta` (optional or required)
to the type fails `tsc` on the pin, and with the pin deleted nothing
else fails, so the pin is the guard. Each of these single flaws in the
hook fails the new runtime test: dropping the rows, dropping the policy,
keeping only the first batch, joining batches in reverse, re-sorting the
rows, and spreading a batch's wire `meta` back into the joined response
(type-clean, which is why the test also asserts the joined keys at
runtime). The dropped rows also fail six table tests.
- **Tests.** tsc, eslint, prettier and the canonical platform audit are
clean; 33 files and 706 tests pass over the hook, the Proxmox features,
the recovery transport and the platform-table guardrails. A wide run
(`features`, `hooks`, `utils`, `api`, `types`, `__tests__`,
`components/shared`, `Workloads`, `Storage`) on the #2787 head plus this
change passes 946 of 948 files; the failing file is
`agentInstallCommand.test.ts`, whose pty helper raises a `termios` ioctl
error on this Mac and which imports nothing this change touches.
- **Browser, before/after.** Production builds of this commit's parent
(`4471dcaaba`) and of this change, each behind `vite preview` and a
private mock backend (sized so the tab's postures request really splits
into two batches, 200 and 18 IDs), driven by one headless Chromium
script at 1440x900 and 390x844, two runs per build interleaved. Both
views (`/proxmox/backups/date` and `/coverage`) give the same 221
targets, the same 109/61/38/10/0/3 posture counts and footnote, the same
144 rows (40 at phone width), the same chips and byte-identical
digit-masked page text; the same two postures requests open and poll;
every response body still has `data`, `meta` and `policy`; with the
endpoint stubbed to a 500 both builds show the "Protection posture is
unavailable" notice. 38 measurements, one value each across the four
runs, no failing responses, no console errors.
- **Bundle.** 189 files each side; one differs in code, `Proxmox-*.js`
(276,802 to 276,696 bytes), and only by the removed `meta` statements.
Bundle check passes with `.bundlesize.json` unchanged (Proxmox chunk
77.78 to 77.74 kB gzip, total JS 1365.65 to 1365.57 kB).
2026-10-10 14:00:00 +01:00
rcourtman 3465804dd1 Stop mock-mode callers that read identity rebuilding the estate view (#2798)
Follow-up to #2778. Stacks cleanly on main; the contract prose
cross-references the wiring change in #2796, so land that first if both
are open.

## What was wrong

In mock mode the unified view is keyed on the fixture data version,
which advances on every metric tick, and building it for the default
1,776-resource estate costs about half a second of CPU (many seconds on
a starved process). #2778 moved the metrics-target lookup onto the
fixture structure revision. The other callers that read only what the
estate lists and how it connects still rebuilt the whole view after each
tick: at startup they made 42 view requests, all misses (the
Docker-alert prune 18, the connection snapshots 13, the host-agent
evaluation 8, the chart prewarm 2, the metrics-target lookup 1), and
with no client connected the monitor tick alone rebuilt the estate about
every nine seconds.

## What changes

- Callers that read identity and topology share the view built for the
current structure revision and operator links
(`currentStructureUnifiedStateView`, `GetUnifiedStructureReadState`,
`currentModeStructureReadState`, in
`internal/monitoring/unified_structure_view.go`; `mockMetricsTarget`
uses the same cache reader): the Docker-alert prune, the workload chart
prewarm, the demo Patrol operator-state resolver, and the host-agent
evaluation. `evaluateHostAgents`, which runs on every monitor tick, now
lists nodes only when an offline host names a linked node.
- The connection-degraded alert feed keeps only platform rows
(`snapshotConnectionsForAlerts` drops every agent row, and a PBS row's
reported node name only becomes a host alias that its snapshot does not
carry). It now builds the aggregator inputs with `platformRowsOnly`, so
it no longer reads the hosts, their desired-config fingerprints or the
PBS instances. The connections endpoint, diagnostics and runtime
inventory still read them.
- Deliberately not moved:
- The mock storage chart (`resolveMockStorageTotal`): a linked pool
takes its capacity from whichever source was seen last, which a tick can
move without a structure change. It stays on the data-version view, read
once per request instead of once per uncached pool.
- `linkedHostForNode`, `getHostAgentTemperatureForNode` and
`carriedTemperatureOutlivesAgentLease` (only reachable with
`PULSE_MOCK_KEEP_REAL_POLLING`): they read a host's status, disks,
sensors and report lease, which a tick moves. Hoisting them would need
the read state threaded through the node poller, and they run after
startup, so they stay as they were.

Real mode is unchanged.

## Measured (pulse-dev, probe builds, one pinned core, GOMAXPROCS=1,
host load 30 to 110, single runs)

| | #2796 alone | with this change |
|---|---|---|
| estate view builds before the listener answers | 2 | 1 |
| estate builds, no client connected, 299 s of life | 33 | 2 (both at
startup) |
| process CPU over a 180 s steady window | 12.3 s | 3.3 s |

A separate run of the pre-#2796 build used 9.0 s over that window, so
read 12.3 against 3.3 as the comparison rather than as exact
percentages. The public demo host runs a mock estate, so the
steady-state saving is the one that matters there.

## Verification

- New tests (in registered proof files): no consumer of the structure
view rebuilds the view after a fixture tick (Docker prune, structure
node listing, workload prewarm, host-agent evaluation including an
offline host that names a linked node); the data-version view still
rebuilds; the structure view follows a structural fixture change and an
operator link; lazy correlation lists nodes only for a linked host; a
storage chart reads the estate only when it is not cached; the alert
feed does not rebuild the view, its platform rows equal the full
aggregator's, and configured PVE/PBS/PMG rows survive without the hosts;
the Patrol resolver does not rebuild the view.
- Negative controls (each fails the named test): prune, prewarm, eager
node listing, structure node listing, alert feed and Patrol resolver
each moved back to the data-version view, and a storage chart that reads
the estate before checking its cache.
- `go vet` and the targeted tests on pulse-dev.

## Known limits

- The structure view can be several ticks old, so a caller on it must
not read status, metric values, timestamps, sensors or anything a link
merges by freshness; the contract says so. The fixture sets pod node
names (which feed the platform aliases a Patrol reference can resolve
through) when the estate is built, not on a tick.
- `pbsInstanceFromReadStateView` never copies `NodeName`, so
`pbsReportedNodeNamesByInstance` appears to feed empty host aliases on
the connections endpoint. That is a separate bug, not changed here.
- The batch sharing of the storage chart's read (`sync.OnceValue` in
`GetStorageMetricsForChartBatch`) is not pinned by a test; the
single-chart laziness is.

Not a feature: no demand-ledger entry.
2026-10-10 13:44:54 +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 b619b5e2e4 Hide section tabs and lift table preview caps in kiosk (#2797)
Follow-up to #2779. Kiosk (`?kiosk=1`) is the read-only wall display
from #917, and with the filter bars gone two pieces of chrome still
assumed someone at the keyboard.

## What changes

- `PlatformSectionTabs` stays unmounted in kiosk, on every platform page
and on the Proxmox Backups `By date` / `Coverage` rail. The maintainer's
replies on #917 and #1102 say kiosk hides section navigation, and the v5
Proxmox overview did too. The rail mounts fresh when kiosk ends, so its
phone scroll controls bind to the new element.
- `createPlatformTablePreview` shows every row in kiosk and renders no
Show all button. The expansion state is kept, so leaving kiosk restores
the preview as it was and an open node drawer is not stranded.
- The contract records both rules next to the FilterBar kiosk paragraph,
why the drawer search fields and the `Guests on <node>` reset stay, and
corrects the stale preview row counts (twelve and six, not eight and
four).

## Why

Measured on the 50-node mock estate with only #2779 applied (headless
Chromium at 1280x400, the rack LCD from #917, plus 1440x900 and a
phone):

- the section rail still cost the 400px display 53px of height (106px on
Backups, which carries two rails);
- the 12-row cap kept 38 nodes off the display, including the offline
node ranked 23rd. The primary navigation and its Alerts count are hidden
in kiosk, so the table rows are where a node going down shows.

## Decisions on the other kiosk gaps

- Drawer search fields: kept. They exist only inside a drawer someone
opened with a click, and type-to-search registers only while it is open.
A stray key on an overview reaches no field (checked live), so
`useTypeToSearch` is unchanged.
- The `Guests on <node>` heading with its Show all nodes reset, and the
Backups and Alert History chart selections: kept. A click, or a URL
someone chose, can narrow the page, and these are the way back from it.
- vSphere VM table title: deferred, see below.

## Not in this PR

The vSphere overview's VM table has no title (Docker and Proxmox title
theirs), so in kiosk it follows the Hosts table with no heading.
`VmwarePageSurface.tsx` is held by an in-flight WorkloadsSurface
refactor, so it is left alone here.

## Verification

- Browser: 16 route and mode pairs at 1440x900, 1280x400 and 390x844,
live kiosk toggling by button and Escape, an expansion with a node
drawer opened past the cap surviving a kiosk round trip, the Kubernetes
drawer search fields, and the node-scoped heading reset. The receipt is
in the diff.
- Tests: new `sharedPlatformPage.kiosk.test.tsx` (all four original
cases fail on main, and two mutations of the helper fail only the
intended tests), guardrail pins in
`SharedPrimitives.guardrails.test.ts`, and the platform feature suites.
2026-10-10 13:11:55 +01:00
rcourtman 2ac9142d85 Merge remote-tracking branch 'origin/main' into refactor/workloads-surface-state 2026-10-10 13:08:20 +01:00
rcourtman 89173fca44 Merge remote-tracking branch 'origin/main' into refactor/postures-meta-drop 2026-10-10 13:08:18 +01:00
rcourtman 7148c4869c Drop the unread postures response meta from the frontend
The Proxmox Backups tab reads the rows and the policy of
GET /api/recovery/postures through useProtectionPostures and nothing
else. The response type still declared the endpoint's pagination meta,
and the hook wrote it twice: an empty one in the initial value, and one
it invented after joining its 200-ID batches (page 1, the batch size as
limit, the row count as total, the request count as totalPages). Nothing
in src or its tests reads any of it, so those fields described a
pagination the client does not have.

Remove the meta from ProtectionPosturesResponse, both writers, the test
fixtures that sent it, and RecoveryResponseMeta, whose last user this was
once the points response dropped its own. The backend and the wire shape
are unchanged. A type-level pin keeps the response to data and policy,
and a new hook test joins a 201-ID fleet into two batches and checks the
rows, their order, the policy and the response keys at runtime: nothing
covered the join or the policy flow before. The storage-recovery contract
records the rule next to the points decode.
2026-10-10 13:07:20 +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 0cebf6f51f Require page-owned state on WorkloadsSurface
WorkloadsSurface built its own workloads state when no `state` prop
arrived, and its props type extended every useWorkloadsState option.
Both production mounts, the Proxmox and vSphere overviews, already
build that state themselves (it also drives their one WorkloadsFilter)
and pass it in, so the fallback only ran in tests. The mounts still
passed vms, containers, nodes and useWorkloads, and Proxmox passed its
app-container exclusion, all of which the surface ignored once it had
a state.

The surface now requires `state` and accepts only presentation props:
forcedPlatform, forcedViewMode, the empty-state copy and tableTitle.
The hook's options type is renamed WorkloadsStateOptions so it no
longer shares a name with the surface's props. The pages drop the
ignored props. Tests that leaned on the fallback build state through a
page-owned harness, as the pages do.

The Proxmox page contract test pins the exact props its mount passes.
The overview guardrails check that each surface repeats the platform
and view scope its page gave the state, and that the surface imports
only the state's type, so it cannot call the hook again.

Because the surface no longer imports the hook at runtime, Rollup now
names the chunk the Proxmox and vSphere pages share after its last
module, useWorkloadsState, and the bundle check reported the old
WorkloadsSurface key as missing. The chunk is the same code at the
same size, so the budget key follows it with its baseline and limit
unchanged.
2026-10-10 13:05:50 +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 8823440f33 Stop mock-mode callers that read identity rebuilding the estate view
In mock mode the unified view is keyed on the fixture data version, which
advances on every metric tick, and building it for the default
1,776-resource estate costs about half a second of CPU (many seconds on a
starved process). #2778 moved the metrics-target lookup onto the fixture
structure revision; the other callers that read only what the estate
lists and how it connects still rebuilt the whole view after each tick.
At startup they made 42 view requests, all misses, and with no client
connected the monitor tick alone rebuilt the estate about every nine
seconds.

Callers that read identity and topology now share the view built for the
current structure revision and operator links (currentStructureUnifiedStateView,
GetUnifiedStructureReadState): the Docker-alert prune, the workload chart
prewarm, the demo Patrol resolver, and the host-agent evaluation, which
also lists nodes only when an offline host names a linked node. The
connection-degraded alert feed keeps only platform rows, so it no longer
reads the hosts, their desired-config fingerprints or the PBS instances
that only feed agent rows and a host alias its snapshot drops.

The mock storage chart is not one of them: a linked pool takes its
capacity from whichever source was seen last, which a tick can move
without a structure change. It keeps the data-version view, read once per
request instead of once per uncached pool. The per-node polling lookups
of a monitor that keeps real polling in mock mode (linkedHostForNode,
getHostAgentTemperatureForNode, carriedTemperatureOutlivesAgentLease)
read a host's status, disks and sensors, which a tick moves, so they stay
on the data-version view.

On pulse-dev (one pinned core, host load 30 to 110, probe builds): a
start built the estate view twice before the listener answered with the
wiring change alone and once with this; with no client connected the
process built the estate 33 times in 299 s and used 12.3 s of CPU over a
180 s window, against twice and 3.3 s with this change.
2026-10-10 12:37:42 +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 199e13e278 Keep an operator-split host pair as two rows in the browser (#2792)
The browser re-joined same-hostname agent and platform rows from the
realtime payload without knowing the operator's merge exclusions, so a
node and agent that "Split merged resource" (report-merge) or unlink had
split on the server still rendered as one row. Every realtime path (the
websocket snapshot, its deltas and `/api/state`) already carries the
server's presentation coalesce, which honours the exclusions and
provider scope, so the browser now merges each row only with the
previous row of its ID. This replaces the "browser's realtime coalesce"
limit sentence that #2741 added to the unified-resources contract.

Showing the split exposed three ways the surviving row kept the departed
source's data, which the Machines page reads as provider ownership (it
answered "No Pulse Agent machines"):

- A row whose own `sources` list lost a source is rebuilt from the
incoming row, keeping the REST-only scopes, source status and readiness
of the sources that remain. A previous row claims every source either of
its two lists names, so a half-updated row is rebuilt as well.
- Source status entries follow the sources the merged row lists.
- The resources hook's full merge handed the websocket store's rows to
the merge, so cache rows shared a nested `sources` array with rows the
store reconciles in place and flipped to the split value while keeping
their joined-era enrichment. It now clones the store's rows on that
path.

Verification:

- Private stack (the server from #2741, a fake Proxmox API, a test
agent): the split is delivered live with no reload and the node stays on
the Proxmox page while the agent appears on Machines with a clean
drawer, at 390 and 1280 wide. On main's frontend the agent was drawn as
a second Proxmox node and Machines showed "No Pulse Agent machines".
Before the second and third fixes the agent was missing from Machines in
2 to 4 of 4 phone-width runs. The browser receipt states which revision
each pass ran on.
- New and changed tests pin a split pair through full snapshots and
deltas in the adapter, the websocket store and the resources hook, and
each rule above has a test that fails without it.
- Reviewed by Sol over seven rounds. The last rounds' remaining findings
concern which REST-only scope and readiness data survive a rebuild when
the realtime payload carries no provenance; the contract states those as
known limits (the next REST refresh restores the data), alongside two
others: a hook's own store is still created from its cache's rows, and
the fast merge path is content-equivalent to the full path only for a
display row whose own source lists agree.

Open PR #2699 edits the same adapter and already conflicts with main.
2026-10-10 12:24:28 +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 c65e412cc1 Merge remote-tracking branch 'origin/main' into fix/browser-coalesce-honours-split 2026-10-10 11:53:07 +01:00
rcourtman 33184012ce Keep an operator-split host pair as two rows in the browser
The browser re-joined same-hostname agent and platform rows from the
realtime payload without knowing the operator's merge exclusions, so a
node and agent that report-merge or unlink had split on the server still
rendered as one row. Every realtime path (the websocket snapshot, its
deltas and /api/state) already carries the server's presentation
coalesce, which honours the exclusions and provider scope, so the
browser now merges each row only with the previous row of its ID.

Showing the split exposed three ways the surviving row kept the departed
source's data, which the Machines page reads as provider ownership and
answers with "No Pulse Agent machines":

- A row whose own sources list lost a source is rebuilt from the
  incoming row, keeping the REST-only scopes, source status and
  readiness of the sources that remain. A previous row claims every
  source either of its two lists names, so a half-updated row is
  rebuilt too.
- Source status entries follow the sources the merged row lists.
- The resources hook's full merge handed the websocket store's rows to
  the merge, so cache rows shared a nested sources array with rows the
  store reconciles in place and flipped to the split value while keeping
  their joined-era enrichment. It clones the store's rows on that path.

On a private stack (server with the split override, a fake Proxmox API
and a test agent) the agent stayed on the Machines page in every final
transition at 390 and 1280 wide; before the last fixes it went missing
in up to half of them. The browser's realtime coalesce limit in the
unified-resources contract is replaced by the new rule and its known
limits.
2026-10-10 11:52:37 +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
pulse-triage[bot] ba91fc6699 Keep guest disk totals, email settings and recovery guidance trustworthy (#2790)
## What changed and why

- Count a guest volume once at its highest accepted usage, independent
of alias mount order, while retaining each mount's readings. Reject
case-variant filesystem identity fields that could overwrite a real
mount/type and hide it from an apparently complete inventory. Extend
both collectors' assertions through serialized totals and mount rows.
- Show only email settings the server persists. Remove unsupported
Reply-to and retry controls, explain server-managed retries, and
preserve supported SMTP/TLS/rate settings and editing while email is
off.
- Explain destructive agent removal, separate action-runner revocation,
retained recovery material and partial failures. Add safe containment
guidance after an accidental public upload: issuer revocation and
attachment removal are separate, deletion cannot guarantee erasure, and
monitoring coverage must be preserved.
- Discover closed pull-request CI in requested, waiting and pending
states as well as queued/running work, retaining exact-PR,
fresh-closure, branch-reuse and refusal checks.

These are repairs to existing source behavior and guidance omissions,
not new features or claims of installed recovery.

## Included reviewed source

Complete unpublished range:
`e4da1b037000e4eb4b6ea7089704993c74708db8..dd46bf916f46c63accdee3aa6b26d68fdb2edf50`.

Candidates:
- `6075dec7f00cfcbbbb59eee23f01f42a90b97bf8`, composition
`3747c98e718afe90e8ff6186e90b88fee9dfc885`: alias usage.
- `cce0cfd5a72b628e3b9219bd26993aaaf42b3f66`: serialized collector
assertions.
- `e7d8092c633a5ed3379ef52d0194b3706d20219e`: canonical identity fields.
- `2526d7dff21f79525e955e019d470c7dbd4f251f`: persisted email settings.
- `e5f27d25f61aa81c84d0409790851c0fa0e132c9`,
`da269aeaf272a048cdbb66afddb0172ac3f54467`: removal guidance and recipe
inventory.
- `6409a38480992a6dacb09df8f4d40515887998e1`: disclosure containment
guidance.
- `50b5345be8f5e9a583d939784ce64118099d3c7c`: closed-PR held-run
cleanup.

All previously reviewed commit identities and ancestry are retained.
Final additive merge `dd46bf916f46c63accdee3aa6b26d68fdb2edf50`
incorporates current published upstream
`e4da1b037000e4eb4b6ea7089704993c74708db8`, including its
scheduler-cadence freshness repair; no accepted commit was rewritten.
Later, separately unqualified notification-draft work is not included.

## Validation and remaining limits

- Final-source provenance, whitespace, per-commit canonical-history
checks and range browser-evidence guard pass. The existing SMTP browser
receipt covers the final four changed runtime files, its original
parent, desktop/phone layouts and supported save/reload behavior; it was
not rebound or repeated.
- Final lightweight fixture checks pass: closed-PR cleanup controls,
twelve removal/containment tests, public documentation (243 Markdown
files) and shipped mirrors (62 documents).
- Retained candidate evidence includes normal/race canonical-metadata
decoder checks (94 identities/six roots), 24 parent failures with
compatibility controls, connected alias/serialized-row collector,
served-state, History and breach/zero-recovery checks, focused
SMTP/browser proof, and 52 held-run cleanup controls with ten parent
failures. These remain candidate evidence, not a new full-source pass.
- The final committed-source frontend command requested offline install,
types, lint, full tests, build and bundle budgets. VM admission returned
`proof-capacity-low`, client exit 2, before execution: no executed stage
or passing receipt. It was not retried through another executor.
- Earlier failed SMTP full-suite/Docker-test evidence and incomplete
wider/build/removal-proof observations remain adverse or unknown. An
older passing frontend run on differing SMTP source does not clear them.
Exact-head hosted frontend, backend, documentation and governance checks
must resolve containing-source acceptance before landing; no check is
waived by this proposal.

No native notification delivery, guest thaw, ordinary installed
polling/recovery, credential revocation, live CI cancellation,
deployment or release readiness is established here.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
2026-10-10 10:36:51 +00:00
rcourtman 4faba67f66 Keep a split PVE disk off the agent's SMART row (#2789)
## What

A merged physical disk that the operator splits ("Split merged
resource", `POST /api/resources/{id}/report-merge`) now stays split for
the PVE disk poller too. #2748 made the registry honour the exclusions
and listed the poller as the part of the split it did not reach:
`mergeHostAgentSMARTIntoDisks` 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 kept 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`.

## How

- `mergeHostAgentSMARTIntoDisks` asks the resource store
(`MonitorAdapter.ProxmoxDiskAgentSMARTSplit`, new
`internal/unifiedresources/physical_disk_agent_split.go`) once per
matched row, on the full poll and on the skipped polls between. 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, in the same forms:
  - an exclusion naming only the agent's candidate splits nothing;
- a replacement disk in the slot inherits no split recorded against the
disk it replaced;
- a manual link that leaves the generation holding both observations as
one disk is the operator's later decision, so the poller pairs them
again.
- A split disk takes nothing from the row: no attributes, I/O, health,
identity, type or temperature, and no `AgentSMARTReported`. It also
takes no agent-supplied node-sensor temperature: the node poll takes the
node's SMART list from the linked agent while the agent is reporting and
its report carries a usable temperature, so `mergeNVMeTempsIntoDisks`
would otherwise hand the agent's row of the other disk to the split one
and the PVE check would alert beside the agent's own alert. An
SSH-supplied reading stays, and the PVE disk check judges it.
- The record is marked `AgentSMARTSplit` (internal poll evidence, never
serialized), so the poll's evidence retention restores nothing into it.
- The Proxmox-query-failure fallback (`physicalDisksFromHostAgentSMART`)
gives a split row no stand-in.

## Limits (named in the unified-resources contract)

- The split applies on the first full disk poll that collects the node
from Proxmox and matches the agent's row; a record already merged keeps
the agent's data until then (five minutes by default).
- A split disk retains no last-known serial or ZFS pool through a poll
that cannot collect them: nothing records which retained value Proxmox
collected and which the agent filled.
- While the Proxmox query fails, a split disk's Proxmox record is not
listed (its stand-in would be the agent's disk again).
- The query-failure fallback judges its stand-in row by the row's own
identity, so it does not apply a split that names identity only the
Proxmox disk carries (an agent disk in standby).
- A split recorded under one key of a controller member (the stand-in's
target-qualified key against a Proxmox poll's device-only key) names an
observation the other key's rows do not carry, as for the registry.

## Verification

- `TestOperatorSplitKeepsAgentSMARTOffTheProxmoxDisk` drives the poller
through four report-merge shapes (both candidates, the Proxmox candidate
alone, the agent's candidate alone, an unrelated exclusion) for three
disks (SAS paired by path, NVMe paired by serial, NVMe whose node
reading is SSH-supplied), on full and skipped polls, with an open PVE
temperature alert that the pairing closes and the split does not bring
back, the health alert the agent's failure raised, and a failing Proxmox
disk query.
- Unit tests pin the merge (including Proxmox's own health and wearout,
fields the agent must not copy, and the own-ID rule for controller
members), the fallback, the node-sensor drop and the source labels it
relies on. `TestOperatorSplitSeparatesDisksJoinedInsideALinkedHost` also
asks the decider at each step (relink, standby, replacement, a
generation that has not ingested the agent's disk).
- Negative controls, one mutation per rule (18), each turned a named
test red; the full `internal/models`, `internal/unifiedresources` and
`internal/monitoring` suites ran on pulse-dev against the merged tree.
`TestGuestWindowsMeminfoPollingContract` failed once in a loaded full
run and also fails on pristine `main` under the same load (it passes
alone on this branch), so it is not part of this change.
2026-10-10 11:17:57 +01:00
rcourtman 6ac7c42d2e Merge remote-tracking branch 'origin/main' into fix/kiosk-section-chrome 2026-10-10 11:15:00 +01:00
pulse-triage[bot] dd46bf916f Incorporate current polling freshness repair without rewriting reviewed work
Preserve the complete accepted local maintenance range and add the current published scheduler-cadence repair. Final containing validation and exact-head protected checks remain required.

Change-source: pulse-maintainer
2026-10-10 11:06:18 +01:00
pulse-triage[bot] ff56e7583d Incorporate batch-start published upstream
Preserve locally reviewed commits and import the upstream frontier observed for this bounded coordination turn. The unqualified alert-draft candidate remains outside canonical source.

Change-source: pulse-maintainer
2026-10-10 10:54:15 +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 dc93d0adea Judge adaptive-polling freshness by the scheduler's cadence
When adaptive polling is enabled (ADAPTIVE_POLLING_ENABLED or system.json;
off by default, no UI), the scheduler picks PVE, PBS and PMG intervals
itself and never reads the per-platform ones. A healthy instance's
staleness score is near zero after every success, so its cadence stretches
toward AdaptivePollingMaxInterval (5 minutes by default). Resource
freshness was still derived from the per-platform intervals, with floors of
60s for Proxmox and 120s for PBS and PMG, so healthy rows read stale for
much of every cycle. A live stack with a fake PMG polled 3.5 to 4.3
minutes apart and the row sat degraded with telemetry_stale from two
minutes after each poll until the next one. The PVE node offline grace and
the node temperature carry window derive from the same cadence and lapsed
between polls too.

Monitor.resourcePollIntervals now judges all three sources by the
scheduler's own maximum interval, which bounds every interval it selects.
ResourceStaleThresholdsForConfig reads the same normalized bounds through
a normalizedSchedulerConfig shared with NewAdaptiveScheduler, so callers
without a monitor agree with the one that has it. The same live stack
stayed online for 14 minutes. Fixed-cadence behaviour is unchanged.
Per-instance planned intervals were not used: they are jittered and shrink
as a poll runs late, so a per-source threshold built on them would flap.

The contract note this replaces said freshness keeps deriving from the
per-platform intervals; the user doc now says what adaptive polling does to
freshness and offline detection. The cost is a longer node hold in
adaptive mode (10 minutes with the defaults). A valid /cluster/status
report that a member is offline still applies immediately.
2026-10-10 10:39:42 +01:00
rcourtman cd3904c89f Drop the unread points response meta from the recovery decode (#2787)
After the `useRecoveryPoints` trim in #2773 the TrueNAS Protection tab
reads only the points of `GET /api/recovery/points`. The frontend decode
still carried the response's pagination meta (`page`, `limit`, `total`,
`totalPages`) end to end, and nothing reads it. This removes that
decode. The backend response and `internal/api/recovery_handlers.go` are
unchanged.

## What goes

- `normalizeRecoveryMeta` and the `meta` field of
`normalizeRecoveryPointsResponse` (`utils/recoveryPlatformModel.ts`)
- `meta` on `RecoveryPointsResponse` and
`RecoveryPointsTransportResponse` (`types/recovery.ts`)
- `meta` in the hook's `initialValue` (`hooks/useRecoveryPoints.ts`)
- the meta fixtures and the meta-normalization tests in
`recoveryTransport.test.ts`, `recoveryPlatformModel.test.ts` and
`recoveryPlatformModel.branchcov2.test.ts`

`RecoveryResponseMeta` stays: `ProtectionPosturesResponse` (the
`/api/recovery/postures` response) still declares it. That response has
the same shape of problem (its `meta` is written by
`useProtectionPostures.ts` and read nowhere); it is a different endpoint
and tab, so it is left for its own change.

## Proof that nothing reads it

A whole-program TypeScript checker scan over `src` and tests (2,458
files), run on the tree before the edit:

- `RecoveryPointsResponse.meta`: 0 reads. Written only by the decode and
the hook's `initialValue`.
- `RecoveryPointsTransportResponse.meta`: 1 read, the decode itself. The
other references are test fixtures.
- `page`, `limit`, `total`, `totalPages` on `RecoveryResponseMeta`: read
only inside `normalizeRecoveryMeta`.
- No response is spread, iterated or serialized; the query layer treats
the value as opaque (its one spread is the cache-entry wrapper).
- Positive control: `RecoveryPointsResponse.data` is found read in the
hook and in a test. Negative control: a planted file reading `.meta`
eight different ways (property, destructure, element access,
indexed-access type, spread, `Object.keys`, nested member, literal
write) was found 8 of 8.
- Object-literal keys are resolved through the contextual type; the
checker returns the literal's own symbol for a key and would report a
false zero otherwise.

After the edit, `tsc --noEmit` over `src` (tests included) passes, so no
reader can have been left behind. The scan after the edit shows the
points types with `data` only and `RecoveryResponseMeta` with no readers
at all.

## Pins

`recoveryTransport.test.ts` (the registered proof for this path) now
pins:

- the keys of both points response types equal exactly `'data'` (type
level; fails under `tsc --noEmit`)
- a backend-shaped response that includes `meta` decodes with
`toStrictEqual` to the points alone

Single-flaw mutations each fail their own pin: optional `meta` re-added
to the normalized type, to the transport type (tsc); a decode that
copies `meta` through, and one that emits `meta: undefined` (vitest,
both the transport and branchcov2 tests); `meta` back in the hook's
`initialValue` (tsc, excess property). The unmutated tree passes all
checks.

## Contract

`storage-recovery.md`: the clause that 2773 added about the hook now
also says the decode carries the points alone, that the endpoint still
returns its pagination meta (backend contract unchanged), and that a
surface that pages the points adds the meta back with its reader.
Checked `frontend-primitives.md`, `unified-resources.md` and
`api-contracts.md`: none describes the frontend decode as carrying meta;
api-contracts item 34 is the backend's pagination meta and stays.

## Build, coverage and browser evidence

Before/after production builds on pulse-dev (before is the #2773 head,
after is the same tree plus this change):

- 189 files each side: 94 byte-identical (the CSS included), 94 differ
only in chunk-hash names and SRI strings, 1 differs in code. That one is
the TrueNAS page chunk, 68,278 to 67,891 bytes, and after normalising
hashes and short identifiers it loses only the removed statements (the
meta normalizer, the decode's `meta` field, the hook's
`initialValue.meta`).
- `check-bundle-size.mjs` passes on both with no chunk renamed and
`.bundlesize.json` untouched: TrueNAS 16.57 to 16.49 kB gzip, total JS
1372.50 to 1372.47 kB, CSS unchanged.
- Coverage of the hook and the decode over the recovery suites, matched
by normalised source text, loses nothing (`LOST_TOTAL 0`); 253 tests
before, 252 after.
- Browser pass of `/truenas/overview` and `/truenas/protection` at
1440x900 and 390x844, two runs per build. Rows, labels, status filter,
search, detail panel and the open-time and poll request URLs are
identical in every run; every `/api/recovery/points` response in both
builds still carries `meta` (`limit`, `page`, `total`, `totalPages`).
Each build had one run where a section-tab click timed out at 30 s under
pulse-dev load of roughly 40 to 60, so that is the rig, not the change;
the other run per build completed every step. Duplicate request counts
when returning to the tab also vary within the before build (3 and 1 on
desktop). Details are in the receipt under
`frontend-modern/browser-verification/`.

The local `tsc --noEmit` over `src` passes, as do eslint and prettier on
the touched files, the canonical platform audit and the 17 targeted test
files. A read-only second review of the staged change found no issues.

Not in this change: `ProtectionPosturesResponse.meta` (and with it
`RecoveryResponseMeta`) is written by `useProtectionPostures.ts` and
read nowhere; that is the `/api/recovery/postures` hook behind the
Proxmox Backups tab and is left for its own change.
2026-10-10 10:37:55 +01:00
pulse-triage[bot] 4239b6433d Integrate reviewed public disclosure containment guidance
Change-source: pulse-maintainer
2026-10-10 10:31:03 +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
pulse-triage[bot] 7c9daa000b Integrate canonical guest filesystem identity repair
Accept the bounded canonical-key admission repair with retained focused normal, race and exact-parent audit evidence. Existing completeness handling remains unchanged; connected and native recovery are not claimed.

Change-source: pulse-maintainer
2026-10-10 10:15:47 +01:00
rcourtman 49fd0b5d61 Merge remote-tracking branch 'origin/main' into refactor/recovery-meta-decode 2026-10-10 10:13:24 +01:00
rcourtman fb84cdaebf Drop the unread points response meta from the recovery decode
After the useRecoveryPoints trim in #2773 the TrueNAS Protection tab
reads only the points. The decode still carried the endpoint's
pagination meta (page, limit, total, totalPages) through
normalizeRecoveryMeta, the meta field on both points response types, the
hook's initialValue and several test fixtures, none of which anything
reads.

A whole-program checker scan over src and tests (positive control: the
data member, read in the hook and a test; negative control: eight
planted readers all found) showed zero reads of the normalized or
transport meta. The one read of the transport meta was the decode
itself, and the four RecoveryResponseMeta members were read only inside
normalizeRecoveryMeta. The response is never spread, serialized or
iterated, and the query layer treats the value as opaque.

Remove the decode, the meta fields on RecoveryPointsResponse and
RecoveryPointsTransportResponse, the initialValue meta and the meta
fixtures. RecoveryResponseMeta stays: the protection postures response
type still declares it.

recoveryTransport.test.ts pins the new shape: the response types'
keys are exactly data (a type-level pin that fails under tsc), and a
backend-shaped response that includes meta decodes to the points alone.
The backend response and internal/api/recovery_handlers.go are
unchanged, and the contract prose now says the frontend decode carries
the points alone.

A before/after production build pair differs in one chunk (the TrueNAS
page chunk, 387 bytes smaller) and only by the removed statements; the
CSS and the other 93 files are identical apart from chunk hashes. The
TrueNAS Protection tab shows the same rows, filters, search results and
detail panel at desktop and phone width, and the backend still sends
meta in every response. Coverage of the hook and the decode loses
nothing but the removed meta statements.
2026-10-10 10:11:04 +01:00
pulse-triage[bot] 0ddeef2785 Compose published remediation lock repair with reviewed monitoring maintenance
Preserve the exact reviewed local tip and current upstream ancestry. Incorporate the independently published link and succession lock carry repair additively; final-source proof remains required before publication.

Change-source: pulse-maintainer
2026-10-10 10:11:00 +01:00
pulse-triage[bot] 1343f23aa7 Incorporate current upstream without rewriting reviewed maintenance
Preserve reviewed SMTP, removal guidance and filesystem integrations while composing current Patrol, policy coordination and Storage repairs for publication.

Change-source: pulse-maintainer
2026-10-10 10:07:02 +01:00
rcourtman 809f0434b8 Merge remote-tracking branch 'origin/main' into fix/kiosk-section-chrome 2026-10-10 10:06:24 +01:00
rcourtman 61ffbdc29a Keep a remediation lock across links and ID successions (#2783)
## What was wrong

An operator's remediation lock (`NeverAutoRemediate`, or
`LifecycleState=retired`; `BlocksRemediation()`) is a row in
`resource_operator_state` keyed by one canonical ID. The planner, the
Patrol action broker and the dispatch gate
(`actionlifecycle.validateExecutionPolicy` →
`ErrResourceRemediationLocked`) read the row of the ID a resource
currently has. Two identity changes left the locked row behind:

1. **Manual link fold.** `applyManualLinks` merges the linked resource
into the primary and deletes it from the registry. Only the surviving ID
is ever acted on, and nothing consulted the folded ID's row. The
registry may also keep either side (a guest outlives an agent running
inside it whichever way the link points).
2. **Canonical-ID succession.** `ApplyCanonicalIDSuccessions` re-keyed
operator state with `UPDATE OR IGNORE`, so an existing successor row,
even an unlocked one, won and the predecessor's locked row was left
behind.

Each path is proven by a test that failed on `main`: registry-level for
the fold, store-level for the succession, both against the SQLite and
in-memory stores.

## The fix

The lock follows the resource, in the owning layer
(`internal/unifiedresources`), through one pure function
(`CarryRemediationLock`) and one sharing step over a group of IDs.

- **Link.** `AddLink` writes a block held by any member of the linked
component to every member, in the link's own transaction. The store
cannot see resource types, so it cannot know which side the registry
keeps; a chain of links is one component.
- **Unlink.** `AddExclusion` shares just before the split, so each
member the unlink leaves keeps the lock the merged resource carried.
- **Succession.** The block is carried onto a successor row that already
exists. The IDs the predecessor's and successor's links joined before
the re-key stay one group and share after it. Sharing waits for the
whole batch, so a block-only row made for one predecessor cannot take
the place of another's whole-row move. With no successor row the
predecessor's whole row still moves, as before.
- **Upgrade.** A one-shot carry (`migrateRemediationLockCarry`, recorded
in a new `resource_store_migrations` table) restores locks that earlier
links and already-recorded successions dropped. It follows recorded
succession chains, repeats with the links until nothing changes, and
only records itself done once it has converged. A failure is logged and
retried at the next start; it never keeps the store from opening. It
does not run again, so a lock an operator lifts afterwards stays lifted.

Decisions worth a look:

- **Retired is carried as `NeverAutoRemediate`, not as retired.**
Retired also silences alerts and Patrol attention. That is monitoring
posture, not a safety property, and copying it would mute a survivor the
operator still watches. Only the block crosses; every other setting of
an existing row is left alone and a member with no row gets a row
carrying the block alone, attributed `system:identity-change`.
- **Persisted copies, not a derived lock.** Deriving the lock from folds
at read time would show "off" in the operator-state panel while the gate
refuses, with no way to lift it short of unlinking.
- **`MemoryStore` now memoizes recorded successions like SQLite.**
Otherwise the test double re-keys on every re-declared succession and
could put back a lock the operator cleared.

## Known limits (stated in the contract, not fixed here)

- Sharing happens at identity changes, not on every operator write. A
lock written to a linked ID while the registry cannot resolve it (a
member absent from the current generation) stays on that ID until the
component's next link, unlink or succession.
- The copies written on folded members cannot be reached through the
resolving operator-state API while the pair is linked. Lifting the lock
on the resource the registry lists holds, but a hidden copy shares again
at the component's next link, unlink or succession. After an unlink each
member can be edited or cleared on its own.
- The store cannot see which links the registry declines to fold
(availability-owned endpoints, a node and agent pair a newer split
separated), so those share too.
- Chains declared across batches carry hop by hop; the stores do not
reorder a batch. Real producers (pin-driven and record-declared
successions) are star-shaped.
- Two other canonical-ID changes reach neither a link nor
`ApplyCanonicalIDSuccessions` and still do not move operator state: a
physical disk re-keyed in place to a machine-scoped ID, and a host whose
earlier ID never held an identity pin. Not part of this change.

## Evidence

No GitHub issue or demand-ledger entry asks for this (the nearest ledger
entry only records that Retired "also prevents remediation"). It is a
defect found by a review of the Assistant "Protected guests" setting,
not a feature.

## Verification

- New proofs fail on `main` and pass here (`registry_test.go`:
`TestResourceRegistry_ManualLinkFoldKeepsRemediationLock`,
`..._ManualLinkFoldLockLeavesSurvivorSettings`,
`TestLinkedComponentSharesRemediationLockThroughLinksAndUnlinks`,
`TestSQLiteStoreRestoresLocksEarlierIdentityChangesDropped`,
`TestSQLiteStoreRestoresLocksAcrossRecordedSuccessionChains`;
`canonical_id_succession_test.go`:
`TestCanonicalIDSuccessionCarriesRemediationLockOntoExistingSuccessorRow`,
`TestCanonicalIDSuccessionLockReachesLinkedSurvivor`,
`TestCanonicalIDSuccessionKeepsLinkedMembersTogether`,
`TestSQLiteSuccessionAndUpgradeCarryIgnoreMalformedLinkRows`).
- The full `./internal/unifiedresources/...` package passes, as do
`internal/actionlifecycle`, `internal/maintenancesentinel`,
`internal/api/resourceapi`, and the operator-state / link / succession
subsets of `internal/api`, `internal/monitoring` and `internal/ai/tools`
(run on pulse-dev).
- Mutation controls on pulse-dev, each breaking one site in a scratch
copy and failing the test aimed at it: `AddLink` and `AddExclusion`
sharing (SQLite and in-memory), the succession's pre-re-key member
capture and group sharing (both stores), sharing per item instead of at
the end of the batch (both stores), the remap that follows only later
re-keys (including the earlier one-global-map version), and the upgrade
carry's chain walk, repeat pass and marker.
- Backend-only; no frontend files change, so there is no browser pass.
- The contract prose in `unified-resources.md` is updated
(operator-state contract with its limits, the fold-resolution paragraph,
and the succession section).
2026-10-10 10:06:14 +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 d196f1cb51 Open the Patrol mode selector when a link lands on its anchor (#2784)
## What was wrong

The "Choose Patrol mode" buttons in Settings > Assistant (header and
Step 1) and the licence panel's Patrol control action link to
`/patrol#patrol-control`. Since the operator workbench redesign
(3088f52dc0) the four Patrol mode choices sit inside the Patrol header's
collapsed "Mode and automation" disclosure, and nothing opened it on
arrival. Those links therefore landed on a page where the choices were
hidden, which the api-contracts "Patrol control route target" clause
forbids: the canonical anchor must resolve to the visible Patrol mode
selector.

## What changed

`PatrolIntelligenceHeader.tsx` now opens the disclosure and scrolls it
into view when the route hash is `#patrol-control` (with or without the
`patrolControlStarter` query) or the `#operations-loop` compatibility
anchor.

- It reacts only to a change of hash or of the disclosure element, so a
disclosure the user collapses stays collapsed, and it still opens when
it mounts after runtime capabilities load.
- A plain `/patrol` visit stays collapsed.
- Plan-locked installs render no disclosure (an inline "Patrol mode"
line), so nothing changes for them.
- The starter query path only records the starter activity and strips
itself from the URL; it never handled focus, so it needed no separate
change.

The anchor-ownership clauses in `api-contracts.md`,
`patrol-intelligence.md` and `frontend-primitives.md` now describe the
arrival behaviour.

## Verification

- The header render suite gains eight cases: each anchor (three), a
plain visit, a late mount after the licence loads, in-app hash
navigation, a user collapse that stays collapsed, and the plan-locked
line. Against the previous header six of them fail (6 failed, 6 passed
in that file); with this change the Patrol feature, routing, Settings
agent panel, licence presentation and shared-primitive suites pass, as
do type-check and the full lint chain.
- Browser pass (receipt included): two dev servers, before and after, at
1440 and 390 px. Before, every anchor route and both Settings links land
with the disclosure collapsed; after, they open it with all four choices
enabled and in view. Runtime-locked installs open it with the paid
choices disabled; plan-locked installs are unchanged.

Observed, not changed here: on a plan-locked install at 390 px, arriving
from Settings leaves the page scrolled to where Settings was, as it did
before. That path renders no disclosure, so this change does not touch
it.
2026-10-10 09:57:25 +01:00
rcourtman 068893f83f Hide section tabs and lift table preview caps in kiosk
Kiosk is the read-only wall display from issue 917. With the filter bars
gone (#2779) two things still assumed someone at the keyboard: the
platform section rail, which the maintainer's replies on #917 and #1102
say kiosk hides, and the Proxmox nodes preview that stops at twelve rows
behind a Show all button.

PlatformSectionTabs now stays unmounted in kiosk and
createPlatformTablePreview shows every row with no toggle, so both rules
live in the shared primitives and every platform page and the Backups
view rail follow. The rail mounts fresh when kiosk ends so its phone
scroll controls bind to the new element, and the preview keeps its
expansion across kiosk so an open node drawer is not stranded.

On the 50-node mock estate the rail cost a 1280x400 display 53px, and
the cap kept 38 nodes off it, including the offline node ranked 23rd;
kiosk hides the navigation with its Alerts count, so the table rows are
where a node going down shows. Drawer search fields and the node-scoped
Guests heading reset stay, since a click or a chosen URL narrows the
page and these are the way back; the contract records why next to the
FilterBar kiosk rule and corrects the stale preview row counts (twelve
and six).
2026-10-10 09:55:39 +01:00
pulse-triage[bot] 6409a38480 Explain safe containment after an accidental public disclosure
Existing forms warn against uploading secrets but stop at prevention. Explain issuer revocation, separate attachment removal, surviving copies, private follow-up and independent monitoring coverage without asking for another diagnostic or credential test.

Change-source: pulse-maintainer
Contract-Neutral: Documentation and guidance coverage only; runtime, API, storage and deployment contracts are unchanged.
2026-10-10 09:54:36 +01:00
rcourtman a4cdb4af87 Admit human action dispatch under the policy coordinator (#2781)
## What was wrong

`actionlifecycle.Service.Execute` read the operator remediation lock
inside `ValidateCurrentReadiness` and committed the `executing`
transition afterwards, with nothing holding the two together. A "Never
auto-remediate" (or Retired) save that landed between the readiness read
and `RecordActionExecutionAdmission` was acknowledged to the operator
and the approved action still reached the executor. `ExecuteUnderPolicy`
already held the admission read lock across the same steps, and
operator-state PUT/DELETE already take the write side through
`WithPolicyMutation`, so only the human path was outside the boundary.

No downstream gate closed it: the HTTP handler delegates straight to
`Execute`, none of the typed executors (`routedActionExecutor`, Docker,
Proxmox guest, host update, host storage cleanup) read the lock, and
neither store's admission write consults operator state.

## Change

`Execute` now runs its gates and the admission commit in
`beginHumanExecution` under `coordinator.mu.RLock()` and releases the
lock before `dispatchCommitted` calls the executor. A lock save either
lands before the readiness check and refuses the action, or queues
behind the committed admission.

An admitted dispatch is deliberately not recalled by a later lock,
including an attempt that was admitted but never sent and is resumed by
`RecoverExecutingActions`. Recalling known-unsent work needs an atomic,
conditional pre-send terminalization that neither store has
(`RecordActionExecutionRefusal` only accepts planned/pending/approved
rows; `ForceFail` records inconclusive truth). The contract now says so,
and notes that operator-facing copy should say an already-admitted
action can still run.

## Proof

-
`TestExecuteAdmissionLinearizesWithOperatorLockSave{MemoryStore,SQLite}`
starts a real policy writer inside the window (through the executor's
availability hook, after the lock read and before admission), asserts
the coordinator excludes a writer there, and orders the save
acknowledgement against the admission commit. Against the old
`service.go` it fails on both assertions: ack seq 1, admission seq 2,
executor called.
- `TestExecuteAdmittedDispatchIsNotRecalledByLaterOperatorLock` pins
release-before-dispatch (a save during `ExecuteAction` must not block)
and no recall.
-
`TestRecoverExecutingActionsDoesNotRecallQueuedAttemptAfterOperatorLock`
pins the unsent-attempt boundary.
- The whole `internal/actionlifecycle` package passes under `-race` on
pulse-dev.

Hooks now fire under the read lock on the human path as they already did
on the automatic path. `WithPolicyMutation`'s comment and
`api-contracts.md` say hooks must not request a policy write or re-enter
`Execute`/`ExecuteUnderPolicy`.

## Not changed

`PulseToolExecutor.recordActionExecutionStart`
(`internal/ai/tools/action_audit.go`) has the same check-then-record
shape outside the coordinator. I did not find a live route into it: its
mutation helpers (`executeRunCommand`, `executeControlGuest`,
`executeDockerControl`, `executeKubernetesScale`,
`executeNativeAppContainerControl`) have no non-test callers, and the
file write/append helpers sit behind `pulse_file_edit`, which the
mutation registry records as `retired_denied` /
`denied_before_transport`. Removing that retained code belongs with the
dead Assistant handler cleanup.
2026-10-10 09:50:56 +01:00
pulse-triage[bot] e7d8092c63 Reject case-variant guest filesystem identity fields
Apply the existing canonical-key admission rule to filesystem identity and filter metadata as well as counters. A case-variant mount or type can otherwise overwrite a real volume, filter it out and falsely publish a complete healthy inventory. Preserve valid peers with the existing completeness error and hold only the original qualified disk evidence. Cover metadata ordering, array and object replies, both collectors, History, alert continuity and genuine ordinary recovery without changing guest command admission.

Contract-Neutral: Repairs existing filesystem identity and incomplete-reading invariants; no public wire schema, guest command, retry, lock, permission, threshold or backup contract delta.
Change-source: pulse-maintainer
2026-10-10 09:49:01 +01:00
rcourtman c97ae3f661 Stop Storage group headers pinning the group they collapse (#2782)
## Why

On Proxmox > Storage grouped by node, clicking a group header did two
things at once: it toggled the group and pinned it. The pin wrote
`summaryGroup=storage:node:<name>` into the URL, showed **Clear
selection**, tinted the group's pool rows, and closed any open pool in
another group. Because the same click also collapsed an open group, the
tinted rows were hidden in the common case. The chevron on the same row
only toggled, so the row and its own chevron behaved differently.

The pin was built to rescope the Storage summary charts, which were
deleted with the standalone surfaces. Nothing else read it. The header
hover preview, which tinted member rows, only fed those charts too. The
demand ledger has no entry for group hover or pin, and no GitHub issue
asks for either. #2723 removed the Workloads equivalent for the same
reason.

## What changes

- `StorageGroupRow`: a header click opens or closes the group, the same
as the chevron. No hover preview, no pin, no `data-summary-row-active`
or `data-summary-group-series-count`. It keeps `data-summary-group-id`,
so the shared clear-surface click still leaves an open pool alone.
- `useStoragePageModel`, `Storage`, `StorageContentCard`,
`StoragePoolsTable`, `StoragePoolRow`: remove the hovered and focused
group scope, `summaryGroup` hydration and writing, the effects that kept
them consistent, the `setExpandedPoolId` wrapper whose only job was
clearing the group pin, and the pool-row
`data-summary-group-member-active` state. **Clear selection** now
appears only for an open pool or a selected disk.
- `storageSummaryGroups.ts` keeps only `buildStorageSummaryGroupId`.
- Shared helpers: Storage was the last surface passing a group scope, so
`summaryCardInteraction.ts` drops `SummarySeriesGroupScope` and the
group resolvers, and `summaryTableFocus.ts` drops `focusedGroupId`, the
hovered and focused group scope options, `activeGroupScope` and the
pinned-group-header scroll.
- `index.css`: removes the `data-summary-group-member-active` rules and
their color tokens.
- `routing/resourceLinks.ts`: storage route state no longer names
`summaryGroup`. No link builder ever passed one; a leftover param in a
saved URL is ignored.
- Contracts: frontend-primitives rules 8, 10 and 11; storage-recovery
clauses 17 and 20 and the Storage contextual-focus paragraph; one
sentence in unified-resources' storage route-state paragraph.

## Verification

- Exercised the live page on a private mock stack (backend and Vite
built from this tree's base, Vite serving the change) at 1440x900 and
390x844:
- Before: one click on the `cluster` header collapsed it (20 to 18 rows)
and added `summaryGroup`. **Clear selection** appeared but no pinned
rows were visible. With `offsite-vault` open in another group, the same
click closed it.
- After: the click only collapses and expands, and the URL and **Clear
selection** are unchanged. An open pool in another group stays open, and
Escape still clears it. A hand-typed `?summaryGroup=` URL is ignored.
`?resource=ceph-rbd` still reopens its collapsed group and opens the
pool. Pool-row hover emphasis is unchanged. A phone tap on a header
toggles the group.
- Receipt: `frontend-modern/browser-verification/7aba289965a8c192.json`.
- Typecheck, eslint and prettier on the changed files, plus the theme,
copy, form-label and table-row audits, passed on pulse-dev against the
original base. The commit was then replayed onto current main, after 880
more commits, none of which touches Storage, the summary focus helpers,
`resourceLinks.ts` or `index.css`; the only same-file overlaps were
three contract docs and `SharedPrimitives.guardrails.test.ts`, which
applied cleanly. On that tree typecheck passes and 7,675 tests pass
across the Storage, shared, routing, utils, platformPage and
`src/__tests__` suites plus the three Workloads files that name
`summaryGroup`.
- One file in that run, `shippedDocFragments.test.ts`, hit its
hard-coded 15s `beforeAll` timeout while pulse-dev sat at load 40-70.
Alone it passes 3 of 3 runs on this tree and 3 of 3 on main.
- An earlier full run on the original base had 33 failures, all 5s
timeouts or workers that never started at load 110-140. Those files were
re-run with one worker and passed, except two `App.architecture.test.ts`
cases that read `tests/integration` files that run's frontend-only tree
didn't include (`ENOENT`); they pass in the full clone.
- Negative controls: the three rewritten Storage tests and the rewritten
`StorageGroupRow` test fail against main's runtime source.
- Commit hook battery and governance audits pass.
- Second-opinion review over two rounds. Round 1 found no functional
regression or remaining consumer, and one overstated sentence in
storage-recovery clause 17, now fixed. Round 2 confirmed the receipt's
content hashes and claims, and narrowed two historical sentences that
still overstated what the old pin did.
2026-10-10 09:47:30 +01:00
pulse-triage[bot] 6223788b6c Incorporate batch-start upstream frontier with reviewed filesystem assertions
Preserve the exact reviewed Core candidate and existing canonical integrations. Incorporate only the locally supplied batch-start origin/main frontier; publication remains with the scheduled gate.

Change-source: pulse-maintainer
2026-10-10 09:34:11 +01:00
rcourtman 395e9016b2 Merge origin/main into fix/operator-lock-survives-identity-change 2026-10-10 09:28:22 +01:00
rcourtman b2eed76e36 Open the Patrol mode selector when a link lands on its anchor
The "Choose Patrol mode" buttons in Settings > Assistant and the licence
panel's Patrol control action link to /patrol#patrol-control. Since the
operator workbench redesign (3088f52dc0) the Patrol mode selector lives in
the header's collapsed "Mode and automation" disclosure, and nothing
opened it on arrival, so those links landed on a page with the four mode
choices hidden. The api-contracts "Patrol control route target" clause
requires the anchor to resolve to a visible selector.

The header now opens the disclosure and scrolls it into view when the
route hash is #patrol-control, with or without the starter query, or the
#operations-loop compatibility anchor. It reacts only when the hash or the
disclosure element changes: the disclosure still opens when it mounts
after runtime capabilities load, and a user who collapses it keeps it
collapsed. A plain /patrol visit stays collapsed, and plan-locked installs,
which have no disclosure, are unchanged.

The header render suite pins each anchor, the plain visit, the late mount,
hash navigation, a user collapse and the plan-locked case; six of its new
cases fail against the previous header. The anchor-ownership clauses now
describe the arrival behaviour.
2026-10-10 09:24:48 +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