Commit Graph

13147 Commits

Author SHA1 Message Date
rcourtman 415c09dafc Judge resources API freshness by the monitor's thresholds
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.
2026-10-08 08:53:19 +01:00
rcourtman d2ac0c5112 Remove the summary chart routes no client calls (#2720)
Removes `GET /api/charts/workloads-summary` and `GET
/api/charts/storage-summary`, their frontend clients
(`ChartsAPI.getWorkloadsSummaryCharts`,
`ChartsAPI.getStorageSummaryTrend` and their types), and the backend
code that existed only for them.

**Why they are dead**
- `getWorkloadsSummaryCharts` arrived with the v6 import and was never
called: `git log -S getWorkloadsSummaryCharts -- frontend-modern/src
':!*test*'` lists only that commit, and no other source file ever
referenced the route.
- `getStorageSummaryTrend` lost its last reader when
`storageSummaryTrendCache.ts` was deleted in b91dc8dd4c (2026-05-26).

**Why the backend routes go too**
- 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`, which lists only `/api/charts` and
`/api/storage-charts` under Metrics & Charts. That page covers common
endpoints, not all of them, so this is supporting evidence rather than
proof. Any `monitoring:read` token could reach these routes, so a
private script may notice; nothing known depends on them.
- pulse-pro, pulse-mobile and pulse-dev-infra never reference them.
pulse-enterprise names `TestSLO_WorkloadsSummaryCharts` only as fixture
data, and `release_latency_gate.py` passes a test that is absent from
the candidate as `removed`.
- They still cost something: a payload cache, an SLO test in the latency
gate and a benchmark for the workloads route, and in mock mode a 24-hour
storage aggregate rebuilt on every sampler tick for a cache nothing
read.
- Requests to either path now get 404.

**Second-order orphans removed:** `buildMockWorkloadMetricHistorySeries`
and its test-only wrapper, `filterMetricPointMap`,
`oldestMetricSeriesTimestamp`, and the summary-only constants.

**Evidence**
- TypeScript reference graph from `src/index.tsx`: zero production
references to the removed methods or types. The live controls
`getInfrastructureSummaryCharts` and `getStorageSummaryCharts` are
found.
- Production build before/after: only the ChartsAPI chunk changes,
losing exactly the two static methods (326 B raw, 49 B gzip). `index.js`
and `index.html` differ only by chunk hashes and SRI values.
- `deadcode ./cmd/pulse` and golangci `unused` (with and without tests)
report nothing newly unreachable.
- Private mock stacks: the removed routes go from 200 to 404, and
`/api/charts/workloads`, `/api/charts/infrastructure` and
`/api/storage-charts` are unchanged. The Proxmox overview in Trends, a
guest History drawer and `/proxmox/storage` make identical chart
requests before and after at 1440 and 390 px, with no console errors.
Neither build requested the removed routes.

**Contracts:** prose that described either route as live now records the
retirement, with a rule under api-contracts item 39.
`TestContract_WorkloadChartMetricBudgetGuardrailsRemainCanonical` now
pins the per-type metric sets the surviving `/api/charts/workloads`
route requests, in place of the summary-only pins.
2026-10-08 08:17:34 +01:00
rcourtman dcde758a73 Start fresh installs with factory Docker alert thresholds (#2683)
A fresh install has no alerts.json, and LoadAlertConfig answered that
with its own AlertConfig literal. The literal never set DockerDefaults,
a
value struct, so container CPU, memory and disk loaded as trigger 0.
UpdateConfig keeps trigger 0 as a deliberate Off, so container metric
alerts never fired, the thresholds page showed the container Global
Defaults as Custom with every metric Off, and the startup identity
migration wrote those zeros to alerts.json a second after first boot.
The literal had also drifted from the manager's factory config on
flapping detection, alert TTL cleanup and the quiet-hours and
escalation templates.

The missing-file path now returns alerts.DefaultAlertConfig, the factory
constructor (now exported), so persistence keeps no second copy. It
leaves the activation state unset, as the literal did: node updates and
imports reapply LoadAlertConfig, and an explicit pending_review there
would send an active install back to pending review. Stored configs are
untouched, because trigger 0 is how a saved Off looks.

Two factory values would have reached installs for the first time
through this path, so they change with it. AutoAcknowledgeAfterHours
drops from 24 to 0: no settings control shows it, no install ever ran
it, and it would acknowledge still-firing alerts on the user's behalf.
The quiet-hours timezone drops from America/New_York to empty, which
keeps the settings page offering the viewer's browser zone.

Verification:
- Fresh data dir, non-mock and mock: GET /api/alerts/config on
origin/main
  returns dockerDefaults cpu/memory/disk {0,0}; this branch returns
80/75, 85/80, 85/80, and alerts.json written at first boot carries them.
- Thresholds page, Docker > Containers on a fresh mock stack:
origin/main
  shows Global Defaults "Custom" with CPU/Memory/Disk Off and every
  container row Off; this branch shows the factory 80/85/85 with no
  Custom badge, at 1440px and 390px.
- TestLoadAlertConfigFreshInstallUsesFactoryConfig fails against
  origin/main's persistence.go and passes here;
  TestLoadAlertConfigMissingFileKeepsRunningActivation and
  TestLoadAlertConfigKeepsStoredDockerDefaultsOff pass on both.
- go test ./internal/alerts/... ./internal/config/... and
  ./internal/monitoring/ pass.
2026-10-08 08:04:45 +01:00
rcourtman 537a32d8ad Point Patrol trigger contracts at the Patrol settings page (#2691)
## What

27ba85ddc6 removed the Patrol page's "Configure Patrol" popover.
Schedule, trigger and model settings moved to **Settings > Pulse
Intelligence > Patrol** (`PatrolSettingsContent` in `AISettings.tsx`,
saved through `useAISettingsState.ts`). Several contract clauses still
described the popover. This PR rewrites them to say where the controls
live and how they save.

| Contract | Old claim | Source today |
|---|---|---|
| patrol-intelligence (alert-trigger policy) | The selector sits in the
Patrol control panel and persists through its own `AIAPI.updateSettings`
call with optimistic state and revert-on-error | It is a field in the
settings form. Nothing is written until `Save Patrol settings`. The
severity goes in the page's single payload only when it changed. A
rejected save keeps the edit, and a successful save reloads the form
from the response |
| frontend-primitives (severity selector) | Lives under
`features/patrol/` in an "advanced Patrol settings disclosure" |
`FormSelect` in the settings page's Triggers section, rendered only
while alert triggers are on |
| frontend-primitives (toggles) | Toggles are named through
`ariaLabelledBy` | They carry explicit `ariaLabel` strings that contain
the visible label (`Enable alert-triggered Patrols`) |
| patrol-intelligence, api-contracts, frontend-primitives #61 | A header
"Schedule & model" drawer, a header settings surface with trigger
toggles, and a Patrol mode dialog | The header has a `Settings` link
(hidden while setup blocks), a conditional trigger-status line, and the
mode selector inside its `Mode and automation` disclosure |

Clauses owned by sibling PRs are left alone: the frontend-primitives
"Patrol schedule and model drawer" block, the patrol-intelligence
"advanced Patrol settings drawer" paragraph, and the patrol-intelligence
"header and configuration dialog" sentence (being folded into #2687).
Merge-tree against #2682, #2686, #2677 and the unpushed sibling heads is
clean.

## Tests

Three new cases in `AISettings.test.tsx` pin the rewritten claims:

- The severity selector is hidden while alert triggers are off, keeps
its value across off/on, and writes nothing before Save.
- A rejected save leaves the edit in the form for a retry. A successful
save reloads the form from the response and does not resend an unchanged
severity.
- Each Patrol trigger toggle's accessible name contains its visible
label.

Each assertion fails against a deliberately broken build: selector
always rendered, autosave on change, form reset on error, no reload
after success, severity always sent, and a divergent toggle `ariaLabel`.
`contract_audit.py`, `registry_audit.py` and `status_audit.py` report no
errors. The test-only frontend change needs no browser receipt; the
guard skips it.
2026-10-08 07:51:55 +01:00
pulse-triage[bot] b03965a0d2 Merge branch 'main' into refactor/drop-summary-chart-routes 2026-10-08 07:45:44 +01:00
pulse-triage[bot] 0bc18c5ddb Merge branch 'main' into fix/fresh-install-alert-defaults 2026-10-08 07:45:32 +01:00
rcourtman 780732a82a Resolve saved hosts an operator link joins to the link's primary (#2744)
Stacked on #2743 (Patrol resolves finding references before reading
operator state), whose commit this branch carries until it lands. Builds
on #2721 (link-fold references, merged).

## Problem

An operator link joins agent A to a live resource V, for example an
agent inside a vSphere VM. After a restart, until A reports again, A
exists only through saved-host continuity. Three consumers then
disagreed about what a reference to A means:

- **Monitor read state** (`GetUnifiedReadStateOrSnapshot`, used by
Patrol): continuity ingest skipped the link pass, so A stayed its own
offline row and references resolved to A.
- **Resources API** (`QueryService.buildRegistry`): seeding from that
listing re-applied the link and folded A into V, merging A's stale
telemetry into V. Operator-state writes naming A landed on V.
- **Alert intent** (`installOperatorIntentResolver`): resolved through
the published registry, where A is absent, so `agent:<host ID>` stayed
literal.

So maintenance or `NeverAutoRemediate` set on V reached neither A's
alerts nor its Patrol findings in that window, while the API said it
applied.

## Change

- **Registry holds instead of merging.** Rows continuity introduces
carry an in-memory marker (`Resource.continuityOnly`, rides clones,
cleared when an observation merges in). The link pass never merges a
link with a saved member. When the saved row is the member the fold
would have taken in, it is held beside the primary: every reference that
resolves to it (canonical ID, `agent:<host ID>`, machine ID, an alias it
shares with the primary) resolves to the primary, through
`ResolveReferenceID`, `GetByReference`, the alias index and
availability-check links. Both rows keep their own telemetry.
- The continuity pass now runs the link pass too. The resources API's
registry, seeded from the read state, holds the saved row the same way.
- A saved **primary** leaves a live member its own identity: a live row
never resolves to a saved one.
- A hold whose primary a later link in the same pass folds follows the
fold. A saved row held under a saved primary follows that primary's
hold, and a cycle of saved rows resolves each to itself.
- **Alert intent** resolves references and ancestors through the
published registry with saved hosts overlaid, the same read state Patrol
uses. This also lets intent set on an unlinked saved host reach its
offline alert.
- The resolver runs under the alert manager's lock once per metric
check, so the overlay is kept per published registry generation
(`MonitorAdapter.Generation`: the registry plus its last change time,
since two publications can share a timestamp).
  - Publication builds it before alerts are evaluated.
- A build that overlaps a mock-mode switch is discarded inside the
mock-mode fence.
  - In mock mode, lookups use the published registry as before.

## What lookups by A's ID do

- **Follow the link to V:** reference lookups (resource detail, agent
resource context), operator-state reads and writes, Patrol's finding
projection and alert intent. These match what a live fold already does.
- **Stay on A:** exact reads (`Get`). The saved agent's drawer still
shows its own facet bundle and history, and its operator-state section
shows V's state.
- **Unchanged:** history bindings, `SupersededCanonicalIDs`, and action
planning, which uses exact `Get`. Continuity rows advertise no
capabilities.

## Known limits

- The read state does not re-resolve availability checks after
continuity, so a check naming the saved agent stays as the published
registry left it. The resources API replays checks and attaches it to V.
This was the same before the change.
- When the saved agent is the link's effective primary (it stays primary
unless it was linked into its own guest), intent set under its ID does
not reach the live member until it reports.
- **Chains are out of scope.** Take an agent linked into a VM that is
itself linked into another resource. The saved agent is not held,
because the published registry has already folded the VM when continuity
adds the agent. It answers for itself until it reports. Live chains
already depend on the order their links are stored in. I tried making
chains order-independent here and every precedence rule picked a worse
identity somewhere, so that is a separate follow-up.
- A lookup that races ahead of publication, or the first one after
leaving mock mode, still builds the overlay under the alert lock.

## Testing

- `internal/unifiedresources/resolve_test.go`:
- `TestContinuityAgentLinkedIntoLiveGuestAnswersToTheGuest`: read state
and resources-API registry, including a hostname both rows carry, then
the fold once the agent reports.
  - `TestContinuityPrimaryLeavesLiveLinkMemberItsOwnIdentity`.
  - `TestObservationMergedIntoASavedRowLetsItsLinkFold`.
- `TestLinkHoldsFollowTheirPrimaryThroughThePass`: later fold, saved
primary, three-member cycle.
- `internal/monitoring/issue1913_host_continuity_test.go`
(`TestManualLinkToSupplementalGuestHoldsWithAndWithoutContinuity`): with
no continuity, an unrelated absent machine, and the agent's own saved
enrollment, the read state, alert intent and a registry seeded from the
read state resolve the agent to the guest without merging it.
- `internal/monitoring/monitor_host_agent_removal_lifecycle_test.go`
(`TestHostAgentRemovalLifecycleHonorsSavedHostIntentAfterRestart`):
after a restart, the monitor's own host-offline check raises no alert
for a saved host the operator marked intentionally offline. On main it
raises a critical host-offline alert, even with no link involved.
- `internal/unifiedresources/clone_test.go`
(`TestCloneResourceKeepsContinuityMarker`).
- `internal/monitoring/monitor_alert_intent_test.go`:
- `TestOperatorIntentReachesSavedHostAlertsAfterRestart`: an unlinked
saved host's intentionally-offline state reaches its offline alert.
- `TestOperatorIntentIdentityKeepsOneOverlayPerGeneration`: reuse within
a generation, a publication sharing or preceding the last timestamp, the
mock switch, and a rebuild from inside an admitted fence call. Passes 3×
under `-race`.
- `internal/api/resources_link_continuity_test.go`
(`TestResourcesAPIAndPatrolAgreeOnContinuityAgentLinkedIntoGuest`): API
write and Patrol read on the same store, with zero listing clones.
- Negative controls on pulse-dev:
- With the base `registry.go`, the registry, 1913, and API tests fail.
The resources API folds the saved agent into the guest when the guest is
the link primary, and folds the live member into the saved agent when
the saved agent is the primary.
- With the base `monitor_alert_intent.go` (plus `monitor_alert_sync.go`
and `monitor.go`), the 1913 continuity case,
`TestOperatorIntentReachesSavedHostAlertsAfterRestart` and
`TestHostAgentRemovalLifecycleHonorsSavedHostIntentAfterRestart` fail.
- Full `./internal/unifiedresources/...`, `./internal/monitoring/...`,
`./internal/alerts/...` and `./internal/api/...` on pulse-dev pass on
this change over #2721's earlier head. The one exception is
`TestServerInfoEndpointReportsDevelopment`, which fails in a tree
rsynced without `.git`.
- On the final stack (main after #2721 and #2742, then #2743, then this
commit), the targeted proofs plus #2739's unlink and report-merge tests
in `resourceapi` pass locally. The Go tree is byte-identical to that
verified stack. pulse-dev's disk was stalled on I/O at the time, so
every SQLite-backed test hung there in `fsync`, on `origin/main` too.
2026-10-08 07:02:17 +01:00
rcourtman 95e5a25daf Resolve Patrol finding references before reading operator state (#2743)
Patrol's finding operator-state provider read the store under the
finding's exact reference first and only resolved the reference on a
miss. After an operator links an agent into a VM, a retained finding
still names the agent's canonical ID. If the agent kept an
operator-state row of its own (even a default-valued one), Patrol read
that row 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 the three disagreed.

## Change

- `Router.patrolResourceOperatorStateProvider` (internal/api/router.go)
resolves every finding reference through the organization monitor's
current read state (`GetUnifiedReadStateOrSnapshot`), via its
`ResolveCanonicalResourceID`, before reading the store. Only an
unresolved reference is read as given.
- That read state is the published registry with saved hosts that have
not reported since a restart overlaid (or the mock estate): the listing
the resources API seeds its registry from. It covers link-folded IDs,
retired canonical IDs and node-scoped Proxmox guest refs.
- The old miss path built a resources-API registry, which clones the
full unified listing, for every finding without a row. The read state
answers from a registry it keeps.

## Known difference

A saved agent that is linked into a live VM and has not reported since a
restart stays its own offline row in the read state, because continuity
records never join links. The resources API's registry re-applies the
link and folds it. In that window, Patrol (like alert intent for the
agent's canonical ID) reads the agent's own row, so state set only on
the VM does not reach the agent's findings until it reports again.
Whether identity should follow the link there is a registry-level
decision, tracked separately; it affects alert intent the same way.

## Tests

- `TestPatrolFindingOperatorStateFollowsLinkFoldedReference`: agent plus
vSphere VM, manual link, two rebuilds, operator state written through
the resources API. The provider must return the VM's maintenance for the
folded agent ID, the VM ID and the vSphere source ID, must read an
unresolved reference's own row, and must take zero listing clones.
- `TestPatrolFindingOperatorStateResolvesContinuityBackedHost`: a host
known only through continuity after a monitor restart. The provider must
read the state the API stored for `agent:<host ID>`, with zero listing
clones.
- Negative controls (run on the final tree before rebasing onto this
main, locally and on pulse-dev):
- The old exact-first body fails the fold test (it reads the agent's
default row) and the continuity test (it clones the listing).
- Resolving first through the resources-API registry fails both on the
clone count.
- Resolving through the raw published adapter fails the continuity test.
- The targeted tests pass on this exact tree.
- The full `internal/api` suite passed on pulse-dev on an earlier base
(main plus #2715 plus the first version of #2721), apart from
`TestServerInfoEndpointReportsDevelopment`, which fails there only
because the rsynced tree has no `.git`.
2026-10-08 06:43:46 +01:00
rcourtman 19d8b4f81a Resolve saved hosts an operator link joins to the link's primary
After a restart, an agent an operator linked into a live resource, such
as an agent inside a vSphere VM, exists only through saved-host
continuity until it reports again. Three consumers then disagreed about
what a reference to it meant. The read state kept the saved agent as its
own offline row and resolved references to it there. The resources API,
which seeds a registry from that listing and re-applies the links,
folded it into the VM and merged its stale telemetry. Alert intent
resolved through the published registry, where the agent is absent, so
its agent:<host ID> alerts kept their literal reference. Maintenance or
NeverAutoRemediate set on the VM reached neither the agent's alerts nor
its Patrol findings in that window, while the API said it applied.

An operator link names one identity; telemetry separation is the only
reason the saved row stays apart. Link application now holds a saved
row beside the link's primary instead of merging it, in every registry.
A reference that resolves to the saved row (its canonical ID,
agent:<host ID>, its machine ID or an alias it shares with the primary)
resolves to the primary, as a folded row's references do, while each
row keeps its own telemetry. An in-memory marker on rows continuity
introduced rides clones, so the resources API's registry holds them the
same way. A saved primary leaves a live member its own identity.

Alert intent now resolves references and ancestors through the published
registry with saved hosts overlaid, the read state Patrol resolves
through. That also lets intent set on an unlinked saved host reach its
offline alert. The resolver runs under the alert manager's lock once
per metric check, so the overlay is kept per published registry
generation, publication builds it before alerts are evaluated, and a
build that overlaps a mock-mode switch is discarded.
2026-10-08 06:27:50 +01:00
rcourtman b60f441e22 Resolve Patrol finding references before reading operator state
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.
2026-10-08 06:25:45 +01:00
rcourtman 30ab8fa31f Resolve references to resources a manual link folded (#2721)
Stacked on #2715 (fix/manual-links-record-ingest), whose commit this
branch carries until it lands.

## Problem

When an operator link folds one resource into another, such as a Pulse
agent into the vSphere or Proxmox VM it runs in, the folded resource's
canonical ID stopped resolving. An availability check configured against
the agent (`LinkedResourceID` = the agent's `agent-…` ID) lost its
target: the VM showed no check and the endpoint row kept no
relationship. The resources API seeds a fresh registry from the
monitor's already-folded listing and then replays the checks, so it lost
the check too. With #2715 joining links on record ingest, the monitor
broadcast does as well. `ResolveReferenceID` / `GetByReference` returned
nothing for the folded ID either.

A registry-local fold set is not enough: the resources API never sees
the folded resource, so the monitor and the API would disagree.

## Change

- The merged resource carries the IDs links folded into it
(`Resource.linkFoldedIDs`, unexported, in-memory only), including IDs
folded along a chain of links. Clones of the listing carry it, and
resource seeding indexes it, so the monitor rebuild, the resources API
and read-state overlays resolve a folded ID to the same merged row.
- The index stores the holding rows and checks them on read. A holder
that a later link merges away, or a disk re-keyed in place, stays
consistent. An ID two rows hold resolves to nothing and does not fall
through to weaker matches such as a hostname alias.
- New fold arms in `resolveAvailabilityLinkedResource` and
`resolveReferenceIDLocked`. An exact live ID still wins.
- Deliberately unchanged:
- **History resolution.** History bindings persist, and
`expandHistoryAliases` walks them in both directions, so binding a
folded ID would join two journals permanently.
- **`SupersededCanonicalIDs`.** Alert-configuration and
availability-link migrations consume it and would rewrite the operator's
stored config.
- **Patrol's finding operator-state projection**
(`internal/api/router.go`) still reads an exact row first. That is a
separate follow-up; the contract states the exception.

## Testing

- `availability_link_test.go`:
- `TestAvailabilityLinkToALinkFoldedResourceFollowsItsPrimary`: both
link directions, two rebuilds, monitor + resources-API seeding +
overlay; history and superseded IDs untouched.
  - `TestAvailabilityLinkFollowsChainedLinkFolds`.
- `TestLinkFoldIndexFollowsItsHolders`: ambiguity with a hostname-alias
decoy, holders merged by a link, a re-keyed holder.
- The first two fail on #2715's head. The holder cases fail against the
first version of this change (string-keyed index).
- `go test ./internal/unifiedresources/` passes locally.
`./internal/unifiedresources/... ./internal/monitoring/...
./internal/api/...` on pulse-dev: everything passes except the known
`TestServerInfoEndpointReportsDevelopment`, which fails because the tree
was rsynced without `.git`.
2026-10-08 06:24:31 +01:00
rcourtman bda041c261 Record the browser pass for main's merged Patrol state hook (#2742)
Fixes the red Canonical Governance check on main's tip a9f23501eb, which
blocks open PRs:

> BLOCKED: browser verification does not cover
986cead30ce7..a9f23501ebbd:
frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts final
content 8c3557c19ac1 is not verified by any valid receipt in the range

#2704 landed on main after #2740 was pushed. The main ruleset does not
require branches to be up to date, so the #2740 merge combined #2704's
removal of the circuit-breaker read with #2740's removal of the
test-only autonomy save helpers. The merged hook matches neither parent,
and #2740's receipt covered only its own content.

This PR is a single receipt-only commit bound to a9f23501eb, recording a
fresh browser pass on main's final content:
- The production builds of 986cead30c (main just before the merge,
governance green) and a9f23501eb are byte-identical (189 files).
- The Patrol mode pass matched on both builds at 1440x900 and 390x844.
It covered paid saves including Autopilot through the acknowledgement
dialog, plus the runtime lock with and without the demo presentation
override.
- No PUT carried `full_mode_unlocked`, and neither build requested `GET
/api/ai/circuit/status`.
- The only differences were timing on the loaded pulse-dev host and an
unrelated replication-jobs poll.
- `tsc --noEmit` and the Patrol hook, page, header and resource-boundary
suites pass on a9f23501eb.
- Locally, `browser_verification_guard.py --base 986cead30c --commit
HEAD` passes, which is the range main's push check runs.
2026-10-08 06:16:10 +01:00
rcourtman 059428e2dc Merge fix/receipt-patrol-state-hook-merge into fix/link-fold-references
Main's tip fails the required governance check: the merged Patrol state
hook has no browser-verification receipt. PR 2742 adds that receipt on
main's tip. Carry it here, together with main's commits beneath it, so
this branch's governance range is covered whichever PR lands first.
2026-10-08 06:03:56 +01:00
rcourtman 793b3899bc Record the browser pass for main's merged Patrol state hook
#2704 landed on main after #2740 was pushed, and main's ruleset does not
require branches to be up to date, so the #2740 merge (a9f23501eb)
combined #2704's removal of the circuit-breaker read with #2740's removal
of the test-only autonomy save helpers. The merged
usePatrolIntelligenceState.ts (8c3557c1) matches neither parent, no
receipt covered it, and Canonical Governance went red on main, blocking
open PRs.

This commit records a fresh browser pass on that final content. The
production build of main is byte-identical to the build of 986cead30c
(main just before the #2740 merge, governance green), and the Patrol
page and Patrol mode passes on both builds match.
2026-10-08 06:00:55 +01:00
rcourtman a9f23501eb Remove test-only Patrol autonomy save helpers (#2740)
Stacked on #2695 (branch contains its head 32fad38aa4). Once #2695
merges, this PR's diff is the single commit 776c2774ba.

## What

`resolvePatrolAutonomyLevelForSave` and
`resolvePatrolAutonomySettingsForSave` in
`features/patrol/usePatrolIntelligenceState.ts` had no production
caller. Only their own unit tests imported them. Patrol mode saves go
through `handleAutonomyChange` and `acknowledgeAndActivateAutopilot`,
whose payload type `PatrolAutonomyUpdate` has no `full_mode_unlocked`
field, and both reconcile from `GET /api/ai/patrol/autonomy`. This PR
deletes both helpers and their tests.

Contract prose in patrol-intelligence, api-contracts,
frontend-primitives and ai-runtime described the helpers' model rather
than the live path: the browser clearing stale full-mode unlock state
before a monitor-only save, sending `full_mode_unlocked:false` when
locked, and sending `full_mode_unlocked:true` for Autopilot. The API
does that work. `HandleUpdatePatrolAutonomyMonitorOnly` and the paid
handler's `SaveAutonomySettings` adapter clear the unlock and Autopilot
activation on every save below Autopilot, and `GatePatrolAutonomyUpdate`
derives the compatibility flag from a recorded acknowledgement. The
prose now says what the header, the state owner and the API each do.
Three neighbouring api-contracts sentences about the same save were also
corrected:
- a `full` request without a current acknowledgement is refused by the
gate before any licence check;
- the "Patrol mode dialog" (removed in 27ba85ddc6) is now the header's
`Mode and automation` disclosure, with the same wording as #2691;
- the lock is checked "when a save starts", not "never submits", because
the Autopilot flow checks it only at entry.

The deleted helper tests are replaced by tests of the live locked path.
A page test loads stale paid state (Safe auto-fix plus the compatibility
unlock) under a runtime lock. It checks that Watch only is presented,
that the paid choices are disabled and save nothing, and that Watch only
sends exactly `autonomy_level`, `investigation_budget` and
`investigation_timeout_sec`. A source pin keeps the state owner's own
lock guards.

## Verification

- Production build: parent and this change produce byte-identical dists
(187 files), because Rollup had already dropped both helpers.
- Browser pass on pulse-dev, before and after, one DEMO_MODE stack at a
time, 1440x900 and 390x844. Covered paid saves (Ask first, Watch only,
Autopilot through the acknowledgement dialog) and the runtime lock with
and without the demo presentation override. 6 sessions and 80 recorded
fields, with 0 differences between before and after. No PUT ever carried
`full_mode_unlocked`. Receipt
`frontend-modern/browser-verification/994121d9684bd9f0.json`.
- `tsc --noEmit` and the Patrol hook, page, header, workspace,
commercial-boundary and resource-boundary suites pass (84 tests).
- Negative controls, each failing the intended test:
  - header showing the stale paid mode while locked;
  - payload carrying `full_mode_unlocked`;
  - hook lock guard removed;
  - paid choices enabled while locked.
2026-10-08 03:41:42 +01:00
rcourtman 986cead30c Stop Workloads row focus staging a stale shell scroll restore (#2736)
Stacked on #2723 (its one commit is the parent here). Whichever lands
first, the other still merges.

## Problem

Selecting a Workloads row called `capturePendingAppShellRestoreTop()`,
which staged the `.app-scroll-shell` position for `App.tsx` to restore
on the next route change. That made sense when row focus wrote
`?resource=` into the URL. 86ac84f68e made selection local, but the
capture stayed. So the staged position now waits for whatever route
change comes next. The same-path route-state scheduler only re-stages
from a scrolled shell, so at scrollTop 0 the old value survives.

Reproduced on a mock estate (embedded build of main, /proxmox/overview,
1440x900 and 390x844), with every scripted `scrollTop` write on the
shell recorded:

| Steps | main | this branch |
| --- | --- | --- |
| Open a guest drawer at scrollTop 3000, scroll to 0, set Type to VMs |
URL becomes `?type=vm`, then the shell jumps 0 → 3000 (74 ms later on
desktop, 126 ms on phone) | stays at 0, no scripted write |
| Same scroll path, no drawer | stays at 0 | stays at 0 |
| Drawer, then filter changed at scrollTop 500 | stays at 500 (the
scheduler re-stages) | stays at 500 |
| Drawer, then Proxmox Storage sub-tab or Docker tab | 0 (destinations
are shorter than the stale offset) | 0 |

## Change

- `useWorkloadSelectionState.ts`: drop the capture. Row focus already
holds its place through `preserveScrollableAncestorVerticalOffset`;
opening a drawer still leaves the shell at 3000 with the URL unchanged.
The now-identical `setSelectedGuestIdState` folds into
`setSelectedGuestId`.
- `appShellScrollRestoration.ts`: delete the capture helper, which has
no callers left, and its branch-coverage test. `routeStateNavigation.ts`
is now the only producer, and it stages right before it navigates.
- A regression test in `useWorkloadSelectionState.test.ts` toggles a row
at 3000, returns to 0 and runs a same-path filter write. It fails on the
parent with `expected 3000 to be null`.
- Contract prose in performance-and-scalability and unified-resources no
longer describes the old capture handoff.

## Verification

- Before/after browser probe at both widths on pulse-dev. A `?resource=`
deep link still opens and reveals the drawer, closing it keeps the
position, and there were no page errors. Receipt:
`frontend-modern/browser-verification/b3c60348af17e8cc.json`.
- prettier, eslint and `tsc --noEmit` are clean.
- Targeted vitest: 556 files passed. `docsLinks.test.ts` timed out at
host load ~76 and passes on its own (104/104 across the five related
files).
- Full pre-commit and pre-push batteries passed.
2026-10-08 03:41:25 +01:00
rcourtman 7949036657 Merge origin/main into fix/link-fold-references
PR 2739 added Resource.linkFolds, a record of every link folded into a
resource that rides in-memory clones, which is the same fold set this
branch carried as Resource.linkFoldedIDs. Keep one record: drop
linkFoldedIDs and index folded IDs from linkFolds when a link applies
and when resource seeding copies the record in.

Main's unlink test treated a lookup by the folded agent's ID as proof
that the agent was still listed. That lookup now answers with the VM
holding it, as lookups by source IDs and canonical aliases already do,
so the test checks which row answered and pins the VM answer.
2026-10-08 03:30:51 +01:00
rcourtman 31fcc4f4d6 Split operator-linked resources when a merge is reported (#2739)
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.
2026-10-08 03:19:09 +01:00
rcourtman 217ebc3db5 Remove the unreachable Workloads chart visibility toggle (#2734)
The Workloads View disclosure rendered `ChartVisibilityToggleButton`
only inside `<Show when={props.onChartsToggle}>`, and no surface passes
`onChartsToggle` or `chartsCollapsed` since the workload summary chart
section was deleted with the summary components. The button had no other
production consumer.

This removes:
- the `Summary` block and import in `WorkloadsFilter.tsx`, and the
`chartsCollapsed` / `onChartsToggle` props in `workloadsFilterModel.ts`
- `ChartVisibilityToggleButton` and its icon import in
`shared/FilterToolbar.tsx`
- the `chart-visibility-toggle-button` rule and
`chart-visibility-local-toggle-labels` pattern guard in
`scripts/shared-template-registry.json`
- the unit tests for the button and the WorkloadsFilter charts describe

The guardrail case that required the primitive now keeps the exact
retired registry ids, export and props out. It deliberately does not
block unrelated future chart controls: a returning summary section is a
product decision that should register its own primitive.

Contract prose: `frontend-primitives.md` (View-disclosure list, the
FilterToolbar rule, the registry paragraph, the FilterBar view-options
list), `performance-and-scalability.md` (the workload chart-visibility
sentence) and `unified-resources.md` rule 12 now say no chart show/hide
control exists instead of describing the deleted one.

Verification:
- frontend `src/features`, `src/components/shared`,
`src/components/Workloads`, `src/utils` suites on pulse-dev: 783 files
passed, one load-induced 5s timeout in `AgentsMachinesTable.test.tsx`
that passes alone (32/32) and does not reference these symbols; tsc,
prettier and the shared-template audit pass
- browser: production builds of this change and of main's three runtime
files, served against the 50-node mock rig, Proxmox and vSphere
overviews at 1440x900 and 390x844 (phone via Filters > View). The View
preferences disclosure shows identical headings, controls and pressed
states before and after, with no Summary heading, no Charts button, no
horizontal overflow and no console errors. The after bundle no longer
carries the `Show charts` copy.
2026-10-08 03:11:57 +01:00
rcourtman b9c98a4a16 Stop Patrol fetching the circuit-breaker status nobody reads (#2704)
The Patrol page fetched `GET /api/ai/circuit/status` on every load,
every 60 s poll, every return to the tab, and again in the background
after Retry. The store's breaker state had one production reader: the
`circuitBreakerStatus` memo in `usePatrolIntelligenceState`, which
nothing read and which #2695 removes. An open breaker already reaches
the page through `/api/ai/patrol/status` as the `circuit_open` blocked
cause, and the paused banner shows its reason.

## Change

- `stores/aiIntelligence.ts`: remove the breaker signal, getter,
`circuitBreakerStatusSignal`, `loadCircuitBreakerStatus()`, its
org-switch reset, and its `loadDashboardData()` entry.
- `features/patrol/usePatrolIntelligenceState.ts`: drop the read from
the Retry path's background supporting reads.
- `api/ai.ts`: remove `AIAPI.getCircuitBreakerStatus` and the
`CircuitBreakerStatus` type; nothing else used them. The backend route
stays.
- Tests: the client's unit tests and the store and page test mocks go.
The store bundle test still pins the exact AIAPI read set. The breaker
route stubs are removed from seven E2E specs.
- Contracts: patrol-intelligence, ai-runtime, api-contracts and
frontend-primitives say the browser reads no breaker status and how an
open breaker still reaches Patrol. Each note sits next to #2677's
correlation note.

## Evidence

- Normalized production-build compare: only the removed store loader,
signal, getter and client differ in `index.js`, plus the removed
supporting read in `AIIntelligence.js`. `check-bundle-size.mjs` passes.
- Private demo-mode stacks for the parent and this tree, driven by the
same Playwright script at 1440x900 and 390x844. Across Patrol load, tab
hide/show and stale-data Retry, the breaker read drops from 1-2 requests
per phase to none. Every other endpoint matches, the Open in Assistant
handoff is unchanged, and neither build logs a console error.
- A forced `circuit_open` Patrol status still shows "Patrol paused /
circuit breaker is open" on both builds.
- `tsc --noEmit`, eslint, and vitest for `src/stores`, `src/api`,
`src/features/patrol`, `src/components/AI`, the Patrol page test and the
boundaries test pass, as does the full pre-commit battery.

Stacked on #2695 (which stacks on #2687) with #2677 merged in, so until
those land this PR shows their commits too. The 2677 owner agreed this
read belongs in a follow-up. `git merge-tree` is clean against main,
#2677, #2690, #2691 and #2693.
2026-10-08 03:05:15 +01:00
rcourtman 533027ee06 Drop Patrol state members no surface reads (#2695)
`usePatrolIntelligenceState()` returned 18 members that no file outside
the hook reads. A TypeScript checker scan of src and tests found no
readers and no spread, computed or `Object.*` escapes of the state
object. Several were also dead inside the hook.

## Change

- **Return object.** Drops activePatrolFindings, activityRefreshTrigger,
acceptedManualRunId, assistantHandoffFindingId, circuitBreakerStatus,
fullModeUnlocked, initialSurfaceReady, isRefreshing, loadAllData,
openAssistantOperationsLoopForFinding, the four decision/outcome counts,
both work-evidence counts, recordPatrolWorkflowStarterActivity and
setFullModeUnlocked, plus handleAssistantFindingHandoff (see below).
- **Dead locals.** Removes the write-only signals (initialSurfaceReady,
isRefreshing, fullModeUnlocked, assistantHandoffFindingId), the 15 s
background refresh timeout that only reset isRefreshing, the unread
memos, and the never-called `openAssistantOperationsLoopForFinding`.
That last one was a second copy of the handoff `FindingsPanel` already
performs. Locals still used inside the hook (activePatrolFindings,
loadAllData, activityRefreshTrigger, acceptedManualRunId) stay.
- **Test-only exports.** `openPatrolAssistantWorkflowHandoff` and
`recordPatrolWorkflowStarterActivity` were used only by that dead path
and by their own tests, so they go.
- **Workspace.** `handleAssistantFindingHandoff` only wrote the unread
handoff signal, so `PatrolIntelligenceWorkspace` no longer passes
`FindingsPanel` an `onAssistantHandoff` callback. The prop is optional.
- **Tests.** Raw-source pins on removed code are dropped. The
manual-sync timeout test now pins the Retry path's own generation guard;
a mutation of its timeout fails it. The non-blocking starter test now
exercises `recordPatrolControlStarterActivity`.
- **Contracts.**
- patrol-intelligence said a finding-to-Assistant handoff *must* record
a `pulse_patrol` workflow starter. Only the dead path did that; the live
`FindingsPanel` handoff never has, so `pulse_patrol` has no browser
producer. The clause now says that and keeps the content-free rules as a
condition.
- The "local Patrol state must expose work evidence" sentence no longer
requires counts nothing reads.
- Three refresh clauses that described a header refresh action now name
the stale-data Retry, the only operator refresh left.
- api-contracts and frontend-primitives record that `FindingsPanel` owns
the handoff.

## Not in this PR

The store still fetches `GET /api/ai/circuit/status` for nothing. That
removal is a follow-up after #2677, because it edits the same lines.

## Evidence

- Production builds before and after compared with chunk hashes, SRI
strings and short identifiers normalised. Only the removed hook code
differs, the unused `pulse_patrol` constant drops out of `aiChat.js`,
and `index.js` reorders the same preload list. `check-bundle-size.mjs`
passes.
- Private demo-mode stacks for both builds, driven by the same
Playwright script at 1440x900 and 390x844: Patrol load, Activity >
Finding options and history, Open in Assistant (drawer opens with the
finding), tab hide/show, and the forced stale-data banner plus Retry.
Each phase hit the same `/api` endpoints on both builds, and neither
build had console errors.
- `tsc --noEmit`, eslint, and vitest for `src/features/patrol`,
`AIIntelligence.test.tsx`, `frontendResourceTypeBoundaries.test.ts` and
`FindingsPanel.test.ts` pass, as does the full pre-commit battery.

Stacked on #2687 (which carries #2682 and #2686). Until those merge,
this PR shows their commits too. `git merge-tree` is clean against main,
#2690, #2677 and #2691.
2026-10-08 03:01:08 +01:00
rcourtman 91dbc94b5f Colour pulse-relaxed guests by the thresholds their alerts use (#2735)
The `pulse-relaxed` Proxmox tag lifts a guest's CPU, memory and disk
alert triggers to a 95/92/95 floor (applied in
`effectiveAlertPolicyNoLock` since #2705), but the browser coloured bars
from the configured thresholds alone. A relaxed guest at 88% memory
showed a red bar while the alert engine stayed silent until 92%, and the
Alerts thresholds row kept showing 80/85/90 with no sign the tag raised
them. That is the gap a user described on #863 (comment of 2026-01-16):
the tags override the UI settings "but there's no visible indication in
the UI".

## What changes

- **Bars follow the tag.** `resolveMetricDisplayThresholds` takes the
tags the engine reads for a guest and applies the backend's
`applyRelaxedGuestThresholds` step before mapping to display thresholds:
unset gets the floor, a lower trigger is raised and keeps its clear
point (a missing clear is filled five below, as
`ensureHysteresisThreshold` does) unless the raised trigger reaches it,
and Off stays Off. A relaxed guest now turns red where its alert fires.
- **Only Proxmox guests.** `getWorkloadAlertPolicyTags` and
`useThresholdsGuestData` pass tags only for Proxmox VMs and LXCs, the
only guests whose tags reach alert policy. vSphere and TrueNAS VMs (also
`type: vm`) and containers keep their thresholds whatever they are
tagged.
- **The thresholds row says what is in effect.** A relaxed guest's
Alerts → Thresholds row (table and card) adds one line, "Proxmox tag
pulse-relaxed: alerts at CPU 95% · Memory 92% · Disk 95%", listing only
the thresholds the tag actually raises. It follows unsaved edits. The
cells keep the configured values the editor changes.

## Why the row and not the drawer or table

Following the user-lens procedure: the job is "I tagged this noisy VM
pulse-relaxed: is Pulse honouring it, and when will it alert?". Before
this change the bar answered it wrongly at zero clicks, and the only
surface that states thresholds (Alerts → Thresholds → Proxmox → VMs &
Containers, then search) stated the wrong numbers. No user doc describes
the tag, and GitHub comments give conflicting values (95/95/98 in #323,
95/92/95 in #863), so the row has to state the numbers. A badge in the
guest drawer or table row would fail the actionability test: once the
bar colour is right, the user has nothing to do there, and the place to
act is the thresholds row (or the tag in Proxmox).

## Verification

- Private mock stack from this branch with a dev-only overlay tagging
two demo profiles `pulse-relaxed` (68 VMs). Before the change: relaxed
`ci-runner-117` at 88% memory was red. After: every running relaxed row
matches the relaxed band (`analytics-batch-70` at 87% reads warning),
while untagged guests at 86–92% stay red, at desktop and 375px. The
thresholds row line was checked in the card layout, in the table layout
(full-width mode at 2560px), at 375px, and through an unsaved edit
(Memory 96 drops it from the line). Receipt:
`frontend-modern/browser-verification/1f9dcc1c17da6345.json`.
- Proof files: `metricThresholds.test.ts` (relaxed resolution, Off,
unset, clear filling at margins 5 and 10, scope gating),
`workloadTopology.test.ts` (Proxmox-only tags, legacy tag strings),
`ResourceTable.test.tsx` (desktop and mobile line, omitted metrics,
edits, disabled and untagged rows). `GuestRow.test.tsx` gets the new
export in its topology mock.
- On pulse-dev: tsc, `npm run lint` and the Alerts, Workloads, utils,
stores, platformPage, shared and `src/__tests__` suites. The broad run's
only failures were missing repo-root files from a partial sync and 5s
timeouts at load ~65; all 15 affected files pass when rerun with the
full tree.

## Known limits (existing, not widened here)

- The display resolver does not read filter-driven custom rules, which
the engine folds in before the override, so a guest a custom rule
matches can colour by thresholds the engine does not use. Custom rules
have no UI today. The alerts contract now says so.
- The Alerts page's guest list memo compares only id and name, so adding
or removing the tag in Proxmox shows on that page after a reload.
Workloads rows update live.
- The thresholds page reads an unset global default as Off
(`getTriggerValue`), so the line omits the floor the engine would give a
genuinely unset default. A save from the Alerts page always writes these
defaults.
- `pulse-no-alerts` and `pulse-monitor-only` still have no indication on
the row. They are tracked with the rest of the #863 comment in the
demand ledger.
2026-10-08 02:59:25 +01:00
rcourtman 1c6d2a0b2f Stop Storage scrolling back to open pools and reopening groups (#2733)
Follow-up to #2709.

## Problem
On Proxmox > Storage (pools view):
1. With a pool open, scrolling the page back to the top pulled it back
down to the open pool a second or two later, and kept doing so.
2. With `?group=node`, collapsing the open pool's group (Collapse button
or header click) bounced it straight back open.

## Causes and fixes
- **Bridge reveal re-ran on unrelated state.**
`useSummaryTableFocusBridge` called the consumer's `revealActiveSeries`
inside a tracked effect, so Storage's reads (the pool-to-group lookup
rebuilt on every live update, the expanded group set) re-ran the reveal
and its inline-detail scroll. The effect now depends only on a memo of
the focused series id and the table root, and runs the callback and
scroll untracked (`summaryTableFocus.ts`). Cleanup is registered before
the first attempt, so a focus change or unmount cancels pending settle
frames.
- **Expansion state reopened collapsed groups.**
`syncExpandedStorageGroups` re-added every group key missing from the
expanded set whenever live data rebuilt the groups, so any collapsed
group reopened within ~10 s. The bounce above had been hiding this.
`useStorageExpansionState` now stores only the collapsed groups and
derives the expanded set, so new groups still open by default. Group
keys are bare labels, so a grouping change clears the collapsed set.
That stops a label carrying collapse state into another grouping or
hiding the headerless `All` group.

## Evidence
12-node mock stack, Chromium at 1440x900 and 390x844, same stack swapped
between #2709's code and this change:

| Check | Before | After |
|---|---|---|
| App-shell scrollTop after scrolling away from an open pool (every 0.5
s, 12 s) | 0, 0, 1008, then 1111 ×21 | 0 ×24 |
| Collapse open pool's group via button, group state (0.5 s samples) |
open ×12 | shut ×32 (16 s) |
| Same via header click | open ×12 | shut ×32 (16 s) |
| Disks view / Workloads table scroll-away | 0 ×24 | 0 ×24 |

Unchanged: lower pool, disk and guest rows open their detail with the
row header and detail top in view; Escape closes it;
`?resource=ceph-rbd` opens in view; collapsing `shared` and then
following an in-app `?group=node&resource=ceph-rbd` link reopens the
group and shows the detail; a `?summaryGroup=` deep link scrolls to the
pinned header; regrouping node → type → none → node shows every pool.

Tests: new bridge tests (`summaryTableFocus.test.tsx`), Storage-level
live-update and collapse tests (`Storage.test.tsx`), expansion hook
tests, and a guardrail pin that the reveal runs untracked. All six new
tests fail on #2709's code. The three expansion tests also fail with the
new bridge but the old expansion state.

## Known limit
Filter changes do not re-run the reveal. A focused pool whose row stays
filtered out past the reveal's ~1.2 s retry window is not revealed when
the filter clears. Storage filters live in the URL and a storage link
without filter parameters resets them, so only a link carrying a filter
that excludes its own pool reaches this. Likewise, with a pool already
open and its group collapsed, an in-app link to that same pool leaves
the group collapsed, because focus does not change.
2026-10-08 02:58:15 +01:00
rcourtman 295edef4d5 Bind SAML logins to the browser that started them (#2729)
## Why

With `allowIdpInitiated` off, the default, every SAML login failed.
`SAMLService.ProcessResponse` passed crewjam/saml an empty list of
request IDs, and crewjam refuses any Response whose `InResponseTo` is
not in that list, so both SP-initiated and unsolicited Responses ended
in "Authentication failed". Only providers with IdP-initiated logins
enabled, which skips the check, could sign in.

## What changes

- `/api/saml/{id}/login` records each AuthnRequest ID, on the provider's
service, with the login's `returnTo` and the hash of a random token it
sets as the `HttpOnly`, `SameSite=Lax` cookie `pulse_saml_login`
(`__Host-pulse_saml_login` over HTTPS). A browser that already holds a
token keeps it, so logins started one after another in several tabs all
stay valid. Records last ten minutes, 1,024 per provider, oldest
evicted.
- The IdP posts its Response cross-site, and browsers withhold
`SameSite=Lax` cookies from cross-site POSTs. The ACS answers a
cookieless POST with a small page that re-posts the same fields once
from Pulse's own origin (no form action, so path prefixes survive; CSP
nonce on the script; a no-JS Continue button; a marker field stops a
second repost).
- `ProcessResponse` reads the answered request ID from the canonical
content goxmldsig verified for the signature covering the assertion
crewjam returns: the signed Response (or the Response in a signed
ArtifactResponse), otherwise that assertion's own
`SubjectConfirmationData`. It spends the record only when it is bound to
the presenting browser, and the login returns to the recorded `returnTo`
rather than the posted `RelayState`. A Response for another browser
leaves that browser's record intact.
- The signature verifier refuses signed content carrying a prefixed
attribute or namespace declaration named like an un-namespaced attribute
crewjam reads. encoding/xml fills such a field from any attribute with
that local name, and exclusive canonicalization leaves an unused
declaration out of the signed form. On the base commit, appending
`xmlns:IssueInstant` and `xmlns:NotOnOrAfter` to a two-hour-old signed
Response got it accepted, so with `allowIdpInitiated` on a captured
login could be replayed indefinitely.
- RelayState is now URL-escaped into the redirect, so a `returnTo` with
its own query string reaches the IdP whole.
- With `allowIdpInitiated` on, behaviour is otherwise unchanged: no
cookie or repost needed, posted `RelayState` honoured.
- The SLO LogoutRequest store from #2713 is generalised into a shared
record store; its `consume` semantics are unchanged.

Stacked on #2714 (on #2713, on #2707). Those three conflict with main in
`storage-recovery.md`, where their boundary notes and main's land at the
same anchor, so none can merge on its own. This branch carries a merge
of main that keeps both sets of notes; landing it lands the stack.

## Verification

- New tests in `internal/api/saml_handlers_more_test.go`,
`saml_service_test.go` and `audit_handlers_test.go`: default-settings
login through the repost, multiple tabs, refusal of another browser's,
unknown, unsolicited and replayed Responses (the refused browser's own
login still completes afterwards), the `xmlns:InResponseTo` spoof with
the Response signed and with only the Assertion signed, a
confirmation-less signed assertion spliced ahead of another browser's
assertion, assertion-only signed success, stale IdP-initiated Responses
with spoofed ages, repost escaping and CSP nonce, and the audit event
for a binding refusal.
`TestContract_SAMLManualIDPCertificateVerifiesIdPSignatures` now runs at
default settings instead of enabling IdP-initiated logins.
- Negative controls by `-overlay`: with the attribute refusal disabled
the stale-Response replay is accepted again; with the refusal disabled
and the binding read from crewjam's parsed fields the
`xmlns:InResponseTo` spoof logs in; the round-one binding code accepted
the spliced-assertion case as the other user.
- A real Chromium run against a test IdP on `127.0.0.1` and the SP
handlers on `localhost`, behind `SecurityHeadersWithConfig` in
production CSP mode: the IdP's POST arrived `Sec-Fetch-Site: cross-site`
without the login cookie, the repost arrived `same-origin` with it, and
the login landed signed in at
`/alerts?range=7d&saml=success&tab=history`.

## Known limits

- Records live in memory: a restart, a provider re-initialization, or a
login started on a host that does not share cookies with the configured
public URL leaves the Response unmatched, and the user signs in again.
- Two logins started at the same moment in a browser that holds no token
yet each mint one; the last cookie wins and the other login fails.
- As with OIDC login state, a sustained flood of unauthenticated login
starts can evict pending records.
- On plain HTTP, or HTTPS that Pulse does not recognise behind a proxy,
the cookie has no `__Host-` prefix, so a sibling subdomain could plant
one, the same exposure the session cookie has there.
2026-10-08 02:45:20 +01:00
rcourtman 8d6f489d3e Wrap long identity chips inside phone drawers (#2731)
Below the lg breakpoint, the inline drawer's Identity section rendered
alias and IP chips as hand-rolled nowrap spans inside a fixed-layout
table cell. Generated aliases such as `proxmox-lxc-docker:Production
West:pve1:113` or `k8s-production-1:deployment:redis-ffcd-b17c78f012`
are wider than that value cell on a phone, so the cell's hidden overflow
and the drawer's clipping content shell cut them off with no ellipsis.
The part that distinguishes two aliases is at the end, and a phone has
no hover to show the title.

- `MetadataBadge` (the contract owner of read-only chip whitespace
handling) gains an opt-in `wrap` mode: the chip is capped at its
container and breaks inside a token only where no space or hyphen fits.
Default stays nowrap; existing consumers get byte-identical classes.
- The drawer's IP, alias and alias-count chips in
`ResourceDetailSummary.tsx` compose `MetadataBadge`; identity values
wrap in full. Desktop drawers are unchanged (one line per alias).
- The shared template registry requires the consumer and forbids the old
hand-rolled chip classes there; the guardrail and unit tests cover the
wrap classes.
- `04-mobile.spec.ts` opens a Docker container drawer at phone width and
fails if any Identity-section element or text crosses the detail cell,
table wrapper or drawer shell (values that end in their own ellipsis
excepted).

Measured on a private mock estate at 390px: 53 cut chips across 92
Docker/Kubernetes/Proxmox/Machines drawers before, 0 identity chips past
the shell across 140 drawers at 390 and 768px after. Rebased on main
after #2703 (wider phone drawers): the new spec passes on the Pixel 5
and iPhone 12 projects and fails with the two runtime files reverted
(`proxmox-lxc-docker:...:113` still 71px past the cell).

Correlation-summary and redaction-label chips were checked and left
alone: redaction labels are a fixed five-word vocabulary, and the
correlation chips sit outside the nowrap table and break at spaces and
hyphens.
2026-10-08 02:37:34 +01:00
rcourtman 826252d274 Stop sending the ignored autonomous_mode chat field (#2730)
## Why

Since 4edf5f8265 (2026-07-11), `ChatRequest` in
`internal/api/ai_handler.go` has no `autonomous_mode` field. Go's
decoder drops it and `HandleChat` passes approval-required mode into the
chat service for every `/api/ai/chat` request (pinned by
`TestHandleChat_CannotElevateAutonomousModePerRequest`). The browser
still put `autonomous_mode` in the request body from its send options,
and the eval runner kept a per-step override that sent it too. Both read
as if the client chose the execution mode, but the server ignored them.

## What changes

- `AIChatAPI.chat` loses the `autonomousMode` positional parameter and
no longer writes `body.autonomous_mode`.
- `SendMessageOptions.autonomousMode`,
`ChatMessageRequestContext.autonomousMode`, and the clone, retry,
restore and Explain send-option copies are removed.
- `aiChatStore.context.autonomousMode` stays as a drawer-only flag.
Scoped handoffs still show "Chat: Ask first" and "Approval required
before any action." and hide the chat-actions warning.
- `internal/ai/eval` drops `Step.AutonomousMode`; the envelope test now
asserts the field is absent.
- `scripts/check-patrol-assistant-journey.mjs` asserts the Explain
request has no `autonomous_mode`.
- Contract prose (ai-runtime, api-contracts, agent-lifecycle,
storage-recovery, patrol-intelligence, frontend-primitives) no longer
describes `autonomous_mode:false` as a per-request override sent by the
browser. It now describes the server-set mode and the drawer disclosure.

No backend runtime change. The handler test proving a stale client's
`autonomous_mode:true` cannot elevate a request stays.

## Verification

- Private mock stack built from this branch, 1440x900 and 390x844. Every
`/api/ai/chat` body was captured with a page-level fetch wrapper.
- Plain chat with the saved level at Chat actions: body is `{prompt}`.
The pill and warning are unchanged.
- Patrol "Explain with Assistant": body keys are `handoff_context`,
`handoff_resources`, `prompt`. The drawer shows "Approval required
before any action." and "Chat: Ask first", with no warning.
- Follow-up turn: body keys are `model`, `prompt`, `session_id`, and the
disclosure persists.
- `go test ./internal/ai/eval/` passed.
- On pulse-dev: `tsc --noEmit` passed. `vitest` over
`src/components/AI`, `src/api/__tests__` and the aiChat store test
passed 2531 tests. In that parallel sweep, `AIChat.test.tsx` hit its 10s
`beforeAll` import timeout; run alone it passed 211/211.
- Read-only second-opinion review found no runtime regressions. It
flagged two contract sentences (undo/retry restoring an
"approval/autonomous override", and an overbroad "the body carries no
field"); both are fixed in this commit.
2026-10-08 02:34:34 +01:00
rcourtman 2d5ea5758b Name sensor setup notice nodes the way the nodes table does (#2727)
The outdated sensor setup notice on the Proxmox page named each node by
its Proxmox node name, while the nodes table directly below it, the
outdated agent notice beside it and Infrastructure settings label the
same node by its display name. With display names set, the notice read
"pve3 is using an older temperature monitoring setup..." above a table
with no row called pve3.

The notice now leads with the label the nodes table shows for the row
and adds the Proxmox node name in parentheses:

> West Production C (pve3) is using an older temperature monitoring
setup that cannot read SATA/SAS disk temperatures.

Both names are needed. The user has to match the notice to a table row,
and the nodes table switches to the Proxmox node name in its narrowest
layout (it showed `pve1`, `pve3` at a 375px viewport and display names
at 414px and up). They also have to find the host to re-run the setup
script on, which is the Proxmox node. Nodes without a display name read
as before ("pve3 is using ...").

The node name is left out only when the label already contains it as a
whole word, ignoring ASCII case, or as a domain's host part (`PVE1`,
`pve3.lan`, `Rack 4 (pve3)`). This is deliberately stricter than
`hasAlternateDisplayName`, which the guest table's group header uses.
That helper normalises punctuation, so it would show `PVE3` alone for
node `pve-3` and hide the host the user has to act on.

The notice's own test file is also registered as proof for the
frontend-primitives policy that covers the notice, matching the sibling
agent notice's test.

Verification:
- Unit tests cover the renamed case, labels that already contain the
node name, labels that only resemble it (`PVE3`/`pve-3`, `pve5-backup`,
`pve10`/`pve1`, a non-ASCII lookalike), blank names and
`nodeName`-before-`node` precedence. The platformPage, SharedPrimitives
guardrail, resource-boundary, nodes and Proxmox page contract suites
pass (396 tests), and eslint, prettier and tsc are clean.
- Browser pass on a private mock stack with three legacy-format nodes,
at 1440, 768, 414 and 375 wide on Overview and Storage. The notice reads
"Affected: Automation Lab Standalone (standalone2), West Production A
(pve1), West Production C (pve3)." with no overflow and no console
errors. Before control with main's two source files: "Affected: pve1,
pve3, standalone2."
2026-10-08 02:30:00 +01:00
rcourtman 19b963be71 Drop the dead recovery serializer and its stale contract prose (#2728)
The aggregate Recovery page (`frontend-modern/src/components/Recovery/`,
`features/recovery/useRecoverySurfaceState.ts`) was deleted on
2026-05-26 (66fb198cc6), but several contracts still described it as
live, and part of its plumbing survived.

**Code**
- Deletes the recovery query serializer from `routing/resourceLinks.ts`
(`RECOVERY_QUERY_PARAMS`, `parseRecoveryLinkSearch`,
`buildRecoveryRouteSearch`, `normalizeQueryBooleanFlag`). Nothing in
production called it: the Proxmox Backups tab owns its query through
`PROXMOX_BACKUPS_QUERY_PARAMS`, and the TrueNAS Protection tab keeps its
filters out of the URL.
- Deletes `utils/recoveryItemTypePresentation.ts`, whose only production
reader was that serializer, plus its tests and registry entries.

**Contracts** (frontend-primitives, storage-recovery, unified-resources,
alerts, cloud-paid)
- Passages naming the deleted shell, section owners, event-first
workspace, filter/column models, `view=inventory`/`view=events` route
state and the retired presenters now say what exists. Recovery rows
render in the Proxmox Backups and TrueNAS Protection tabs,
`useRecoveryPoints` is the only `/api/recovery/points` reader and
normalizes every response, and rules that only governed the deleted page
are gone or reduced to a reintroduction guard (storage-recovery
forbidden paths 9-11).
- `alerts.md` no longer claims `AlertResourceIncidentsPanel.tsx`
resolves chips through `buildResolvedResourceSurfaceLinks`. That helper
was deleted on 2026-05-16, and the panel imports nothing from
`resourceLinks.ts`.
- Leaves the embed paragraphs that rcourtman/Pulse#2726 rewrites
untouched. Both PRs merge cleanly together (checked with `git
merge-tree`, and the merged guardrails file passes).

**Proof**
- New assertions: no recovery-shaped export in `resourceLinks`;
`useRecoveryPoints` hands out normalized points (rendered hook with a
mocked transport) and is the only literal `/api/recovery/` reader
alongside `useProtectionPostures`; the retired hooks and helper stay
deleted; the Backups tab keeps day in the URL and range in component
state, with view tabs above the filter bar; the TrueNAS Protection table
keeps search, status and sort out of the URL; the incident panel imports
no route helper.
- Each assertion has a negative control that fails at the intended
assertion (15 in total). Targeted vitest, `tsc --noEmit`, eslint and
prettier pass on pulse-dev.
- Browser pass on a private Vite server from this tree at 1280x800 and
390x844. Covered `/proxmox/backups/date` (day selection, 1y range,
reload), `/proxmox/backups/coverage`, and `/truenas/protection` (filter,
row details, empty state). Receipt:
`frontend-modern/browser-verification/fe79952e5bb084d8.json`.
2026-10-08 02:23:18 +01:00
rcourtman d1ef487e09 Stop hidden tooltips and breakpoints listening to resize (#2724)
On `/proxmox` with 140 workload rows, 859 window `resize` listeners were
active after load (203 at phone width with 36 rows), so every window
resize ran one callback per listener:

- 699 from `useTooltipState.ts` (`useTooltipLayoutState`), which
registered a listener for every tooltip instance, including the hidden
`TooltipPortal` on each row's metric bars and sparklines.
- 156 from `useBreakpoint.ts`, where every call added its own listener
and width signal.

## Change

- Tooltip layout state listens only while its tooltip is visible and
re-reads the viewport when it opens. `useTooltipPortalState` uses the
same visibility gate as the portal's render (`when &&
supportsHoverTooltips()`), so a portal suppressed on a touch device
holds no listener either.
- `useBreakpoint` keeps one module-level width signal and shared memos,
fed by a single rAF-debounced listener while at least one caller is
mounted. A new caller syncs to the live `innerWidth`, so a caller
created after resizes nobody heard starts correct.

## Evidence

Live pass on a private mock-mode stack (headless Chromium,
add/removeEventListener wrapped to track live listeners by registering
frame):

| | before | after |
|---|---|---|
| 1440×900, 140 rows | 859 active, 859 callbacks per resize | 5 active,
5 callbacks |
| 390×844, 36 rows | 203 active | 5 active |

- An open row tooltip adds exactly one listener, stays open through
window resizes (its viewBox follows 1440×700 and 1380×640), and its
listener is removed on close.
- Crossing the phone breakpoint in both directions still switches the
workload table (`workload-table--desktop` ↔ `--mobile`) and each row's
layout.
- New tests: `useBreakpoint.test.ts` (one listener for 50 callers,
back-to-back resizes leave one pending frame, every caller crosses a
breakpoint, a new caller starts from the live width) and
`TooltipPortal.test.tsx` (hidden and touch-suppressed portals add no
listener, an open portal re-clamps on resize, a portal opened after an
unheard resize uses the live viewport). The listener assertions fail
against main.
- 198 frontend test files / 3620 tests pass across `components/shared`,
`components/Workloads`, the hooks, AppLayout, AIChat, Docker tables and
the boundary tests; prettier, eslint and tsc are clean.

Contract notes: `frontend-primitives.md` (tooltip owner) and
`performance-and-scalability.md` (new current-state section).
`useBreakpoint.ts` has no owning subsystem, so its rule lives in the
performance contract.
2026-10-08 02:23:06 +01:00
rcourtman 6d8dc0aa73 Correct stale platform-page embed contract prose (#2726)
Several v6 subsystem contracts described platform pages embedding
canonical surfaces through controls that no longer exist: a `tableOnly`
/ `embedded tableOnly` mode, a `showFilterToolbar` prop, a
`RecoverySurface`, `forcedPlatformFilter`, an embedded `StorageSurface`
at `/truenas/storage`, and a TrueNAS Protection tab built on a Recovery
surface. The aggregate Recovery surface was deleted on 2026-05-26
(66fb198cc6), and neither `WorkloadsSurface` nor `Storage` has an embed
mode.

What the code does, and what the contracts now say:

- **Workloads embeds** (`performance-and-scalability.md`): only the
Proxmox and vSphere Overview tabs embed `WorkloadsSurface`. Each page
builds its own `useWorkloadsState` with `forcedPlatform` and
`suppressPlatformFilter: true`, renders the one `WorkloadsFilter`
toolbar, and passes that state to the surface with
`suppressFilterToolbar`.
- **Platform-page rule** (`frontend-primitives.md`, with `cloud-paid.md`
now deferring to it instead of keeping a second copy): three tabs embed
a canonical surface (Proxmox and vSphere Overview, and Proxmox Storage
with `forcedSourceFilter`), and those tabs keep their rows on the
embeds. Every other tab renders a platform-owned table on the shared
platform-page primitives. The old "chrome-only, no per-family tables"
wording has not matched the code since the platform-first migration. The
bans on estate summary panels and forked table primitives stay.
- **Storage and recovery** (`storage-recovery.md`,
`unified-resources.md`): `/truenas/storage` renders
`TrueNASStorageTopologyTable`. Proxmox Backups reads `/api/backups/pve`,
`/api/backups/pbs` and the protection postures itself. TrueNAS
Protection feeds `TrueNASProtectionTable` from `useRecoveryPoints` with
the `truenas` platform forced.

Contract-only PRs never get their required checks, so a new case in
`platformOverviewLayout.guardrails.test.ts` pins these claims by parsing
the page surfaces with the TypeScript compiler API. It checks which
embeds each page has and which tab gates them, the platform lock on the
Workloads state and toolbar, `suppressFilterToolbar`, the source lock on
the Storage embed with no `forcedView`, the absence of an embed-mode
prop on either surface, and the wiring from the recovery hook to the
TrueNAS protection table. Twelve negative controls (removing the lock,
un-gating or moving a tab, re-adding the platform chip,
`suppressFilterToolbar={false}`, adding `forcedView` or a `tableOnly`
prop, embedding a `RecoverySurface`, emptying the protection points, and
others) each fail the case at the intended assertion. Two
behaviour-preserving edits still pass: a paren inside a comment and a
`>` inside a quoted attribute. The full guardrail file passes 19/19, and
the project typecheck and eslint are clean.

Out of scope here: the `storage-recovery.md` sections already rewritten
by #2718, and the wider prose about the deleted aggregate Recovery
page's internals (`components/Recovery/`, `useRecoverySurfaceState` as a
state owner, the `buildRecoveryRouteSearch` serializer paragraph). A
separate PR covers those, and the two merge cleanly in either order.
2026-10-08 02:20:07 +01:00
rcourtman b7bdf1b4eb Split a manually linked pair when the API unlinks it (#2725)
## Problem

`POST /api/resources/{id}/unlink` answered 200 "Resources unlinked" but
only recorded a `ResourceExclusion`. The manual link row stayed in
`resource_links`, and `ResourceRegistry.applyManualLinks` never consults
exclusions, so the pair stayed merged in the monitor's registry
rebuilds, the resources API and the websocket broadcast. Repro: link a
standalone agent into a VM, unlink it, then `GET /api/resources/<vm>`:
the agent facet is still there and the agent row is still gone.

Separately, the websocket broadcast coalesced host views with
`CoalescePresentationHostResources`, which applies no exclusions, while
REST's `ListForPresentation` applies the registry's, so a host pair the
operator split could be one broadcast row and two REST rows.

No frontend code calls `/api/resources/{id}/link` or `/unlink`
(`/api/agents/agent/unlink` is a different endpoint).

## Semantics

A resource pair carries one operator decision, and the later one
replaces the earlier: a link joins the pair, an exclusion (unlink,
report-merge) keeps it apart.

- Store writes: `AddExclusion` deletes the pair's link rows and
`AddLink` deletes its exclusion rows, in one transaction, matching both
ID orders; each also deletes a row of its own kind that canonical-ID
succession stored in the other order. The in-memory store does the same
and stamps `CreatedAt` like SQLite.
- Store reads: `GetLinks` / `GetExclusions` keep only each pair's latest
decision (`effectiveManualPairDecisions`), so rows written before this
change or rewritten onto one pair by succession resolve by their later
write; a tie keeps the pair apart.
- Registry: `loadOverrides` resolves its two reads together, so an
unlink committed between them cannot load both decisions into one
generation.
- Identity pins: while a Proxmox node was linked to an agent, the merged
resource's pin could record the node's endpoint hostname with the
agent's machine key. After unlink the node completed that key, minted
the agent's ID and folded into it by ID, a path no exclusion check sees.
A pin no longer completes the identity of a record the operator split
from the pinned resource.
- Broadcast: coalesces through `MonitorAdapter.CoalesceForPresentation`
on the read state that listed the rows when its registry is store-backed
(the store's adapter or a host-continuity overlay), else on the resource
store's adapter (the mock view lists through a store-less registry),
sharing `presentationExclusionFilter` with `ListForPresentation`.

In production REST seeds from the monitor's already-linked listing, so
an unlink shows on REST, `/api/state` and the websocket from the
monitor's next registry rebuild, the same generation in which a new link
reaches the broadcast.

## Known limits (unchanged, documented in the contract)

- Report-merge derives candidate IDs from the merged resource's type, so
it does not name the folded side of a cross-type manual link and cannot
undo one; unlink does.
- An agent's own declared node link (`resolveLinkedResource`) still
ignores exclusions.
- Presentation coalescing compares the IDs of the rows being merged, so
a third same-host fragment that merges first can carry an excluded pair
into one row (REST and broadcast now agree on this).

## Verification

- `TestResourceUnlinkSplitsManuallyLinkedPair` (new
`resources_unlink_test.go`) drives link, unlink and relink through the
API handlers against a `MonitorAdapter` on the same SQLite store,
rebuilt twice per step. It failed on main at "after unlink" exactly as
reported.
- `TestUnlinkSplitsHostPairWhosePinTookTheOtherSidesKey`: node/agent
pairs (IP and FQDN endpoints) through three rebuilds per step; both FQDN
cases fail with the pin guard removed.
- `TestManualPairDecisionReplacesThePreviousOne`,
`TestManualPairDecisionReadsResolveStoredConflictsByRecency`,
`TestRegistryResolvesDecisionsAcrossItsTwoReads`,
`TestMonitorAdapterCoalesceForPresentationHonoursExclusions`,
`TestBroadcastPresentationCoalesceHonoursMergeExclusions`,
`TestBroadcastPresentationCoalescePrefersTheListingReadState`.
- Negative controls (each fails with its fix reverted): store write
rule, newest-link-per-pair, registry combined resolution, pin guard,
broadcast exclusion coalesce, broadcast read-state preference,
store-less adapter fallback.
- Full `./internal/unifiedresources/...`,
`./internal/api/resourceapi/...` and `./internal/monitoring/...` pass.
- Live replay on private mock stacks built from main and from this
branch (`POST .../link`, `.../unlink`, `.../link` on a mock VM and a
standalone agent, then `GET /api/resources/{id}` for both). Main: after
unlink the VM still carries the agent facet and the agent row is still
404, unchanged 25 s later. This branch: after unlink the VM has no agent
facet and the agent row is back at once; relink folds it again.

Report-merge and agent-declared node links are left for separate
follow-ups.
2026-10-08 02:07:02 +01:00
rcourtman ce338b1496 Remove the unreachable Workloads group drawer and group pin (#2723)
## Why

`useWorkloadsState` defaulted `groupNodeDrawerMode` to `'inline'`, and
only that mode wired group-header hover preview, click-to-pin group
scope and an inline `NodeDrawer` under the group row. The only page that
used the default, the standalone `/workloads` page, was retired in May.
Both production mounts have passed `'disabled'` since host drawers moved
to each page's own hosts table: Proxmox in 84f627e22a (2026-05-18, "Move
Proxmox host drawers to the host table") and vSphere in d96d5612fd
(2026-05-23). The disabling was deliberate on both pages, so this
removes the dead path instead of re-enabling it. The demand ledger has
no entry asking for workload group hover or pin.

The only way left to reach the group pin was a hand-typed
`?summaryGroup=` URL. No link builder passes it, and row expansion
stopped writing it in 86ac84f68e.

## What changes

- `WorkloadPanel`: drops the inline `NodeDrawer` mount, the group hover
and pin handlers, `aria-expanded`, `data-summary-group-series-count`,
`data-summary-row-active` on group rows, and the group-member state
passed to `GuestRow`. Group rows stay identity-only dividers and keep
`data-summary-group-id`, so clicking one still leaves an open guest
drawer alone (`summaryTableFocus` ignores that selector).
- `useWorkloadSelectionState`: drops the hovered and focused group
scope, `summaryGroup` hydration and the related effects. It no longer
passes group options to the shared bridge; Storage still uses them.
- `useWorkloadsState`: drops the option, `getNodeTemperatureThresholds`
(it only fed the removed drawer), and the `summaryGroupScopes` memo,
which rebuilt a scope for every group on every filtered-guest change.
- Removes `buildWorkloadSummaryGroupScope(Map)`,
`workloadsHasVisibleWorkloadGroupScope`, the `GuestRow` group-member
attribute and the workloads `summaryGroup` route param.
- Proxmox and vSphere stop passing the option. Their filter bars'
pinned-selection state now reads only the selected guest.
- `NodeDrawer` stays live in `ProxmoxNodesTable`. The temperature
guardrail now pins that live mount instead of the dead one.
- Contracts: rule 8 and the Workloads, Proxmox and vSphere clauses now
describe static dividers. Stale sentences about grouped node rows
opening the node drawer, group-row emphasis, and Workloads selection
writing back to the route are corrected.

## Verification

- Before/after browser pass on pulse-dev: embedded builds on mock demo
stacks, Proxmox and vSphere overviews, at 1440x900 and 390x844.
  - Hover, click and tap on group dividers do nothing on both builds.
- Guest drawer, Escape, guest-row hover emphasis, `?resource=` links and
the Proxmox nodes-table `NodeDrawer` behave the same on both builds.
- Clicking a divider with a guest drawer open keeps the drawer: 10/10 on
each build.
- The hand-typed `?summaryGroup=` URL pinned 18 Proxmox rows on main and
pins none now.
- Receipt: `frontend-modern/browser-verification/db4ee39cb5db006c.json`.
- Frontend typecheck passes. The targeted suites (Workloads, proxmox,
vmware, platformPage, shared, routing, utils, hooks, Storage) pass on
pulse-dev except one Proxmox backups windowing test that failed once at
load ~40 and then passed 2/2 alone.
- A full-suite run on the earlier base passed except files that also
fail on unmodified main or only under load. `useReportingPanelState`
fails on main there too.
- Commit hook battery and governance audits pass.
- Two rounds of second-opinion review. Round 1 caught a stale test pin
and stale perf-contract prose; round 2 caught one inaccurate
scroll-restore sentence. All are fixed here.

Follow-ups raised separately: the built-in `WorkloadsSurface` filter
toolbar is also unreachable, since both mounts pass
`suppressFilterToolbar`. The quarantined E2E spec 48 still targets the
retired `/workloads` page.
2026-10-08 01:58:31 +01:00
rcourtman 64458fc13f Retire the summary hover E2E spec pinned to removed surfaces (#2722)
Deletes the quarantined
`tests/integration/tests/48-summary-hover-selection.spec.ts` and its
`QUARANTINED_SPECS` entry.

None of its nine tests could pass against the current app. Each one
opens `/workloads` or `/storage`, which now show Page Not Found, or
`/infrastructure`, which redirects to the first platform page. Each then
waits for a `workloads-summary`, `infrastructure-summary` or
`storage-summary` card that no longer renders.

The storage pool-group pin test was the only candidate for a port to
`/proxmox/storage?group=node`. Checked against the live page, a
group-header click there collapses the group as it pins it. In that run
the table went from 17 to 14 rows and showed 0 pinned member rows. The
test expects the row count unchanged and every member pinned. It also
asserts summary chart series counts, and those charts no longer exist.
The pin behaviour that still works (hover preview, `summaryGroup`,
pinned emphasis after expanding, Clear selection) is already covered in
`Storage.test.tsx`.

Tier ledger check (`check-e2e-tiering.mjs`, run on pulse-dev):
- origin/main: passes, 102 spec files, 3 quarantined
- this branch: passes, 101 spec files, 2 quarantined, with stable and
probation test counts unchanged (366 / 553)
- spec deleted but ledger entry kept: fails with `QUARANTINED_SPECS has
unknown specs: 48-summary-hover-selection.spec.ts`
2026-10-08 01:29:20 +01:00
rcourtman 860b5087d2 Merge origin/main into fix/link-fold-references
PR 2719 added per-side link pins beside the fold bookkeeping this
branch rewrote; keep both, and replace the contract bullet that said
folded IDs do not resolve.
2026-10-08 01:15:27 +01:00
rcourtman 776c2774ba Remove test-only Patrol autonomy save helpers
resolvePatrolAutonomyLevelForSave and resolvePatrolAutonomySettingsForSave
had no production caller; only their own unit tests imported them. Patrol
mode saves go through handleAutonomyChange and
acknowledgeAndActivateAutopilot, whose payload type (PatrolAutonomyUpdate)
has no full_mode_unlocked field, and both reconcile from
GET /api/ai/patrol/autonomy afterwards. Rollup already dropped both
helpers, so the production build is byte-identical.

Several contract sentences described the helpers' model rather than the
live path: the browser clearing stale full-mode unlock state before a
monitor-only save, sending full_mode_unlocked:false when locked, and
sending full_mode_unlocked:true for Autopilot. The API does that work.
The monitor-only handler and the paid SaveAutonomySettings adapter clear
the unlock and the Autopilot activation on every save below Autopilot,
and the autonomy gate derives the compatibility flag from a recorded
acknowledgement. Rewrite those sentences in patrol-intelligence,
api-contracts, frontend-primitives and ai-runtime to say what the header,
the state owner and the API each do. Two neighbouring api-contracts
sentences were also wrong about the same save: a `full` request without
a current acknowledgement gets 409 acknowledgement_required from the gate
before any licence check, and the Patrol mode dialog they name was
removed in 27ba85ddc6.

Replace the deleted helper tests with tests of the live locked path. The
page test loads stale paid state (Safe auto-fix plus the compatibility
unlock) under a runtime lock and checks that the header presents Watch
only, that the paid choices are disabled and save nothing, and that
Watch only sends exactly autonomy_level, investigation_budget and
investigation_timeout_sec. A source pin keeps the state owner's own lock
guards, which the disabled buttons otherwise hide from the page test.
2026-10-08 01:10:22 +01:00
rcourtman 6dea7101a7 Split operator-linked resources when a merge is reported
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.
2026-10-08 01:06:07 +01:00
rcourtman 544588b117 Merge remote-tracking branch 'origin/main' into fix/drop-unread-circuit-breaker-read 2026-10-08 01:00:44 +01:00
rcourtman 799c92b9d6 Record the browser pass for the merged circuit-breaker removal
Merging the repaired state-members branch, which now carries current
main, left the Patrol hook with content neither parent has, so it needs a
receipt bound to that content. Builds of that branch head and of this
merged tree ran on pulse-dev on private demo stacks: the breaker read
drops to none in every phase, while the Patrol page, Open in Assistant,
the stale-data Retry and the circuit-open paused banner behave the same
at 1440 and 390 px, with no console errors.
2026-10-08 00:59:35 +01:00
rcourtman c99028bc82 Stop Workloads row focus staging a stale shell scroll restore
Selecting a Workloads row still staged the app-shell scroll position for
App.tsx to restore. That is left over from when row focus wrote ?resource=
into the URL, which 86ac84f68e removed. Selection no longer navigates, so
the staged position waited for the next unrelated route change. The
same-path route-state scheduler only re-stages from a scrolled shell. So
after opening a guest drawer deep in the Proxmox overview, scrolling back
to the top and picking a Type filter jumped the page back down to that
row. This reproduced on a mock stack at 1440x900 and 390x844; the shell
went from 0 to 3000 about 100 ms after the URL changed.

Row focus already holds its position through the contextual-focus offset
preservation. Drop the capture, and with it the helper that has no callers
left and its coverage test. Add a regression test that fails on the old
hook, and correct the contract prose that still described the old
handoff.
2026-10-08 00:57:13 +01:00
rcourtman 39b6007f5d Pin each manual-link side from its own identity (#2719)
Stacked on #2715 (its commit is this branch's parent; whichever lands
first, the other still merges).

## Problem

A manual link merges two resources for display, but
`PersistIdentityPins` pinned the merged identity. A link primary was
pinned with the folded resource's machine key, and the store's
one-owner-per-key rule deleted the folded resource's own pin.

For a Proxmox node with a qualified endpoint
(`https://pve1.example.test:8006`) linked to a host agent, the node's
pin records the endpoint, so the next rebuild completed the agent's
machine key from it, minted the agent's machine-derived ID and merged
the two by ID before the link applied. With the node as primary,
succession then rewrote the link into a self-link on the agent's ID; in
either direction, removing the link left the pair merged. Reproduced
through `MonitorAdapter.PopulateSnapshotAndSupplemental` with a
`MemoryStore` on #2715's head.

## Change

- `applyManualLinks` captures each side's own pin before the merge
(`recordLinkOwnPin`, first capture per rebuild wins), and
`PersistIdentityPins` writes those instead of the merged projection,
keeping a pinnable folded side pinned under its own canonical ID.
- Link sides write through a new
`ResourceStore.ReplaceResourceIdentityPins` (both stores). Like an
upsert the row keeps fields the side's own pin leaves empty, but it
drops a strong key another resource holds this rebuild, which is how a
key an earlier release borrowed from the merged identity leaves the row.
- A folded side drops a key a live resource or an earlier folded side
already holds instead of trading it every rebuild, and writes no pin
when that would cost it its machine evidence (a cluster-only row would
be open to cluster-slot succession).
- Proxmox auto-links (LinkedAgentID / identity matcher) merge before any
manual link applies, so their boot-window pins are unchanged
(`TestIssue1753*`, `TestMonitorAdapterRebuildPersistsIdentityPins`
pass).

Known limits, written into the contract: a node row an earlier release
already filled with the agent's key still merges the pair by ID while
the node can complete from it; two live resources sharing a strong key
still alternate, as on main; evidence a later record pass of the same
rebuild merges into an already-joined row reaches neither side's pin.

## Verification

- New tests in `registry_merge_policy_test.go` (standalone and clustered
node, both link directions, three rebuilds with no steady-state pin
writes, then unlink separates the pair; shared-key, kept-field,
hostname-guard, machine-loss and borrowed-key cases) and `store_test.go`
(replace vs upsert on memory and SQLite).
- Each new test fails against #2715's runtime files via `go test
-overlay`; each guard rule fails its own test when removed.
- `internal/unifiedresources` full package, `internal/monitoring`
link/identity tests, `internal/api/resourceapi`, `go build ./...` and
`go vet` pass.
- Contract: `unified-resources.md` clause 25 and "Operator links reach
record-ingested resources".
2026-10-08 00:45:22 +01:00
rcourtman bb35a5c006 Correct stale Storage embed and History range contract prose (#2718)
Three contract passages described toolbar controls that no longer exist.

**storage-recovery.md (StorageSurface embed).** The contract said
platform pages opt into the Storage toolbar through a
`showFilterToolbar` prop on `StorageProps`, keep `tableOnly` to hide a
storage summary section, and get a chart-collapse control. It also
mentioned `RecoverySurface`, `forcedPlatformFilter`, and an embedded
`StorageSurface` at `/truenas/storage`. On main:
- `Storage.tsx` always renders `StoragePageControls`; only kiosk mode
hides it.
- There is no summary section and no chart-collapse control.
- The Proxmox Storage tab is the only embed. It passes
`forcedSourceFilter`, `resourceSource`, `suppressNodeFilter`,
`filterAriaLabel` and search copy.
- `/truenas/storage` renders `TrueNASStorageTopologyTable`.

The rewrite describes what `Storage.tsx`, `StoragePageControls.tsx` and
`useStoragePageFilters.ts` actually do:
- The forced source is read as the route source and never written back
to the URL.
- The Source chip is auto-suppressed and does not surface the clear
action.
- `suppressNodeFilter` drops the Node chip and resets a selected node to
all nodes.

**performance-and-scalability.md and frontend-primitives.md (Workloads
History range).** Both said the sparkline range shows only while Trends
is active and carries a `Trend range` label. `WorkloadsFilter` actually
shows it in Trends, and in Bars at desktop widths while Row hover is
`History`. Its label is `History`.

**Test.** A new rendered test in `Storage.test.tsx` pins the embed
behaviour the rewrite claims. Five negative controls each fail it:
- dropping the forced-source suppression
- dropping the node reset
- unlocking the route source
- gating the toolbar off
- keeping the Node filter

The `History` label was already pinned by `WorkloadsFilter.test.tsx` and
`WorkloadsSurface.performance.contract.test.tsx`. Another stale test
title, "table-only embedding", is renamed.

Sibling stale passages are left for a follow-up: `embedded tableOnly` in
frontend-primitives.md and cloud-paid.md, the WorkloadsSurface
`showFilterToolbar` text in performance-and-scalability.md, and the
TrueNAS StorageSurface line in unified-resources.md.
2026-10-08 00:44:18 +01:00
rcourtman 1b01ab6e1b Merge branch 'refactor/drop-unread-patrol-state-members' into fix/drop-unread-circuit-breaker-read 2026-10-08 00:36:23 +01:00
rcourtman aac5ca5fc2 Colour pulse-relaxed guests by the thresholds their alerts use
The pulse-relaxed Proxmox tag lifts a guest's CPU, memory and disk alert
triggers to a 95/92/95 floor, but the browser coloured bars from the
configured thresholds alone. A relaxed guest at 88% memory showed a red
bar while the alert engine stayed silent until 92%, and its Alerts
thresholds row kept showing 80/85/90 with no sign that the tag raised
them (issue #863, comment of 2026-01-16).

- resolveMetricDisplayThresholds takes the tags the engine reads for a
  guest and applies the backend's applyRelaxedGuestThresholds step:
  unset gets the floor, a lower trigger is raised and keeps its clear
  point unless the raised trigger reaches it, and Off stays Off.
- getWorkloadAlertPolicyTags passes tags only for Proxmox VMs and LXCs,
  the only guests whose tags reach alert policy. vSphere and TrueNAS
  VMs and containers keep their thresholds whatever they are tagged.
- A relaxed guest's Alerts thresholds row and phone card add one line
  naming the tag and the thresholds it raises. It follows unsaved edits,
  while the cells keep the configured values the editor changes.
2026-10-08 00:35:28 +01:00
rcourtman 32fad38aa4 Merge remote-tracking branch 'origin/main' into refactor/drop-unread-patrol-state-members 2026-10-08 00:34:24 +01:00
rcourtman 25fba3bf82 Record the browser pass for the merged Patrol state hook
Merging main brought in its own removals of summaryStats and the
correlation supporting read, so the hook's final content matches neither
parent and needs a receipt bound to that content. Builds of origin/main
and of this merged tree ran on pulse-dev on private demo stacks: the
Patrol page, Open in Assistant, tab hide/show and the stale-data banner
with Retry behave the same on both at 1440 and 390 px, with the same
endpoint set and no console errors.
2026-10-08 00:33:25 +01:00
rcourtman 199f45e211 Merge origin/main into refactor/drop-chart-visibility-toggle 2026-10-08 00:27:26 +01:00
rcourtman 04f61d431f Remove the unreachable Workloads chart visibility toggle
WorkloadsFilter rendered ChartVisibilityToggleButton only when a surface
passed onChartsToggle, and none has since the workload summary chart
section was deleted with the summary components. The button had no other
production consumer, so the shared primitive, its two shared-template
registry entries, the filter-model props and the tests that pinned them
described a Show/Hide charts control that nobody could reach.

Delete them. The guardrail that required the primitive now keeps the
exact retired registry ids, export and props out: a returning summary
section is a product decision that should register its own control.
Contract prose in frontend-primitives, performance-and-scalability and
unified-resources now says no chart show/hide control exists instead of
describing the deleted one.

No visible change: the Workloads View disclosure on the Proxmox and
vSphere overviews shows the same sections and controls before and after,
at desktop and phone widths.
2026-10-08 00:24:23 +01:00
rcourtman 4e95ca45cb Stop Storage scrolling back to open pools and reopening groups
On the Storage pools view, an open pool pulled the page back to itself a
second or two after the operator scrolled away, and collapsing the open
pool's group bounced it straight back open. The summary focus bridge ran
the consumer's revealActiveSeries callback inside a tracked effect, so
everything Storage's callback reads (the pool-to-group lookup that every
live update rebuilds, the expanded group set) re-ran the reveal and its
inline-detail scroll. The reveal now subscribes only to the focused
series id and the table root, and runs the callback and scroll
untracked, so it runs once per deliberate focus change. Its cleanup is
registered up front, so a focus change or unmount also cancels pending
settle frames.

Fixing that exposed a second cause of the bounce: the expansion state
re-added every group key missing from its expanded set whenever a live
update rebuilt the groups, so any collapsed group reopened within about
ten seconds. It now stores only the groups the operator collapsed and
derives the expanded set, so new groups still open by default. Group keys
are bare labels, so a grouping change clears the collapsed set rather than
carrying collapse state into another grouping or hiding the headerless
"All" group.

Measured on a 12-node mock stack: after scrolling away from an open pool
the shell stays at scrollTop 0 for 12 s (was 0 -> 1111 within ~1.5 s),
and a group collapsed by its button or header stays collapsed for 16 s.
The new bridge, Storage and expansion tests fail on the previous code.
Filter changes do not re-run the reveal either, so a focused pool whose
row stays filtered out past the reveal's retry window is not revealed
when the filter clears; storage links reset the URL-owned filters, so
only a link that carries an excluding filter gets there.
2026-10-08 00:23:41 +01:00
rcourtman 1eb4a84fe6 Drop the dead recovery serializer and its stale contract prose
The aggregate Recovery page and its route-state owner were deleted on
2026-05-26, but RECOVERY_QUERY_PARAMS, parseRecoveryLinkSearch and
buildRecoveryRouteSearch stayed in resourceLinks.ts with no production
caller, which kept recoveryItemTypePresentation.ts alive as their only
reader. Both go.

The frontend-primitives, storage-recovery, unified-resources, alerts and
cloud-paid contracts still described the deleted page as live: its shell,
event-first workspace, section owners, filter and column models, recovery
route state, and the incident panel's link chips. Those passages now say
what exists. Recovery rows render in the Proxmox Backups and TrueNAS
Protection tabs, useRecoveryPoints decodes every recovery-points read, and
rules that only governed the deleted page are gone or reduced to a
reintroduction guard.

New assertions pin the rewritten claims: resourceLinks exports nothing
recovery-shaped, the recovery-points hook hands out normalized points and
is the only reader, the retired hooks stay deleted, each tab keeps its own
view and filter state, and the incident panel imports no route helpers.
2026-10-08 00:17:30 +01:00
rcourtman d95a316118 Stop sending the ignored autonomous_mode chat field
Since 4edf5f8265, ChatRequest in internal/api/ai_handler.go has no
autonomous_mode field: Go's decoder drops it and HandleChat passes
approval-required mode into the chat service for every /api/ai/chat
request. The browser still serialized autonomous_mode from its send
options, and the eval runner kept a per-step override that sent it too,
so both read as if the client chose the execution mode while the server
ignored it.

Drop the field from AIChatAPI.chat, SendMessageOptions,
ChatMessageRequestContext and the clone, retry and restore helpers, and
Step.AutonomousMode from internal/ai/eval. aiChatStore.context's
autonomousMode stays as a drawer-only flag, so scoped handoffs still show
"Ask first" and "Approval required before any action." Tests now assert
the request carries no autonomous_mode; the backend test proving a stale
client's autonomous_mode:true cannot elevate a request stays.

Contract prose that described autonomous_mode:false as a per-request
override set by the browser now describes the server-set mode and the
drawer disclosure.
2026-10-08 00:17:08 +01:00