Mirror the qualified Unix reader in setup, Proxmox and container producers: select one terminal tool, distinguish missing terminal/tool/mode failures, restore its complete state before installation and on cleanup, and retain silent overflow draining. Emit the isolated privileged body once without changing tokens, trust, scopes, profiles or state. Cover serialized contracts and actual consumer payloads.
Change-source: pulse-maintainer
Preserve exact reviewed d636bfdaea and published 0a44d276db ancestry. Retain both contract additions, readable System evidence and stable accessible update help alongside upstream alert-first trends, facts and Details.
Change-source: pulse-maintainer
Patrol's operator-state projection dereferenced the one-shot
MaintenanceStartAt/EndAt whenever IsInMaintenanceAt was true. A
recurring window also reports active but leaves those fields nil, so
the first finding Patrol evaluated during a recurring occurrence
panicked the provider. The projection now takes its bounds from the
active occurrence.
Alerts already silence guests under a host window scoped to
resource_and_descendants, but Patrol read only the exact resource, so
the same planned work dismissed a guest's alerts and still raised its
Patrol findings. Both now resolve maintenance through one rule,
unifiedresources.ActiveMaintenanceCoverageAt: own window plus
descendant-scoped ancestor windows, latest active end wins. Only
maintenance is inherited; monitoring mode, lifecycle and remediation
locks stay exact-resource state. An inherited dismissal says the window
is on a parent resource and names it on the lifecycle event, and the
Manage help now says descendants' new Patrol findings are dismissed
too.
Omit mobile connection and push offers, and advertise only current Pro history. Same-path HTTP controls preserve signed grants, public/private runtime fences and 7/14/90-day compatibility without changing subscriber terms.
Change-source: pulse-maintainer
Reverts #2863 before any release ships it. The live Pro pricing and
Patrol pages, in English, German and Spanish, sell both modes ("applies
safe fixes in the mode you choose: Ask first, Safe auto-fix, or
Autopilot"), and paying installs chose them. Shipping the retirement
would have silently downgraded those installs while the sales page still
promised the modes.
If the modes are retired, that needs a dated notice: stop selling them
first, warn installs on those modes in-app, and then re-ship the change.
It should not arrive through an engineering cleanup.
The receipt #2863 landed stays in place; this commit adds the receipt
for the restored content.
Patrol could run some fixes on its own in two paid modes: assisted (Safe
auto-fix) and full (Autopilot, behind a server-owned acknowledgement).
Schema-v17 telemetry for the week to 2026-10-11 counts 1 assisted and 2
full installs out of 612 reporting Patrol, 4 executed Patrol actions in
30 days, all on one install, and no GitHub request for more autonomy,
while the automatic path carried its own policy leases, acknowledgement
store, per-resource allowlist, release gate and Colima lab. Investigation
is what paid installs use, so it stays.
Patrol now has two modes. Watch only reports findings. Ask first
investigates and proposes fixes, and every fix waits in Actions for an
operator to approve and run it.
- A saved assisted, full or autonomous level reads as approval, and the
update routes refuse those levels with 400 invalid_autonomy_level.
- The core Patrol action broker only plans: Plan and Submit persist the
plan and return. ExecuteUnderPolicy, the policy authorization lease,
the policy admission store methods, the Autopilot acknowledgement
persistence, routes and dialog, and the per-resource automatic-action
allowlist (store column now written as NULL) are gone.
GetPatrolFullModeUnlocked stays on the extension contract and reports
false.
- Historical action records keep their policy lease and tenant/resource
reason codes decodable, labelled as retired modes.
- The Patrol inbox no longer splits issues into "continuing without a
decision"; every current issue is an operator decision.
- Release gate RG-06 now proves Patrol never executes without an operator
decision (integration tier) instead of limited unattended autonomy,
and RG-02/RG-04 point at the surviving human-admission proofs.
- Pro plan copy, AI docs, privacy telemetry wording and the subsystem
contracts describe the two modes.
AI Discovery ran a separate model-backed service scanner beside Patrol
and the Assistant: its own store, scheduler, model route, API, tool and
drawer UI. No telemetry counter ever measured its use (the telemetry
discovery_enabled flag OR'd it with network subnet discovery), the
maintainer judged it a flop, and the context it produced is already
available to the models through pulse_query, pulse_read and the
unified-resource context sections.
This deletes internal/servicediscovery, internal/ai/infradiscovery, the
pulse_discovery tool, the /api/discovery routes, the mock Discovery
fixtures, the Discovery tabs in the resource, guest, node and Docker host
drawers, the identified-service cards, readiness badges, provenance
markers, suggested web-interface URLs, the Workloads AI Context column,
the chat "Discovery is off" hint and the AI Service Context settings page.
The old settings route redirects to Assistant settings. The model-bound
sanitizer allow-list existed only to pass Discovery context through, so it
goes too, along with the CopyableCodeRow primitive that only the
Discovery tab used.
Network subnet discovery (/api/discover, the Infrastructure discovery
settings) and the discoveryTarget agent coordinates are separate features
and stay. Stored discovery_enabled, discovery_interval_hours and
discovery_model AI settings are ignored on load; the settings API decoder
is lenient, so older frontends saving those fields still succeed.
pulse_control only plans typed resource actions into Pulse Actions
(executeControlResource -> typedActionPlanner). The handlers that sat
behind the retired model-authored command, file and container paths
stayed in the tree with no production caller: run command, guest
control, app-container control, Docker control and container update,
Kubernetes scale/restart/delete-pod/exec, and file append/write.
registerFileTools was never called either, so pulse_file_edit is not
registered at all; calls to it are denied by the mutation registry
before any tool lookup.
A whole-program reachability run over cmd/... lists each of them as
unreachable, and so is everything only they used: approval-record and
preflight authoring, the command and native-action audit lanes with
their remediation-lock gate and plan-hash helpers, the Docker host
resolvers, and the file write verification. The executor's autonomous
flag, whose only readers were those handlers, goes with them (the
executor's SetAutonomousMode and the flag argument of SetContext).
Delete them with their tests and the constants, types and test helpers
they stranded. The contract prose that described the removed broker now
says that plan freshness and the operator remediation lock are enforced
by internal/actionlifecycle. Two source pins that named deleted code go
(the publishActionCompleted dispatch sites and the pulse_file_edit
declaration), the standards tests drop their expectations of an
ActionPlan construction in action_audit.go and of the legacy app_container
rejection in the deleted file-edit test, and the RA35 evidence entry for
the deleted run-command test goes with them.
Left alone on purpose: the saved autonomous control level and its
entitlement and approval-admission checks, which stay as legacy
compatibility, Service.SetAutonomousMode and the chat clamp,
SetOnActionCompleted and its router wiring (no dispatcher is left, the
contract prose says so), and the protected-guests plumbing.
Rebuilt on current main: the first version had 213 commits of drift.
Two conflicts, both on code this change deletes. tools_control.go takes
the deletion over the Protected guests cleanup that landed inside
executeRunCommand. action_audit_remediation_lock_test.go goes with the
audit lanes, including the test added for the dispatch gate's read
through a succession; that gate is deleted here, and
TestLockMovedBySuccessionStillRefusesTheIDThePreviousGenerationLists in
internal/actionlifecycle covers the same refusal on the live path.
Distinguish cancelled or timed-out probes from certificate faults before classifying error text. Replace verification-bypass advice with endpoint, chain and independently verified fingerprint checks across PVE and PBS diagnostics, including scheduler errors and support JSON.
Exercise real loopback trust refusal and operator-supplied pin recovery without changing stored trust, credentials or scheduler authority.
Contract-Neutral: Restore existing TLS/timeout diagnosis and safe recovery guidance; errorKind/schema, saved trust enforcement/settings, agent lifecycle and scheduler/recovery authority remain unchanged.
Change-source: pulse-maintainer
Discriminate advice that weakens certificate trust and stopped handshakes mislabelled as certificate faults, before repairing the existing diagnostic path.
Contract-Neutral: Tests only; no runtime, diagnostic schema or TLS policy change.
Change-source: pulse-maintainer
## Problem
A source-filtered `POST /api/resources/{id}/report-merge` picked the
links to undo by each fold's `Sources`, which is the folded side's whole
subtree. Two consequences:
- In a chain (Docker host linked into an agent linked into a VM), naming
only `docker` also split the agent from the VM, because the agent's fold
lists the Docker source it took in.
- Where recorded links form a cycle (S->V, W->V, S->W with V primary),
naming only `vmware` excluded V-W but left W-S, so W rejoined through S
on the next rebuild. `main` cannot record that cycle today (its third
link is skipped once W is folded), so this half is proved on hand-built
folds and will matter once chains record their closing links.
## Change
- `ManualLinkFold` records what each side is on its own (`HolderOwn`,
`FoldedOwn`), read before the other side is merged in. A repeated pair
unions them, as it does `Sources`.
- `ReportedManualLinkFolds` undoes the links that join a member carrying
a reported source to the part that stays, or to another reported member.
A member that hangs below a reported one and carries no reported source
goes with it, and its link stays. "The part that stays" is what the
resource still reaches without passing through a reported member, so a
reported member is cut from every link that would rejoin it.
- The handler uses it in place of `selected(fold.Sources...)`.
A report naming every source, or none, still undoes every link. The
drawer's Split sends every merged source, so it is unchanged; only API
callers with a real `sources` filter see a difference. No schema,
storage or response change.
## Proof
- `TestResourceReportMergeSourceFilterDetachesChainMembers`
(resourceapi): a Docker host linked into an agent linked into a VM,
reported for the leaf, the holder, both, every source and no filter,
checked on the monitor and the REST listing and against the links left
in the store.
-
`TestContract_ResourceReportMergeSourceFilterKeepsLinksOfUnreportedMembers`
(api).
- `TestReportedManualLinkFoldsDetachMembersCarryingTheSource` and
`TestReportedManualLinkFoldsLeaveNoReportedMemberJoined`
(unifiedresources): trees, the cycle above, the root re-keyed, folds
without own sources, a refold that gained a source. The second checks
that no reported member stays joined to the root; selecting by subtree
sources fails it on the cycle.
- Fold recording tests updated for the own sources; clone test checks
they are deep-copied.
- Negative controls on pulse-dev with the handler restored to the
subtree selection: the leaf case of the chain test and the new contract
test both fail with the full split they used to produce.
- Full `internal/unifiedresources` and `internal/api/resourceapi`
packages pass on pulse-dev.
## What and why
Settings > Pulse Intelligence > Assistant offered a **Protected guests**
list (`protected_guests`). The value was saved and pushed into the
Assistant tool executor, but the only code that read it was
`executeControlGuest`, which has no callers. The live mutation path,
`pulse_control type=resource` -> `executeControlResource` ->
`resolveControlTarget` -> `assistantTypedActionPlanner.PlanTypedAction`
-> `actionlifecycle.Service.Plan`, never consulted it. #2765 labelled
the field "legacy" and said it does not exclude saved plans; this
removes the setting instead of keeping a field that cannot do its job.
**Decision: remove, do not enforce.** The canonical way to keep Pulse
off a resource is its operator lock (**Never auto-remediate**, or a
Retired lifecycle): keyed by canonical resource id (a VMID or a name is
ambiguous across nodes and clusters), available for every platform, set
from the resource's own drawer, and enforced by the action lifecycle at
dispatch for every actor, approved plans included. A second,
string-keyed list that only the Assistant honours would extend a legacy
path next to a canonical one. Nothing in GitHub issues, discussions or
the demand ledger ever asked for it, and no user doc described it before
#2765.
The one real gap the lock had on the Assistant's path was planning:
`Plan` does not read operator state, so the Assistant could leave a plan
in Actions that can never run. `assistantTypedActionPlanner` now reads
the state through the tenant-scoped lifecycle store (the same read
Patrol's broker uses) and declines before `Plan`; `pulse_control`
returns an `ACTION_NOT_ALLOWED` result with `reason_code:
resource_remediation_locked` and how to clear it. A state that cannot be
read does not refuse planning, because dispatch re-checks.
## Proof it was inert (before)
- In-process, production planning chain (tool executor +
`assistantTypedActionPlanner` + real lifecycle and Proxmox executor,
only the node runner faked): a guest whose name and VMID were both in
the list was planned (`planned:true`, real action id). An
operator-locked guest was planned too.
- Live on a private non-mock stack (stub Proxmox node, stub
OpenAI-compatible provider emitting `pulse_control restart web-prod`,
`protected_guests: ["web-prod","101"]` saved): the call reached the
planner and was refused only by the runner availability gate; protection
was never consulted. (The typed action runner needs Linux systemd
containment, so planning success could not be reproduced live on macOS;
the in-process run covers it.)
## After
- Same live stack on this branch, with the old config (still carrying
`protected_guests`) loaded: `GET /api/settings/ai` omits the field,
Settings shows no field, Save sends no `protected_guests`.
- Locking `web-prod` through the real UI (guest drawer > Manage > Never
auto-remediate > Lock this resource > Save overrides) and repeating the
Assistant turn returns the lock refusal and leaves nothing in Actions.
Unlocked, the turn behaves as before.
- Negative control: with the planner check removed,
`TestAssistantDeclinesToPlanAgainstBlockedGuest` fails for both blocking
states.
## Compatibility
- A stored `protected_guests` still loads (tolerant decode) and is
dropped the next time the settings are written. `PUT
/api/settings/ai/update` ignores the key from older clients (verified by
test), and `GET` no longer returns it. No other repo reads it.
- The maintainer's saved-policy compatibility tests from #2765 keep
their real assertions (control level, entitlement, Patrol policy); only
the `ProtectedGuests` marker is gone. The legacy copy, pins and prose it
added are replaced: Settings points at the lock, docs/AI.md and
docs/AI_AUTONOMY.md say the list is gone and how to lock a resource,
contract clauses updated in ai-runtime, api-contracts, agent-lifecycle,
frontend-primitives and storage-recovery.
- `frontend-modern/browser-tests/assistant-control-compatibility.cjs` (a
manual harness for the maintainer's proof environment) is updated for
the removed field; it was not run here.
## What this does not fix (chipped separately)
- A lock is lost when its resource is manually linked into another
resource or re-keyed by an identity succession (`registry.go` fold,
`canonical_id_succession.go` `UPDATE OR IGNORE`).
- Saving a lock while a human-approved dispatch is being admitted can
race past the dispatch gate (`actionlifecycle.Service.Execute` re-checks
without the admission lock). The public copy says the lifecycle checks
the lock again before it runs an already-planned action; the contract
names the window.
- REST and Patrol planning still use the unchanged lifecycle planner;
the planning refusal is Assistant-only by design. The lock covers the
exact resource, not its host or descendants, and does not stop read-only
investigation.
## Verification
- Go: `internal/config`, `internal/ai/...`, targeted `internal/api`
(Assistant planning, AI settings handler, contract) and `go vet` pass
locally; new tests cover unlocked, Never auto-remediate, Retired,
non-blocking overrides, a non-default tenant, the exact response
envelope and no audit written on refusal.
- Frontend: tsc, eslint, copy and form-label lints,
Settings/utils/guardrail vitest pass; new AISettings test covers the
hint, read-only hiding, an old backend still echoing the list, and the
Save payload.
- Browser: private stack, desktop and 390px, Settings > Assistant and
the guest drawer Manage tab; receipt included.
- Three Sol review rounds; findings fixed (tenant store path,
overclaiming copy, retired-state remedy, missed browser harness pins,
missing receipt) or chipped.
A source-filtered report-merge chose the links to undo by the sources
each fold's folded side took in, which spans that side's whole subtree.
Naming the source of a leaf in a chain (a Docker host linked into an
agent linked into a VM) also split the leaf's holder from the VM, and
where recorded links form a cycle a reported member stayed joined
through the link whose fold did not name it.
Folds now record what each side is on its own (HolderOwn, FoldedOwn),
read before the other side's sources are merged in, and
ReportedManualLinkFolds undoes the links that join a member carrying a
reported source to the part that stays or to another reported member.
A member hanging below a reported one goes with it and its link stays.
A report naming every source or none still undoes every link, so the
drawer's Split is unchanged; only API callers with a real sources
filter see a difference.
Settings > Assistant offered a Protected guests list (protected_guests)
that was saved and pushed into the Assistant tool executor, but its only
reader was executeControlGuest, which nothing calls. pulse_control plans
through the shared action lifecycle and never consulted the list, so a
guest named in it (by VMID and by name) was planned like any other. The
"legacy" label added in #2765 states that limit; this removes the
setting instead of keeping a field that cannot do its job.
The canonical way to keep Pulse off a resource is its operator lock
(Never auto-remediate, or a Retired lifecycle). It is keyed by canonical
resource id, applies to every platform, and the action lifecycle refuses
dispatch for it even for approved plans. The one gap was planning: the
lifecycle plans without reading operator state, so the Assistant could
leave a plan in Actions that can never run. assistantTypedActionPlanner
now reads the state through the tenant-scoped lifecycle store (the same
read Patrol's broker uses) and declines before Plan, and pulse_control
reports the block with how to clear it. A state that cannot be read does
not refuse planning, because dispatch re-checks.
Removed: AIConfig.ProtectedGuests and its getter, the settings API
field, the executor field/setter/config, the chat service plumbing, the
Settings field and form state, and the legacy copy, pins and prose from
#2765. A stored value still loads (tolerant decode) and is dropped the
next time the settings are written; older PUT bodies that send the key
are ignored. The Settings panel points at the guest's Manage tab.
Non-default organisations run their monitors against a detached copy of
the server config (#1619). A system-settings save reaches those monitors
only as runtime PBS and PMG polling overrides, so the copy that the
connections list reads through the org's config handlers kept the old
interval until that monitor was rebuilt. The active-to-stale cutoff is
3 x the polling interval, so raising PBS from 1m to 5m left a 3m cutoff
and Settings > Infrastructure badges read "stale" for part of every
cycle, while lowering it delayed the badge by the same factor.
Add Monitor.BasePollInterval, an override-aware reader for the clamped
PVE, PBS and PMG base cadence that never takes m.mu, and have the
connections input builder use it whenever a monitor is present. The
fixed-cadence branch of resourcePollIntervals reads the same accessor, so
resource freshness and connection freshness share one derivation.
The system settings GET is not affected: both production routes serve
SystemSettingsHandler, which reads the base config and persisted
settings; the configapi handler that reads the tenant copy is only a
fallback when that handler is absent.
Project interactive Assistant controls to Read-only and Ask first without migrating legacy stored preferences or relaxing entitlement, legacy command approval, scoped execution or Patrol policy. Preserve stored policy even when an older client echoes the projected mode on an unrelated save. Explain saved Actions plans and operator review in existing Settings, Chat and help.
Add saved-client and encrypted-config compatibility, authenticated plan-only access, active-stream and scope regression controls with parent comparisons. Record inspected final-byte desktop/phone browser proof and the affected subsystem contracts.
Change-source: pulse-maintainer
Additively incorporate the exact main repair that landed during this publication turn. Preserve every starting reviewed commit and the deferred browser qualification boundary.
Change-source: pulse-maintainer
The resources API rebuilds its registry from the monitor's unified
seed, but built it with NewRegistry and plain ingests, so its stale
pass used the registry defaults while the monitor judges each source
by its configured polling cadence. A Proxmox node polled every two
minutes read stale, with warning status, in /api/resources ninety
seconds after its poll while the websocket called it online.
Presentation coalescing had the same gap in both views. When it merges
an agent row the registry kept apart into its Proxmox node by hostname,
it picked each reading's source by the default thresholds, so the row
showed a silent agent's readings over a poll the registry calls
current.
The seed now carries the thresholds its registry generation was judged
by. The monitor adapter records them per generation: thresholds set
since the last rebuild wait for the rebuild that applies them, and the
read-state overlays and live supplemental refreshes of a generation are
judged by its thresholds, since re-judging a listing cannot clear a
stale verdict it already carries. The monitor lists a generation with
its thresholds from one registry pointer, and a view built from mock
fixtures or a bare snapshot reports none (the defaults it was judged
by). The monitor hands seed, freshness and thresholds over from one
view, per tenant too. The resources API builds
its registry with them (NewRegistryWithStaleThresholds: ingests and
stale passes that pass none use them) and keys its cache on its own
copy. The coalesce judges metric freshness with metricSourceStale, the
rule the stale pass and the merge already share, and both the
broadcast and ListForPresentation pass their view's thresholds. The
broadcast coalesce also honours the operator's merge exclusions, so the
adapter's CoalesceForPresentation takes the listing's thresholds next
to them.
Patrol's scoped runtime state still rebuilds registries with the
defaults.
The existing edit form defaults omitted mail, queue and quarantine flags to on. Serialize false collection options explicitly and test the real node GET payload so reopening an all-off or partial scope cannot silently re-enable reads on a later save.
Change-source: pulse-maintainer
Gate mail, queue and quarantine reads at the owning poller. Preserve legacy mail defaults while persisting explicit all-off scope, keep missing readings out of alert recovery and reject absent PMG object data. Partial queue observations may prove the highest configured severity but cannot resolve or downgrade alerts. Retain version connectivity separately from downstream poll failure and cover scope, persistence, pause, in-flight and adverse-read boundaries.
Change-source: pulse-maintainer
Patrol's finding operator-state provider read the store under the
finding's exact reference first and resolved it only on a miss. Once an
operator link folds an agent into a VM, a finding kept under the agent's
ID still found the agent's own row, even a default-valued one, and missed
the maintenance window or monitoring mode the operator then set on the
merged VM. The resources API and alert intent resolve first, so Patrol
disagreed with both.
The provider now resolves every reference through the organization
monitor's current read state (the published registry with saved hosts
overlaid, or the mock estate), which is the listing the resources API
seeds from, and reads only an unresolved reference as given. The old
miss path built a resources-API registry, which clones the unified
listing, for every finding without a row; the read state answers from a
registry it keeps.
One window still differs: a saved agent linked into a live VM that has
not reported since a restart stays its own row in the read state, so its
findings read the agent's own state there, as alert intent does.
Additively incorporate exact observed upstream a9f23501eb following successful bounded refresh 7a6fff3c87105d65e8e5cb5b45e82d27bffdedd05ff2c846e086aa27f2daee21. Retain starting reviewed tip d6a0244ede and both earlier full-SHA compositions. This batch remains deferred for its outstanding canonical, browser and security evidence; no source validation or release readiness is inferred.
Change-source: pulse-maintainer
Stacked on #2725 (Split a manually linked pair when the API unlinks it).
Until that merges this PR carries its commit too; only the last commit
is new here.
## Problem
`POST /api/resources/{id}/report-merge` (the drawer's "Split merged
resource" dialog) excluded only the candidate IDs that
`ResourceRegistry.SourceTargets` derives with
`sourceSpecificID(mergedResource.Type, source, sourceID)`. For a source
an operator link folded in, that candidate names neither side of the
link:
- an agent linked into a VM gets a `vm-` candidate;
- an agent linked with a node or a Docker host gets a source-specific
candidate, while the folded side's canonical ID derives from its machine
ID.
No `resource_links` row matched, so even with #2725's
one-decision-per-pair store rule the link survived, the pair stayed
merged on REST, `/api/state` and the websocket, and the API answered 200
"Merge reported" while the dialog toasted "Resource split applied". Two
linked agents share their only source, so the API refused them as
"Resource is not merged".
Identity merges were already handled: `findMatch` checks exactly the
candidate `SourceTargets` reports, and an agent/Docker pair merged by
machine identity stays apart through three monitor rebuilds after
report-merge. That is now pinned by a test.
## Fix
- `applyManualLinks` records each link it applies on the merged resource
(`recordManualLinkFold`, unexported `Resource.linkFolds`): the link
row's own pair plus the sources the folded side brought, carrying the
folded side's earlier folds so each link along a chain keeps its pair.
- The record rides in-memory clones. The resources API seeds its
registry from the monitor's already-linked listing and never holds the
folded row, so this is the only place it can learn the pair.
- `HandleReportMerge` excludes the pair of every link whose folded side
brought a reported source (`ResourceRegistry.ManualLinkFolds`),
alongside the existing identity-match exclusions, deduplicated in either
order. #2725's `AddExclusion` then deletes the link row, so the pair
splits from the monitor's next rebuild, as with unlink. An applied link
also counts as a merge, so two linked agents can be reported.
- A pair is recorded once. Record ingest can recreate a folded side
whose source mapping points at a holder of another type, then fold it
again.
- A link joins only the pair its row names. Canonical-ID succession
re-keys link endpoints with `UPDATE OR IGNORE` but moves `primary_id`
unconditionally. A row whose re-key collided with the successor's own
row therefore kept the retired endpoint and took the successor as its
primary, and `applyManualLinks` merged the successor through it. No
unlink or report-merge of the merged pair removed that row. A primary
naming neither side now falls back to the row's first side, which leaves
such a row inert.
The drawer sends every merged source. Source categories are not
attributed to individual contributors, so naming one that a folded side
carried undoes that link. A source that arrived along a chain undoes
every link on its way to the merged resource.
## Verification
- `TestResourceReportMergeSplitsOperatorLinkedPair`: link and
report-merge through the API handlers against a `MonitorAdapter` on the
same SQLite store, rebuilt three times per step. Cases cover VM/agent,
node/agent and agent/Docker pairs linked from either side, plus two
linked agents. With #2725's handler, 6 of the 7 cases fail: five with
"report-merge left 1 monitor resources", and the agent pair with a 400.
The exception is a node linked from its agent, because the node's
source-specific candidate happens to be its own ID.
- `TestResourceReportMergeSplitsPairBehindASuccessionShadowedRow`,
`TestResourceReportMergeSourceFilterSelectsLinks`,
`TestResourceReportMergeSplitsIdentityMergedPair`,
`TestContract_ResourceReportMergeReplacesOperatorLink` (response shape
plus link removal), `TestManualLinkFoldsNameEachLinkPairAlongAChain`
(monitor registry and a registry seeded from its listing),
`TestManualLinkFoldsRecordEachPairOnceAcrossRecordIngests`,
`TestManualLinkFoldRepeatedPairKeepsEarlierSources`,
`TestCloneResource_CarriesLinkFolds`, and the
`TestResourceAPIReportMergeExcludesRegistryLinkFolds` guardrail.
- Negative controls, each failing at the expected assertion: the handler
from #2725, the old not-merged gate, the primary honoured outside its
row, folds appended without dedupe, a repeated pair keeping only its
later sources, `applyManualLinks` without `recordManualLinkFold`, and a
clone that shares the fold slice.
- Full `./internal/unifiedresources/...`,
`./internal/api/resourceapi/...`, `./internal/truenas/...` and
`./internal/monitoring/...` pass.
Additively incorporate exact upstream main 533027ee06 without rewriting accepted maintenance. Resolve the storage-recovery contract insertion by retaining both the reviewed silent-source History fence and upstream SAML/method constraints. No runtime conflict resolution is introduced; final-content evidence and exact-head checks remain required before publication.
Change-source: pulse-maintainer
POST /api/resources/{id}/report-merge excluded only candidate IDs that
SourceTargets derives from the merged resource's type. For a source an
operator link folded in, that candidate names neither side of the link:
an agent linked into a VM got a vm- ID, and an agent linked with a node
or Docker host keyed by its machine ID got a source-specific one. No
link row matched, so the pair stayed merged while the API answered
"Merge reported" and the drawer toasted "Resource split applied". Two
linked agents share their only source and were refused outright as
"Resource is not merged".
applyManualLinks now records each link it applies on the merged
resource: the link row's own pair and the sources the folded side
brought, once per pair, carrying earlier folds so a chain keeps every
pair. The record rides in-memory clones, so the resources API's
registry, seeded from the monitor's already-linked listing, has it
although it never holds the folded row. Report-merge excludes those
pairs for the reported sources, and the store's one-decision-per-pair
rule deletes the link, as unlink does.
A link also joins only the pair its row names now. Canonical-ID
succession moves primary_id even when the endpoint re-key collides
with the successor's own row, and honouring that primary merged the
successor through a row that no unlink or report-merge of the merged
pair removes.
Additively integrate exact upstream 1a094646d7 while retaining starting reviewed 19a8358a49. Keep reviewed UUID incident/event IDs and upstream non-wrapping atomic sequences for other memory IDs. Compose upstream shared SMART/Unraid temperatures with reviewed pending context and observation-gap interruption; pin Unraid-only continuity and both grace paths. Retain all browser, completion and security validation limits until evidenced.
Change-source: pulse-maintainer
Brings the branch up to the main it lands on; the merge is clean.
Contract-Neutral: Merge of origin/main into the SAML stack: main's changes were governed when they landed, and the merge is clean
The SAML stack this branch builds on appends its boundary notes to
storage-recovery.md at the anchor main used for the linked-agent stale
flag note. Both sets of notes are kept, the SAML ones first.
Contract-Neutral: Merge of origin/main into the SAML stack: main's changes were governed when they landed, and the only resolution keeps both sets of storage-recovery boundary notes
## What
Single-resource report generation (`/api/admin/reports/generate`) now
takes `POST` with a JSON body as its canonical transport, and only
`POST` may narrate a PDF with Pulse Assistant.
- **POST**: the same fields as before (`resourceType`, `resourceId`,
`format`, `metricType`, `start`, `end`, `title`) in a JSON body, decoded
with the `generate-multi` rules (1MB cap, unknown fields and trailing
payload rejected). When Pulse Assistant is configured, a PDF's executive
summary is narrated by the AI provider as before.
- **GET**: still accepts the same fields as query parameters and returns
the same report, but always uses the deterministic summary. The PDF
shows a short note in place of the "Configure Pulse Assistant" tip,
which would be wrong for a tenant that already has the Assistant
configured.
- **GET never resolves the AI service at all.** It reads Patrol findings
only from a tenant AI service that is already running, through a new
`AISettingsHandler.ExistingAIService` lookup. Constructing a tenant
service through `GetAIService` can list provider models or start
background discovery, so a report GET must not trigger it. The router's
reporting wiring moves into `Router.wireReportingAIResolvers` so a test
can drive it.
- **Other methods**: `405` with `Allow: GET, POST`.
- The shared report body decoder now also returns `body_too_large`,
rather than `invalid_body`, when the cap is hit while reading past a
valid value.
- The reporting catalog advertises
`performanceReport.singleResourceMethod: "POST"`, and the settings UI
follows it. A catalog without the field, and the legacy fallback
catalog, keep the GET query transport that older backends accept. Fleet
reports were already `POST`.
- `docs/API.md` and `docs/MSP.md` (and their shipped copies) document
both methods.
## Why
Before this change, a GET for a PDF resolved the tenant's AI narrator.
That made a paid provider call and recorded a usage event in the AI cost
ledger. GET passes the demo-mode guard and the CSRF check, and a
SameSite=Lax session cookie is sent on cross-site top-level GET
navigations. So following a link could spend a signed-in admin's AI
budget.
GET stays available rather than returning `405` because the API
reference and the MSP guide documented it for scripted exports. Breaking
those scripts would add no protection: CSV is never narrated, and GET no
longer reaches the provider.
## Verification
- `TestContract_SingleReportAINarrationRequiresPOST` drives a real
report engine with a counting narrator. GET, HEAD, PUT and DELETE make
zero narrator calls and POST makes one. The GET narrative is heuristic
and carries the note. As a negative control, the same test fails against
the handler on `main` via `go test -overlay` ("GET report called the AI
narrator 1 times, want 0").
- `TestContract_SingleReportGETDoesNotConstructTenantAIService` drives
the router's real resolvers with an uncached tenant: GET constructs no
tenant AI service, while POST resolves one. A negative control that
swaps `ExistingAIService` back to `GetAIService` fails it ("GET report
constructed 1 tenant AI services, want 0").
`TestAISettingsHandler_ExistingAIServiceNeverConstructs` covers the
lookup itself.
- Handler tests cover the POST body rules (empty, unknown field,
trailing value, form-encoded body, fields only in the query, oversized
padding) and the `Allow` header. The catalog contract snapshot includes
the new field.
- Frontend tests cover POST bodies, the GET transport for catalogs that
name GET, parser defaults and validation, and the legacy fallback.
`docsLinks.test.ts` ties the docs to the handler.
- Browser pass on a private stack: real login, dev Pro licence, and
Pulse Assistant pointed at a local stub OpenAI-compatible provider that
logs every call. Checked at 1280×800 and 390×844:
- Generating a single-resource PDF from Settings → Data & Reports sends
one `POST` with a JSON body, downloads the PDF and calls the provider
once. The downloaded PDF carries the AI narrative.
- CSV makes no provider call. The fleet report still posts to
`generate-multi`.
- A top-level browser navigation to the GET URL with the session cookie
downloads the deterministic PDF, with the new note, and makes no
provider call.
- HEAD, PUT, DELETE and PATCH return `405`, and a session POST without
the CSRF header returns `403`.
- The AI usage summary lists `report_narrative` only for the UI POSTs.
- Full `./internal/api/...` and `./pkg/reporting` run on the dev VM:
everything passed apart from two tests that fail there for environment
reasons in code this change does not touch
(`TestServerInfoEndpointReportsDevelopment` needs `.git`; a `configapi`
shell-hangup bootstrap test).
The same class of issue on the Assistant session routes
(`/api/ai/sessions/{id}/summarize` and friends) is handled separately in
#2670.
GET /api/charts/workloads-summary has had no first-party caller since
the v6 import: ChartsAPI.getWorkloadsSummaryCharts arrived with it and
nothing ever called it. GET /api/charts/storage-summary lost its last
reader when storageSummaryTrendCache.ts was deleted on 2026-05-26.
Both still cost something. The workloads route carried a payload cache,
an SLO test in the release latency gate and a benchmark, and mock mode
rebuilt the 24-hour storage aggregate on every sampler tick for a cache
nothing read.
No stable release ever requested either route from the browser: v6.0.0
and later carry no caller, and the storage trend reader shipped only in
v6.0.0-rc.1 to rc.5. Neither route is in docs/API.md (a list of common
endpoints, not a complete one), and pulse-pro, pulse-mobile and
pulse-dev-infra never call them. A monitoring:read token could reach
them, so a private script may notice, but nothing known depends on
them. pulse-enterprise names the SLO test only as fixture data, and its
latency gate passes a test that is absent from the candidate as
removed. So the routes go with their clients:
handlers, response types, the monitor trend helpers, the mock aggregate
and its prewarm, the SLO budget, the benchmark, and the ChartsAPI
methods and types. Requests to either path now get 404.
buildMockWorkloadMetricHistorySeries goes too, because the
workloads-summary handler was its only production caller.
A TypeScript reference graph from src/index.tsx finds no production
reference to the removed methods or types. The production build loses
only the two static methods from the ChartsAPI chunk (326 B raw, 49 B
gzip); every other file differs only by chunk hashes and SRI values.
deadcode ./cmd/pulse and golangci unused report nothing newly
unreachable. On private mock stacks the Proxmox overview in Trends, a
guest History drawer and the storage page make the same chart requests
before and after at 1440 and 390 px, and neither build requested the
removed routes.
Contract prose that still described either route as live now records
the retirement, and the workload chart guard pins the per-type metric
sets the surviving /api/charts/workloads route requests.
Compose exact observed upstream e7cabc72f1 additively. Preserve both storage contract accounts at the sole documentation conflict; runtime merges retain reviewed repairs and upstream linked-agent disk ownership and public-demo profiling restrictions. Browser and security gaps remain publication blockers.
Change-source: pulse-maintainer
With allowIdpInitiated off, the default, every SAML login failed:
ProcessResponse passed crewjam/saml no request IDs, and crewjam refuses
a Response whose InResponseTo is not among them. Only providers that
allowed IdP-initiated logins, which skips the check, could sign in.
MakeAuthRequest now records each AuthnRequest ID with the login's
returnTo and the hash of a random token that /login sets as the
HttpOnly SameSite=Lax cookie pulse_saml_login (__Host- over HTTPS),
reused across tabs. The IdP posts its Response cross-site, which
withholds Lax cookies, so the ACS answers a cookieless POST with a
page that re-posts the same fields once from Pulse's origin. The
Response must answer an unexpired request bound to the presenting
browser; it spends that request and returns to the recorded path.
Another browser's request is left for its owner.
The request ID is read from the signature covering the assertion
crewjam returns (the signed Response, else that assertion's own
SubjectConfirmationData), not from crewjam's parsed fields and not
from other signed assertions riding in the same unsigned Response.
encoding/xml fills an un-namespaced
attribute field from a namespace declaration with the same local name,
and exclusive canonicalization drops an unused one from the signed
form, so xmlns:InResponseTo could rebind a signed Response and
xmlns:IssueInstant / xmlns:NotOnOrAfter let a stale one pass crewjam's
age checks: an IdP-initiated login could be replayed indefinitely. The
signature verifier now refuses signed content declaring a prefix named
like an attribute crewjam reads, in both modes.
RelayState is now escaped into the redirect query, so a returnTo with
its own query string reaches the IdP whole.
GET /api/admin/reports/generate resolved the tenant's AI narrator, so a
PDF requested with GET made a paid provider call and appended a usage
event to the AI cost ledger. GET passes the demo-mode guard and the
CSRF check, and a SameSite=Lax session cookie rides a cross-site
top-level GET navigation, so a link could spend a signed-in admin's AI
budget.
POST with a JSON body, decoded with the generate-multi rules, is now
the canonical single-resource transport and the only one that may use
the AI narrator. The settings UI sends it, following a new
performanceReport.singleResourceMethod catalog field; a catalog without
the field, and the legacy fallback catalog, keep the GET transport that
older backends accept. GET stays available with the same query
parameters but narrates deterministically, printing a note in place of
the "Configure Pulse Assistant" tip, and other methods get 405 with
Allow: GET, POST. GET also stops resolving the AI service at all: it
reads Patrol findings through a new ExistingAIService lookup, because
GetAIService constructs a tenant service on first use and that can list
provider models or start background discovery. The router's reporting
wiring moves into wireReportingAIResolvers so a test can drive it.
Keeping GET rather than answering 405 avoids breaking documented API
scripts (MSP.md pointed providers at it) for no security gain: CSV is
never narrated and GET no longer reaches the provider.
SAML providers configured with an SSO URL and a PEM signing certificate
(idpCertificate or idpCertFile) instead of IdP metadata never verified an
IdP signature. buildManualMetadata put the certificate into the IdP
descriptor's X509Certificate field as a PEM block, but crewjam/saml
base64-decodes that field as DER, so every ACS Response and LogoutResponse
failed with "cannot parse certificate: illegal base64 data at input byte 0"
before any key was compared. Providers configured from IdP metadata were
unaffected because metadata carries base64 DER.
Store base64 DER, as metadata does. The saved idpCertificate PEM and
idpCertFile path are read as before; only the descriptor rebuilt in memory
on each load changes, so no saved configuration needs migrating.
TestContract_SAMLManualIDPCertificateVerifiesIdPSignatures signs an ACS
Response and a LogoutResponse with one test IdP key and checks that
inline-certificate, certificate-file and metadata providers all accept
them, and that a Response signed by another key is refused. The test
allows IdP-initiated responses because ProcessResponse records no
AuthnRequest IDs, so at the default every ACS Response is refused on
InResponseTo. That is a separate open bug, not fixed here.
The /debug/pprof handlers are gated on RequireAdmin plus a settings:read
scope that session logins skip. The public demo signs every visitor in
as the configured admin, so any visitor could fetch goroutine and heap
dumps and the process command line, or start CPU profiles and execution
traces that load the whole process. DEMO_MODE already hides diagnostics,
logs, admin users and manual discovery through the admin-operations
policy; pprof was missing from it.
The policy now has the demo guard answer /debug/pprof and every path
below it with 404 for every method, ahead of route authentication.
Instances outside demo mode keep the routes, and PULSE_PPROF_DISABLED
still removes them everywhere.
TestContract_DemoModeHidesPprofFromDemoAdminSession signs in as the
demo admin through the full router, shows the same session still reads
/debug/pprof/cmdline below the demo guard, and gets 404 for eight pprof
paths through the served handler. On the previous policy GET and HEAD
returned pprof output (200, or the root's 301 into the index) and POST
got the generic demo 403. A policy test also fails if a literal route
the router files register under /debug/, with or without a method
prefix, escapes the policy for any common method.
Additively incorporate published demo-write admission, disk-replacement identity and Proxmox sensor guidance. Resolve the storage-recovery contract append conflict by retaining both the silent-source History rule and the demo-write rule unchanged.
Change-source: pulse-maintainer
DemoModeMiddleware admitted any request carrying "Upgrade: websocket",
whatever its method or path, so a POST/PUT/PATCH/DELETE with that header
reached its handler on a read-only demo instance. The public demo signs
visitors in as the configured admin user, which RequireAdmin accepts and
which skips token scope checks, so the middleware was the only write
guard: adding two headers let a demo session switch the instance out of
mock mode, change system settings or create organisations.
The exemption never served a real handshake. RFC 6455 handshakes are GET
requests, gorilla's Upgrader refuses any other method, and GET already
passes the read-only branch above it, so /ws and /api/agent/ws keep
connecting. Removing the branch leaves the method alone deciding.
TestContract_DemoModeUpgradeHeadersDoNotExemptWrites signs in as the demo
admin through the full router, proves the session passes RequireAdmin,
gets 403 for a mock-mode POST with upgrade headers and still opens /ws.
On the old middleware the same POST returned 200 and turned mock mode off.
The reporting race suite exposed an obsolete warning expectation after ea1a07a721 preserved a source's own offline verdict. Assert expired and cluster-down nodes remain offline, an unobserved member stays unknown, a reporting degraded array stays warning and a live merged node stays online. Preserve every existing no-false-live and current-reading assertion; no production behaviour or threshold changes.
Change-source: pulse-maintainer
Restore machine and other subject alert enrichment without path-based ownership. Reuse the existing PVE alert journal decision and cross-source hardware matching for serials, WWNs, placeholders, moved paths and ambiguous identities. Preserve machine isolation, handover counts, resolution windows and exact paid-boundary controls.
Adapt rcourtman's PR #2641 (de524e823d) and the boundary controls of rejected maintainer candidate b548e6dd3ae6d689a479254baf785d2bead7cf77. Correct its disk ownership defect against current main rather than retaining a parallel identity matcher. Imported and shared history is unchanged.
Change-source: pulse-maintainer
Compose exact upstream a21e0ef8f8 from the retained bounded refresh. Resolve the shared frontend boundary test by retaining both the accepted AST-based shared-adapter check and upstream per-change wrapper assertions. Preserve all reviewed commit identities, authors and ancestry; final source and browser evidence remains required.
Change-source: pulse-maintainer
Follows #2591. A node alert that moves to its linked Pulse agent closes
with a `moved_to_agent` resolution (#2580). Two readers still presented
that close as a recovery.
**Performance reports.** The node, VM and container enrichers copied
recently resolved alerts into `reporting.AlertInfo` without the
resolution, so a report generated in the five-minute window after a
handover read HEALTHY, "All systems operating normally", counted the
alert as "triggered and resolved", and sent the AI narrator `resolved:
true`.
- Report rows now carry the alert's resource and resolution
(`reportActiveAlertInfo` / `reportResolvedAlertInfo`).
- `pkg/reporting` has one rule (`unresolvedAlerts`): an alert that
closed without recovering stays live unless the report also shows its
successor's reading for that metric, meaning an open alert or a recovery
since the handover. That way the condition is counted once, and fleets
match handovers across all their resources.
- The single-resource PDF card reads WARNING (or CRITICAL at the alert's
level) with "Alert moved to <agent> - check the agent for the current
reading". The active list names the agent, the alerts table shows
"Moved" with the engine's summary as a note, and recommendations say to
check the agent.
- Fleets count the alert as active and name the agent. Their all-clear
pattern and "No fleet-wide action required" no longer appear while any
alert is active.
- The narrator payload carries `resolved: false`, the summary and
`successor_alert_listed`, with prompt rules for both.
**Resource history.** `alert_resolved` changes were labelled by kind
alone ("Alert resolved", green) with that label prefixed to the
headline. `getResourceChangePresentation` now reads the
`alert_resolution` metadata: the change reads "Alert moved" in the
neutral blue of the history badge, and its summary is the whole headline
in the drawer, `ResourceChangeSummary` and Patrol's assessment handoff.
**Verification**
- A private mock stack running this branch, with a local-only overlay
that closes a real pve1 memory alert through `clearAlertWithResolution`.
Within the window, `/api/admin/reports/generate?resourceType=node`
rendered CRITICAL with the handover message and a "Moved" row, while the
ordinary CPU recovery stayed green.
- In the browser, a full drawer (Availability checks, Fleet view) showed
the producer-made rows as "Alert moved" next to "Alert resolved" at 1280
and 375. A Proxmox node's own drawer uses the `table-row` presentation,
which fetches no change history (see below).
- Tests in registered proof files: `pkg/reporting/alert_state_test.go`
(verdict, successor timing, fleet pool, PDF text and width),
`internal/api/contract_test.go`, `internal/ai/report_narrator_test.go`,
`resourceChangePresentation.test.ts`,
`ResourceDetailDrawer.history.test.tsx` and
`patrolInvestigationContextModel.test.ts`. Each failed with the fix
reverted.
- Second opinion: two read-only review rounds. The temporal match,
fleet-wide pool, narrator dedupe signal, fleet all-clear fallbacks and
contract wording came from them.
**Not fixed here (separate changes)**
- Reports requested as `agent` carry no alert rows at all. That is how
the Reports page addresses every Proxmox node in v6, so such a report
reads HEALTHY even with active critical alerts. This change makes the
shared rule correct for when those reports attach the machine's alerts.
- Node and guest alert lifecycle changes are recorded under legacy alert
resource IDs, which canonical history queries never match. Facet kind
counts still group moved closes under "Alert resolved".
Preserve the exact source tree validated at 5989d928108acf80dc38b99cef40935fe17e6e32 while repairing the rejected isolated candidate's integration provenance. Apply rcourtman's original PR2628 proposal daa44ab26c alongside the bounded host-identifiable email repair and current-policy fixture corrections. Original contributor history remains untouched; this new commit records the maintainer's source integration, not a contributor identity change or a new runtime change.
Original-Proposal: daa44ab26c
Change-source: pulse-maintainer
TestContract_ConnectionSystemMembersIgnoreOwnedPhysicalDisks proves a
disk's health never folds into its cluster node's liveness. Its warning
disk came from heat: an NVMe at 63C, which the flat 60C disk risk rule
flagged. Disk risk no longer judges temperature, and 63C is under the
NVMe alert trigger, so that disk is now online and the precondition
failed. Reallocated sectors give the disk the same warning status
through real SMART evidence, which keeps the test on its subject.
The PDF performance report's disk table coloured a temperature amber
from 50C and red from 60C for every disk type, the last fixed disk heat
line after disk heat moved to the alert disk temperature policy. An NVMe
at 63C, well under its 70C alert trigger, printed red. A user's raised
trigger changed nothing.
Each reported disk now carries the tenant's alert thresholds for its
type (the clear value and the trigger, from
alerts.Manager.DiskTemperatureThreshold). The PDF colours the reading
amber from the clear value and red from the trigger, as the Physical
Disks Temp column does, and leaves it plain when disk temperature
alerting is off.