Commit Graph

2726 Commits

Author SHA1 Message Date
rcourtman add1096ec8 Cache Patrol preflight outcome and hydrate UI on settings load
The Verify Patrol button reset its result to empty on every
page load — the operator had to re-click to see the verified
state, even though nothing had changed. This commit adds the
observability layer of the auto-preflight plan: every
RunPatrolToolPreflight result is now cached on the AI Service
and surfaced through /api/settings/ai as patrol_preflight, so
the inline result panel rehydrates on page load with the
most-recent outcome and a "last verified Xs ago" indicator.

Backend: patrolPreflightCache (mutex-guarded) on Service with
defensive-copy CachedPatrolPreflight() accessor; every
RunPatrolToolPreflight branch (success, soft warning, classified
failure, validation early-return) records into the cache.
PatrolPreflightSnapshot projects the cached result onto the
AI settings response. Tests cover both success-then-failure
supersession and the defensive-copy invariant.

Frontend: PatrolPreflightSnapshot type mirrors the wire shape;
hydratePatrolPreflightFromSettings(data) projects the snapshot
into the same response shape the manual button writes;
loadSettings and updateSettings flows call it. The result
panel renders a "last verified Xs ago" line under the
provider/model row when recorded_at_unix is present.

End-to-end smoke verified against deepseek-v4-flash: panel
rehydrates as green "Tool calling verified · last verified
just now" after page reload.

Auto-preflight on save (the trigger half of the resilience
plan) follows in the next commit.

Contracts: ai-runtime, api-contracts, agent-lifecycle (dep),
storage-recovery (dep), frontend-primitives all updated to
reflect the new patrol_preflight surface and hydration
contract. Verification artifacts: settingsArchitecture +
patrolPreflight client tests.
2026-05-10 14:58:26 +01:00
rcourtman 404f87854e Pin cross-org and cross-resource isolation on the bundle's pending approvals
The AgentApprovalsProvider closure in router.go applied the
BelongsToOrg and CanonicalResourceID filters inline, which made
the substrate's tenant-isolation property impossible to test
without booting the full router. Drift in the closure (e.g.
swapping BelongsToOrg for a hardcoded "default" or dropping the
resource-id check) would let an agent with one org's token see
approvals targeting another org's infrastructure, but no test
sat right next to that logic to catch it.

Extracts the body into a named function in agent_resource_context.go
(pendingApprovalsForResourceFromStore) behind a minimal
approvalsPendingProvider interface. The closure in router.go
now delegates to it. Four unit tests pin the substrate's
isolation property:

  - FiltersByOrg: same resource id, two orgs, each query returns
    only its own org's approval.
  - FiltersByResource: same org, two resource ids, each query
    returns only its own resource's approval.
  - LegacyEmptyOrgIsDefaultOnly: approvals without OrgID are
    treated as default-org per BelongsToOrg's documented
    semantics; legacy approvals do not leak into a non-default
    org's bundle.
  - EmptyInputsReturnNil: defensive shape on nil store, empty
    resource id, and empty store.

The existing TestContract_AgentResourceContextWiresApprovalsProvider
pin is updated to follow the extraction. Both halves of the
wire-up are now pinned: router.go installs the closure with the
correct delegation, and agent_resource_context.go owns the
filter logic with both safety checks present.

This is the test the substrate was missing: nothing else proved
that an agent with one org's token cannot see another org's
pending approvals at the bundle layer.

Contract-neutral commit: no wire shape, manifest entry, or error
code changed. The refactor preserves identical behaviour;
PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT is set with a documented
reason since three of the four contract docs the canonical-shape
guard would normally demand are actively mid-edit by another
agent on patrol-preflight work, and trampling them would create
a collision the protocol explicitly forbids.
2026-05-10 14:38:10 +01:00
rcourtman e26a57a157 Add POST /api/ai/patrol/preflight tool-call verification
The existing per-provider /api/ai/test endpoints only call
ListModels — they pass for every provider that returns a
catalog, even when Patrol fails 100% of runs because tools
aren't actually wired up. That gap is what let the DeepSeek
tool_choice rejection silently fail Patrol for 33 days
before the recent fix landed.

POST /api/ai/patrol/preflight runs a one-shot tool-call
round-trip with the configured (or overridden) Patrol
provider+model and a minimal verify_pulse_patrol tool.
Failures route through ClassifyPatrolRuntimeFailure so the
new tool_choice_rejected and no_tool_capable_endpoint causes
surface here too. A successful provider call where the model
returned plain text (no tool call) is reported as a soft
warning (model_tool_support_unverified): Patrol may still
work but the operator should run a real pass to confirm.

The endpoint bypasses the chat service so cost recording
isn't charged for verification, and uses ScopeSettingsWrite
to align with the existing /api/ai/test gating.

Backend + typed frontend client (runPatrolPreflight); UI
button on Assistant & Patrol settings follows.

Contracts updated:
- ai-runtime: completion obligation extended to cover the
  new verification surface
- api-contracts: payload shape (tool_call_observed,
  duration_ms) noted in obligations
- agent-lifecycle, storage-recovery: dependent-extension
  acknowledgment that ai-runtime owns the new route despite
  it living under internal/api/
2026-05-10 14:30:41 +01:00
rcourtman f2d9d2aba8 Split overgreedy "tools not supported" classifier into three causes
The Patrol runtime classifier collapsed three distinct upstream
conditions into one misleading "Selected model does not support
Patrol tools" message:

  1. Provider rejected the *value* Pulse sent for tool selection
     (e.g. DeepSeek's "deepseek-reasoner does not support this
     tool_choice" — the model accepts tools, just not the forced
     coercion). The DeepSeek fix in 46145df9 dodges the symptom by
     coercing to auto, but the original misclassification pointed
     operators at the wrong remediation for 33 days.
  2. Provider has no tool-capable endpoint available for the
     selected model (OpenRouter's "No endpoints found …" surfaces
     this when account-level provider/data filters exclude every
     tool-capable route).
  3. Model truly lacks tool calling (the literal "tools are not
     supported" / "tool calling" cases).

Each now has its own PatrolFailureCause, title, summary,
description, and recommendation. summarizePatrolRuntimeFailureDetail
mirrors the split. Helper predicates patrolToolChoiceValueRejected
and patrolNoToolCapableEndpoint encapsulate the substring matching.

The OpenRouter "No endpoints found" test fixture now correctly
classifies as no_tool_capable_endpoint instead of
model_unsupported_tools — fixture updates in
patrol_runtime_failure_test.go, patrol_assistant_handoff_test.go,
and ai_handler_test.go reflect the more accurate diagnostic.
New tests cover the tool_choice_rejected and generic
model_unsupported_tools paths explicitly.

The ai-runtime contract is updated to note the classifier-split
obligation alongside the existing transport-shape obligation.
2026-05-10 14:10:18 +01:00
rcourtman 46145df925 Coerce DeepSeek tool_choice to "auto" so Patrol stops failing
DeepSeek's API server-side aliases deepseek-v4-flash and
deepseek-v4-pro to deepseek-reasoner, which rejects forced
tool_choice with HTTP 400 ("deepseek-reasoner does not support
this tool_choice"). Pulse's classifier then surfaced this as
"Selected model does not support Patrol tools," misdirecting
diagnosis to the model rather than the request shape.

supportsForcedToolChoice now returns false for any DeepSeek
client, so every DeepSeek model falls back to tool_choice
"auto" regardless of how DeepSeek routes the requested ID.
The ai-runtime contract is updated to match: the
provider-transport boundary now coerces forced tool_choice for
every direct DeepSeek model ID, not only unknown ones.

Patrol verified end-to-end: 20 tool calls, 9 findings, prior
runtime failure auto-resolved.
2026-05-10 00:04:13 +01:00
rcourtman 2f0468a87b Verify SSHSIG on in-app update artifacts
The unattended timer (scripts/pulse-auto-update.sh) and the public bootstrap
(scripts/install.sh, /install.sh) all verify the .sshsig sidecar against the
pinned pulse-installer ed25519 key before trusting a release artifact. The
in-app updater verified SHA256 only — same artifact, same root execution
context, lower trust bar. Closing the asymmetry: the in-app tarball download
in ApplyUpdate, adapter_installsh.go's install.sh download (piped into bash
as root), and the rollback binary download now fetch and verify the .sshsig
sidecar against the same pinned key, fail-closed.

The signing infrastructure (release_asset_common.sh, validate-release.sh,
backfill-release-assets.sh) already produces and validates these signatures
for every release; this teaches the Go updater to honor what the shell paths
have always required. ssh-keygen is shelled out to so the in-app updater
shares the exact trust path used by the unattended path, with a package-level
function variable for test injection so unit tests don't require ssh-keygen
on the build host.

Extends the deployment-installability contract's release-trust-fail-closed
invariant to cover the in-app updater paths.
2026-05-09 23:14:07 +01:00
rcourtman eeb2975d22 Stability sweep on the agent-substrate arc
Three things landed:

1. /api/agent/capabilities was missing from publicPathsAllowlist
   in router_public_paths_inventory_test.go. Slice 47 added the
   path to publicPaths in router.go and to publicRouteAllowlist
   in route_inventory_test.go but missed this second mirror,
   which scans publicPaths via go/ast. The test was failing on
   origin; this commit closes the gap.

2. The error-envelope paragraph in api-contracts.md now
   distinguishes capability-specific stable codes (the closed
   set declared per capability in the manifest) from
   cross-cutting codes the multi-tenant / auth middleware
   emits universally (invalid_org, org_suspended, access_denied).
   The previous wording implied all stable codes lived in
   per-capability errorCodes lists, which would have forced
   duplication on every capability or misled agents about which
   codes to expect.

3. New contract pin TestContract_AgentSurfaceErrorCodesMatch-
   ManifestDeclarations enforces the symmetry both directions:
   every code emitted by an agent-surface handler must be either
   declared in the matching capability or be one of the three
   cross-cutting codes; every manifest-declared code must have a
   matching emission. Drift either way is a contract regression.
   Pin verified clean against the current handler set.

Stale forward-reference fixed: the capabilities paragraph no
longer says "future MCP-server slices read the manifest" — slice
51 already shipped that adapter.

Sweep also surfaced two failures in internal/mock/ from
unrelated platform-support drift (unraid token set added in
ac82a2852 but the mock contract test wasn't updated). Those are
not part of the agent-substrate arc and not mine to fix; flagged
in the closing summary so they don't get lost.
2026-05-09 23:04:22 +01:00
rcourtman 8aa22d0605 Surface action verification on the action.completed SSE payload
Closes the certainty loop for agents watching the substrate's push
channel. The action audit's read-after-write probe outcome was
already persisted on the audit record, but agents watching
action.completed only learned "the action ran" — they had to fetch
/api/actions/{id} to know whether the read-back probe confirmed
the intended state. That defeated the substrate's
push-notification guarantee for dispatch certainty.

The new agent-stable AgentResourceActionVerification projection
(ran, success, command, note, ranAt — output stays in the audit
record, deliberately omitted from events to keep payloads small)
is now carried on both:

  - the action.completed SSE payload, projected from
    record.Result.Verification by the router-side bridge in
    wireAIChatDependenciesForService, and
  - the resource-context bundle's recentActions surface, via the
    same shared projectAgentResourceVerification helper

so the bundle (depth) and the doorbell (push) speak the same
vocabulary. Refused-before-dispatch failures omit verification
(the probe never runs) so agents branch on field presence to
distinguish "no probe attempted" from "probe ran with empty
result". Three contract pins lock the symmetry: payload field
present, router bridge populates it, bundle parallels.

The capabilities manifest's subscribe_events description now
mentions the verification block so external agents discover the
field through the same path they already use to learn the rest
of the agent surface.
2026-05-09 22:45:15 +01:00
rcourtman 5156c03eed End-to-end test the operator-state write loop through HTTP
Closes the e2e contract proof on the write side. The only write
capability the manifest declares is the operator-state intent
loop (set / get / clear), and this test boots the full router
stack to walk every state of that loop through the actual HTTP
boundary — proving the manifest's declared error codes for
set_operator_state and get_operator_state reach the wire from
the handlers, the URL canonical id authoritatively wins over
body-supplied ids (no scope-confusion writes), and SetAt/SetBy
are server-populated so attribution cannot be spoofed.

The flow exercised:
  GET unset → 404 operator_state_not_set
  PUT valid → 200 with persisted state + server SetAt
  GET → round-trips
  PUT invalid criticality → 400 operator_state_invalid
  DELETE → 204
  GET → 404 operator_state_not_set (loop closed)
  DELETE again → 204 (idempotent)

Two contract pins lock the audit-honesty and error-token
contracts so a future refactor of the handler can't silently
regress either: SetAt/SetBy populated server-side, URL-id wins
over body-id, and the validator's domain error maps to the
stable wire token via errors.Is rather than message-matching.

Together with the read-side e2e (slice 47), the agent surface —
read, write, push — has now been exercised end-to-end as one
substrate.
2026-05-09 22:22:47 +01:00
rcourtman 8cf15fe639 End-to-end test the agent substrate's discovery → triage → depth flow
The unit tests cover each piece in isolation; this test boots the
full router stack and proves the discovery → triage → depth chain
works as one substrate through the actual HTTP boundary an
external agent would hit. It found two real bugs slice 40
introduced and slice 45/46 didn't surface:

- /api/agent/capabilities was documented as unauthenticated but
  was missing from the router's publicPaths list, so the global
  auth middleware was 401'ing the discovery manifest. Fixed by
  adding the path to publicPaths and pinning the contract so it
  cannot regress.

- The error-envelope shape across the agent surface is
  {"error": "<stable_code>", "message": "<human>"}, written via
  writeJSONError — not the {"code": ...} shape I had assumed in
  the docs. Pinned the wire shape on api-contracts.md so the
  documented error contract matches what writeJSONError actually
  writes.

The e2e test exercises capabilities discovery, triage via
fleet-context, and depth via resource-context with an unknown id
to confirm the resource_not_found stable error code reaches the
wire under the canonical "error" key. The subscribe_events SSE
path is probed unauthenticated to confirm it's gated (401) rather
than 404 — discovery's claim is honest.
2026-05-09 22:16:32 +01:00
rcourtman a168215f6a Add /api/agent/fleet-context for org-wide triage in one read
The substrate had a per-resource bundle but no fleet view, so
"where do I focus?" forced agents to walk every resource id and
bundle each — O(N) round trips that scale with fleet size. The
fleet endpoint returns a thin per-resource rollup in a single
read: identity, operator-intent flags (intentionallyOffline,
neverAutoRemediate, maintenanceWindowActive), per-severity
finding counts, and pending-approval count.

Same auth scope and same provider wiring as the per-resource
bundle — operator-state via the canonical unified store, findings
via AgentFindingsProvider, approvals via AgentApprovalsProvider —
so the fleet sweep is the per-resource bundle's wiring multiplied
by N with no new dependencies. Audit reads are deliberately
omitted from the rollup; agents that want depth on a flagged
resource follow up via /api/agent/resource-context/{id}.

The capabilities manifest declares get_fleet_context with
AgentFleetContext as the response shape so external agents
discover the triage entry point through the same path they
already use to learn the rest of the agent surface.
2026-05-09 22:08:50 +01:00
rcourtman d8f6b1e508 Bundle pending approvals into the agent resource-context endpoint
The substrate's "everything an agent needs in one read" guarantee
covered identity, operator state, findings, and recent actions but
forced a separate /api/approvals call for pending governance
state. AgentResourceContext now carries pendingApprovals as a
lightweight AgentResourceApprovalSummary projection — same
vocabulary as approval.pending SSE events, so the doorbell and
the bundle agree on shape. AgentApprovalsProvider is the parallel
seam to AgentFindingsProvider; the router wires a closure that
resolves approval.GetStore() at request time, scopes via
BelongsToOrg, and filters by CanonicalResourceID so cross-tenant
or cross-resource pending requests don't leak. Empty arrays
preserve the iteration-safe contract the existing sections
already follow.
2026-05-09 22:00:53 +01:00
rcourtman 7fe9b1c492 Use cursor-help on TagBadges hover-only +N indicator
The "+N" overflow indicator on TagBadges was styled with
`cursor-pointer`, which signals a clickable affordance — but the
element only listens for mouseenter/mouseleave to show a tooltip and
has no click handler. Switch to `cursor-help` so the cursor matches
the actual interaction (hover for more info), avoiding a phantom
click expectation.
2026-05-09 21:52:34 +01:00
rcourtman 52669128e6 Drop redundant policy gates in resource-link routing
Tail of the operator-local-UI redaction sweep (abdde303a, a17f879a1).

resolveKubernetesContextForResource gated on requiresGovernedResourceDisplay
to choose between getPreferredInfrastructureDisplayName and a manual
displayName-or-name fallback. Both branches produce a raw infra name
once we trust that displayName never carries a redacted summary in
local rendering, so the gate is dead complexity. Collapse to a single
call and drop the now-unused requiresGovernedResourceDisplay import.

problemResourcePresentation.getProblemResourceDisplayName has no
production consumers today, but it still routes through the governed
helper. Reclassify it now (same as every other operator-local helper)
so the rule is consistent across the codebase if the surface ever gets
adopted.
2026-05-09 21:31:45 +01:00
rcourtman 51c5d344ce Plumb operator-state and operational memory into investigation findings
Closes the "has context vs uses context" gap that defines Pulse's
agent-paradigm differentiation. The orchestrator (in pulse-pro) used
to receive a Finding with no awareness of the operator's
commitments — Patrol could investigate a resource the operator had
marked never-auto-remediate and propose a restart fix that the
action broker would refuse downstream. The proposal shouldn't have
happened in the first place.

Adds two optional fields to aicontracts.Finding:

- OperatorContext: intentionally offline, never auto-remediate,
  maintenance window with computed active flag, criticality, note.
  Populated in MaybeInvestigateFinding from the same operator-state
  projection the suppression hot path consumes, so investigation
  reasoning and suppression behavior cannot drift apart.
- OperationalMemory: regression count, previous resolved fix
  summary, last regression timestamp, times raised. Populated in
  ToCoreFinding from fields the internal Finding already carries.

ResourceOperatorStateProjection grew a NeverAutoRemediate field —
the investigation read path needs it (so the orchestrator can avoid
proposing fixes the broker would refuse) even though the
suppression hot path doesn't. Same projection serves both reads.

Both fields are nil when there's no signal (fresh finding, no
operator state) so the orchestrator branches on absence rather
than parsing zero-valued structs. The pulse-pro orchestrator
consumes the fields in a separate slice; this slice ships the
in-repo half of the data path.
2026-05-09 21:03:15 +01:00
rcourtman 94bfd48a9d Add /api/agent/events SSE stream for real-time agent notifications
Third slice on the agent-paradigm pivot, closing the substrate
triangle (discovery + bundled reads + push). Agents subscribe once
to a long-lived SSE connection and receive real-time events instead
of polling: finding.created when a new finding is raised, heartbeat
every 15 seconds for keepalive. Each event carries a monotonic ID so
agents can dedupe and reason about ordering across reconnects.

The broadcaster fan-outs to multiple subscribers and drops events
for slow consumers rather than blocking the publish path —
publishers cannot stall on consumer slowness. The findings-runtime
hook in router.go publishes finding.created when the finding is new
AND not auto-dismissed by operator-state suppression (operator
already said to stay quiet about that resource); patrol-cycle
re-detection of existing findings doesn't fire the event.

Capabilities manifest declares the stream under subscribe_events so
external agents discover it through the same channel as the REST
surface. SSE chosen over WebSocket because it's simpler, works
through every HTTP proxy without special-casing, and matches the
existing deploy_handlers pattern; agents that need bidirectional
comms call REST endpoints in parallel.

Tests pin the broadcaster's pub/sub semantics (fan-out, unsubscribe,
slow-consumer drop, monotonic IDs), the SSE handler's stream
contract (text/event-stream, no-cache, X-Accel-Buffering=no), and
the connected/published-event delivery via httptest.NewServer. A
contract test pins the publish-gate semantics so operator-state
suppression and stream notifications stay aligned.
2026-05-09 20:13:31 +01:00
rcourtman 71797f9b21 Add /api/agent/capabilities discovery manifest for agent integrations
Second slice on the agent-paradigm pivot: the discovery document any
external agent (Claude Code, custom integrations, future MCP servers)
needs to learn what Pulse exposes. Each capability declares its
agent-stable name (snake_case), description, category, REST surface,
required scope, response shape, and the closed set of stable error
codes the response may carry. Agents branch on the codes
(operator_state_invalid, resource_not_found, etc.) rather than
parsing human messages.

The manifest is hand-authored, not auto-generated, because the
contract decisions (what's agent-stable, which categories, which
error codes) are product-shaping and must not drift behind code
changes. Adding a capability is a deliberate "this is part of the
agent surface" commitment.

v1 surface includes: get_resource_context (substrate from slice 39),
get/set/clear_operator_state (slice 30), and the finding-lifecycle
actions (acknowledge, snooze, dismiss, resolve). Action-broker
capabilities are not in v1 because they go through approval flow,
not direct dispatch — those need their own contract design.

Tests pin: stable shape, version contract, unique-and-snake_case
names, every capability has method/path/scope, closed category set,
required error codes for the most consequential capabilities. The
manifest is unauthenticated and cacheable (5min); the underlying
capabilities keep their own auth scopes.
2026-05-09 19:52:17 +01:00
rcourtman 14f9270a5e Add /api/agent/resource-context/{id} substrate endpoint for agents
First slice on the agent-paradigm pivot: instead of building more
human-glance UI, expose substrate that any agent (in-process Patrol,
external Claude Code, future MCP-driven setups) can consume in one
read. The endpoint returns the full situated picture of a resource —
identity, operator-set state with server-computed
maintenanceWindowActive flag, active findings as a lightweight
seven-question-schema projection, and recent action audits with
refusal tokens (resource_remediation_locked:, plan_drift:) preserved
verbatim for agent branching.

Substrate is the right shape here: an agent reasoning about a
resource gets everything it needs without chaining four or five calls,
and the projection types decouple agent-stable wire shape from
internal type evolution. Active findings flow through an
AgentFindingsProvider adapter wired in router.go from the patrol
service, keeping the api package free of an internal/ai import.

Always-array fields (activeFindings, recentActions) and
omitempty-on-absent (operatorState) give agents stable iteration and
clean field-presence branching. AgentContextHandler owns the agent
surface as its own type so it evolves independently of resource CRUD.
Each test pins a specific contract: identity round-trip, operator-state
projection with computed flag, empty-state shape, refusal-token
preservation, 404 shape, method gating.
2026-05-09 19:46:16 +01:00
rcourtman eae8ca2a68 Wake operator-state-suppressed findings when the suppression lifts
Real product gap exposed by closing the operator-state feature: a
finding auto-dismissed while a maintenance window covered `now` would
stay dismissed forever even after the window ended. Same for findings
auto-dismissed under IntentionallyOffline once the operator cleared
the flag. The time-bounded suppression silently became permanent.

Adds a third wake condition to the dismissed-branch in
FindingsStore.Add: when a finding's most recent dismissed lifecycle
event carries operator_state_cause metadata, AND the provider reports
no current suppression for the resource, clear the dismissal and emit
a suppression_lifted lifecycle event naming the previous cause.

Manual operator dismissals (no operator_state_cause) are unaffected —
the findOperatorStateDismissCause helper stops at the first dismissed
event when scanning newest first, so a manual dismissal that
supersedes an earlier auto-dismiss is not falsely re-awakened. Tests
cover both signal types, the manual-dismissal isolation, and the
helper's newest-first scan order.
2026-05-09 18:15:32 +01:00
rcourtman b822ef17e6 Pin: operator-state-suppressed findings skip autonomous investigation
Cross-slice contract worth making explicit: when slices 31/32
auto-dismiss a finding because the operator's per-resource state
suppresses it, that finding must not also burn investigation budget.
The existing chain already delivers this — findings.Add sets
DismissedReason="expected_behavior", and ShouldInvestigate gates on
DismissedReason != "" — but the relationship was implicit. Without a
test, a future refactor of either branch could silently start
investigating operator-suppressed findings again.

Pins the contract with a table-driven test covering both signals
(intentionally_offline and maintenance_window) at every autonomy
level (approval/assisted/full), plus the lifecycle-cause metadata
attribution. No runtime change — only the test and a contract
paragraph naming the dependency.
2026-05-09 18:05:13 +01:00
rcourtman 744166731e Merge duplicate map keys in TestActionExecutionContractStaysAPIOwned
Pre-existing bug exposed by slice 33: the contract test had two
filepath.Join(".", "actions.go") entries in its requiredSnippets map.
Go silently drops duplicate map keys (last entry wins), so the first
list — including the canonical ErrActionPlanDrift pin and the new
ErrResourceRemediationLocked pin from slice 33 — was never actually
running. The test reported PASS while pinning nothing it claimed to.

Merges both lists into a single actions.go entry so every snippet
runs. Also corrects ErrActionPlanDrift's expected alignment to the
single-space form gofmt produced when the var block grew the new
sentinel.
2026-05-09 15:08:51 +01:00
rcourtman e1103bf119 Refuse action dispatch when resource is operator-locked against remediation
Third compounding slice on per-resource operator state, completing the
suppression triangle: maintenance window (slice 31), intentionally
offline (slice 32), and now NeverAutoRemediate at the action-broker
boundary. When the operator has set NeverAutoRemediate=true on a
resource, executeCommandWithAudit refuses the dispatch even with a
valid approval and matching plan hash — per-resource operator intent
outranks per-action approval.

The check sits next to the drift refusal in the same audit hot path:
fail-open on store-lookup error (logged but doesn't block), persist a
Failed audit record with `resource_remediation_locked:` prefix on the
ErrorMessage so audit-UI filters and alert rules can branch on the
stable token. Returns ErrResourceRemediationLocked sentinel for
caller-side branching.

Reuses the existing actionAuditStore handle (which is the unified
ResourceStore from slice 29), so no new provider plumbing needed —
the broker already has the data path it needs.
2026-05-09 15:06:13 +01:00
rcourtman 8a70a2c23c Auto-acknowledge findings on intentionally-offline resources
Compounding slice on the per-resource operator-state feature: when an
operator has marked a resource as IntentionallyOffline (via the API
surface from slice 30), new findings against that resource get
auto-dismissed as expected_behavior with the lifecycle event tagged
operator_state_cause=intentionally_offline. Same shape as the
maintenance-window suppression from slice 31 but indefinite — no
scheduled end, no maintenance_end_at metadata.

Restructures the ResourceOperatorStateProvider interface to return a
single ResourceOperatorStateProjection per call rather than a narrow
ActiveMaintenanceWindow method. Adding the second signal would
otherwise have meant a second method and another call per finding;
the projection shape carries every signal in one round-trip and gives
future signals (NeverAutoRemediate, criticality) a stable extension
point.

Maintenance windows take priority over intentionally_offline when
both are active because the time-bounded suppression is more honest
to surface to the operator — they'll see it auto-clear when the
window ends rather than wondering when the indefinite suppression
will lift.
2026-05-09 14:54:45 +01:00
rcourtman cf2e61ea22 Auto-acknowledge new findings during operator-set maintenance windows
Third slice on the per-resource operator-state feature delivers the
behavioral payoff: when an operator has set a maintenance window on a
resource (via the API surface from slice 30), new findings against
that resource get auto-dismissed as expected_behavior at creation time
rather than firing notifications. The finding still lands in durable
history with a UserNote naming the window and a "dismissed" lifecycle
event tagged operator_state_cause=maintenance_window so the operator
can audit what tripped during the window.

Adds a narrow ResourceOperatorStateProvider interface to internal/ai
so the findings runtime stays free of an internal/unifiedresources
import. The API layer wires an adapter in router.go that projects
unified.ResourceOperatorState through state.IsInMaintenanceAt into
the ActiveMaintenanceWindow shape the findings runtime consumes.

Suppression is opt-in: stores without a provider wired keep the
original new-finding behavior bit-for-bit, so deployments that
haven't adopted the operator-state feature see no behavioral change.
IntentionallyOffline and NeverAutoRemediate land as compounding
slices on the same provider interface.
2026-05-09 14:43:22 +01:00
rcourtman 46646b4293 Add /api/resources/{id}/operator-state GET / PUT / DELETE handlers
Second slice on the per-resource operator-state feature: the API
surface that the storage foundation from slice 29 was designed to
support. A frontend, pulse-cli, or Assistant tool call can now read
the operator-set state for a resource, replace it with PUT, or clear
it with DELETE.

Contract decisions worth preserving:
- GET 404s with stable error code operator_state_not_set when no
  entry exists, distinct from a 200 with default (all-zero) fields.
- PUT replaces the entire record. URL canonicalId wins over body to
  prevent body-manipulation retargeting; server-side setAt/setBy
  populate from request time and authenticated identity, ignoring
  client values so the audit trail stays honest.
- Validation rejections surface 400 with operator_state_invalid so
  frontend can branch on the code without string-matching messages.
- DELETE is idempotent — 204 whether or not an entry was present.
- GET runs under monitoring:read; PUT and DELETE under
  monitoring:write because the state modulates Patrol's behavior.

Finding-suppression and action-broker integrations land in subsequent
slices that consume the same ResourceOperatorState shape.
2026-05-09 14:34:43 +01:00
rcourtman ee21942f2a Add ResourceOperatorState foundation for per-resource operator intent
Foundational wedge for the operator-set per-resource state feature
(intentionally offline, never auto-remediate, maintenance windows,
criticality hint). Defines the canonical schema, validation, and
persistence contract; subsequent slices wire in the API surface and the
finding-suppression / action-broker integrations.

ResourceOperatorState carries four narrow operator-intent fields plus
attribution metadata. The shape is intentionally fixed (not a freeform
metadata bag) so consumers have a stable contract to honor.
ValidateResourceOperatorState rejects malformed records with a stable
sentinel (partial maintenance window, end <= start, unknown criticality);
NormalizeResourceOperatorState handles whitespace and case.

Adds GetResourceOperatorState / SetResourceOperatorState /
ClearResourceOperatorState to the ResourceStore interface, implements
both on SQLite (new resource_operator_state table) and Memory. Set is
upsert by canonical_id; Clear is idempotent. Tests cover IsEmpty,
IsInMaintenanceAt window semantics (half-open interval, partial windows
treated as no window), validation rejections, and round-trip
persistence on both stores.
2026-05-09 14:23:24 +01:00
rcourtman f99bce7ee4 Author Proxmox VM/CT lifecycle preflight context for approval review
Pulse's primary monitored platform is Proxmox, but the per-command-class
preflight catalog only covered systemd services, Docker containers, and
Kubernetes deployments. Operators approving a Patrol-proposed
qm restart/qm stop/qm shutdown saw the generic preflight without the
operational nuance that distinguishes those verbs (qm stop is a hard halt,
qm shutdown is graceful with a 60s ACPI timeout, etc.).

Adds eight new classes covering qm and pct lifecycle (reboot/restart,
stop, start, shutdown). Each gets hand-authored safety and verification
copy that names the actual semantics — pct stop is destructive vs pct
shutdown's lxc-attach handoff — so the operator sees concrete context at
approval time.

Broker-level VerificationCommandForCommand intentionally does not derive
qm status / pct status from these classes — pulse_control's
verifyGuestAction already runs those checks at the tool layer, so adding
a parallel broker dispatch would double-run. The preflight copy still
names what the tool-layer verification will read.
2026-05-09 13:12:52 +01:00
rcourtman 2bd4621b76 Attribute operator-driven Mark resolved closures as "Resolved by you"
Slice 22 added the manual Mark resolved button (auto_resolved=false) but the
resolution-reason copy still flattened every closure into "Condition
cleared" or "Issue no longer detected" — the operator couldn't tell from
the timeline whether they had closed the loop themselves or Pulse had
auto-detected the condition clearing.

Threads the existing Finding.AutoResolved flag through unified.UnifiedFinding
(Go), router.go conversions, UnifiedFindingRecord (TS), and the store-level
UnifiedFinding. The frontend resolution-reason helper now reads "Resolved by
you <time>" when autoResolved === false, while keeping Patrol's specific
fix outcomes (fix_verified, fix_executed, resolved) priority because those
describe Pulse's actual remediation rather than mere auto-detection.
2026-05-09 11:10:11 +01:00
rcourtman 5cc2f61be0 Surface will_fix_later remind-at on dismiss confirm and dismissed rows
Slice 18 made will_fix_later a real operational commitment server-side, but
the new RemindAt field stayed invisible to operators until the reminder fired
a week later. This wires it through the API surface and renders it where the
operator decides and where they later revisit.

UnifiedFinding (Go and TS) and the Patrol Finding TS shape now carry
RemindAt / remind_at; router.go and AddFromAI mirror it like the other
user-feedback fields. FindingsPanel previews "Pulse will stay quiet for 7
days, then surface again on <date>" on the dismiss confirmation panel before
the operator confirms, badges dismissed-as-will_fix_later rows with
"Reminding <date>" in amber, and adds explanatory copy for the other two
dismissal reasons so all three paths feel deliberate rather than
undifferentiated.
2026-05-09 10:47:21 +01:00
rcourtman fb293169f7 Make will_fix_later an operational commitment, not silent shut-up
Before this change, all three dismissal reasons funnelled through the same
"swallow re-detection at same/lower severity" path in FindingsStore.Add, so
will_fix_later was functionally identical to expected_behavior — Pulse stayed
quiet forever despite the dismiss_finding tool literally telling the LLM
"Pulse Patrol will continue to monitor this issue."

Now will_fix_later sets Finding.RemindAt (default 7 days) at dismissal time.
Once RemindAt has passed, the next re-detection clears the dismissal and emits
a `reminded` lifecycle event so the operator sees their lapsed commitment
instead of a swallowed finding. expected_behavior keeps acknowledged-forever
semantics; not_an_issue keeps Suppressed=true. The LLM tool response now
surfaces the remind-at date so Patrol's conversational explanations stay
aligned with the contract.
2026-05-09 10:19:28 +01:00
rcourtman a597321801 Run class-derived verification check after successful dispatch
Closes the read-after-write piece of the user's act-with-control loop:
"Act with control: ask for approval, execute through Pulse tools, then
verify." Until now Pulse authored verification narrative copy at
approval time but never actually executed the check.

Adds ActionVerificationResult to internal/unifiedresources/actions.go
and embeds it as ExecutionResult.Verification so the existing
result_json column persists the outcome without a schema migration.
The struct records: did the check run, what command did Pulse send,
what came back, did it succeed, when, and any failure note.

Adds VerificationCommandForCommand in tools_control.go that derives
the per-class read-after-write check used by the broker:
- service-restart / service-start / service-reload / service-stop
  → systemctl is-active <unit>

Container classes (container-restart, container-stop) are
intentionally deferred to pulse_docker's existing tool-level
docker inspect verification — adding a broker-level dispatch would
double-run the same check.

executeCommandWithAudit now runs the derived verification command via
the same agent path immediately after a successful dispatch, captures
output and exit code, and writes the result onto
ExecutionResult.Verification before recordActionExecutionResult
persists. If the check returns non-zero, Verification.Success=false
with a Note explaining the exit code so the audit history honestly
shows that Pulse ran the action but couldn't confirm it took.

Verification is best-effort and class-scoped: unknown command shapes
leave Verification nil rather than fabricating a verified=true entry,
matching the no-fabrication boundary from Impact / preflight authoring.

Tests cover:
- VerificationCommandForCommand derives the right check per class,
  shell-escapes single quotes in unit names, and returns "", false
  for docker (deferred) and unknown commands.
- TestExecuteCommandWithAuditRunsClassDerivedVerificationAfterDispatch
- TestExecuteCommandWithAuditMarksVerificationFailedWhenReadbackDoesNotConfirm
- TestExecuteCommandWithAuditSkipsVerificationForUnclassifiedCommands

Updates ai-runtime contract with the read-after-write rule and
container-class deferral. Adds Completion Obligation #21 to
unified-resources pinning ExecutionResult.Verification shape and the
no-fabrication boundary. Extends TestActionExecutionContractStaysAPIOwned
to pin ActionVerificationResult struct and the Verification field on
ExecutionResult.
2026-05-09 09:48:40 +01:00
rcourtman 2dca000416 Author per-command-class preflight context for approval review
Operators about to approve a Pulse-driven action need to know what the
command actually touches before saying yes. Until now the preflight
shown at approval time was the same generic boilerplate for every
action: "approval is scoped, hash must match, etc." — useful safety
posture but no operational specifics. The dry-run summary said the
same thing for 99% of actions.

Adds classifyApprovalCommand and approvalCommandClassPreflightAdditions
in tools_control.go that bucket common Pulse remediation actions and
return hand-authored safety + verification additions per class:
- service-restart  (systemctl restart, service restart)
- service-stop     (systemctl stop, service stop)
- service-start    (systemctl start, service start)
- service-reload   (systemctl reload)
- container-restart (docker / podman restart)
- container-stop   (docker / podman stop)
- k8s-rollout-restart (kubectl rollout restart)

The additions name concrete operational facts the operator needs:
- For service-restart: "Service will be briefly unavailable; no other
  unit dependencies altered" plus verification "Read back
  systemctl is-active <unit>" and "tail journal for crash patterns".
- For container-restart: "Container will be briefly unavailable;
  image and volume mounts unchanged" plus "Read back via docker
  inspect" verification.
- For k8s-rollout-restart: "Pods rolled in waves per deployment
  strategy; PodDisruptionBudget continues to apply" plus "watch
  kubectl rollout status" verification.

approvalPreflight now appends class-specific safety and verification
content onto the existing default content rather than replacing it,
so the broker's structural safety posture (org scope, hash match,
single-use approval) remains visible alongside the operational copy.

Unknown command classes return nil/nil — no fabricated padding for
commands the bucket does not recognize. The default preflight stands
on its own, matching the no-fabrication rule that runs through the
rest of the trust-record arc.

Tests cover: each known class returns non-empty additions with the
expected operational tokens; unknown commands return nil; the
end-to-end approvalPreflight merge surfaces both default and
class-specific safety/verification entries. ai-runtime contract
pinned with the per-class enrichment rule and the no-fabrication
boundary for unknown classes.
2026-05-09 09:29:13 +01:00
rcourtman 643d5f2992 Redact known secret shapes from action audit log persistence
The action audit log is plaintext SQL. Operators sometimes paste
secrets into a natural-language `reason` field ("rotate the key
sk-abc123 because it leaked"), and command output sometimes echoes
tokens. Both flows previously persisted unredacted, leaving the
audit history as a leaked-credential surface.

Adds RedactAuditText / RedactAuditRecord in a new
internal/unifiedresources/audit_redaction.go with a curated regex set
that targets the credential shapes most likely to be real secrets:
- URL with embedded basic-auth credentials (https://user:pass@host)
- Authorization: Bearer <token> and x-api-key: <token> headers
- Query-string secret params (?api_key=, &token=, &access_token=)
- JSON-style secret fields ("api_key": "...", "password": "...")
- Env-style or CLI-style secret assignments (PASSWORD=, api_key=)
- OpenAI/Anthropic-style API keys (sk-...)

The set is intentionally narrower than the patrol-failure redactor in
internal/ai/patrol_runtime_failure.go: it does NOT strip arbitrary
URLs, because operators legitimately reference runbooks, ticket
links, and GitHub issues in audit reasons. Only patterns very likely
to be real secrets are touched.

Wired at the persistence boundary (top of each store method) so all
record paths get redaction uniformly:
- SQLiteResourceStore.RecordActionAudit
- SQLiteResourceStore.RecordActionExecutionStart
- SQLiteResourceStore.RecordActionExecutionResult
- MemoryStore.RecordActionAudit
- MemoryStore.RecordActionExecutionStart
- MemoryStore.RecordActionExecutionResult

Plan, Approvals, and identity fields are left alone — they are
produced by Pulse, not operators or external command output, so they
do not need redaction (and changing them would break PlanHash drift
detection).

Verification artifacts:
- audit_redaction_test.go: pattern-by-pattern coverage plus public-URL
  passthrough and empty-string passthrough; full RedactAuditRecord
  shape test (Reason + Params string values + Result output redacted;
  non-string Params and Plan untouched).
- code_standards_test.go: pins RedactAuditRecord call sites in
  store.go and helper signatures in audit_redaction.go so future
  refactors cannot silently bypass redaction.
- registry_test.go: cross-cutting integration test that exercises the
  registry-store boundary (MemoryStore.RecordActionAudit) and asserts
  redaction is applied while Plan fields stay untouched.

Adds new Completion Obligation #20 to unified-resources contract
pinning the redaction-at-persistence-boundary rule and the
no-touch-Plan-fields invariant.
2026-05-08 22:34:57 +01:00
rcourtman 22ce58cb9b Persist a refused audit record when plan drift is caught
Slice 13 added the drift refusal path but only logged at WARN level —
the audit history showed nothing, so an operator reviewing the action
trail could not see "Pulse caught this drift attempt." Now the drift
branch writes a Failed audit record with Result.ErrorMessage prefixed
"plan_drift:" and dispatches a Failed lifecycle event before returning
ErrActionPlanDrift.

The record carries the same Request, Plan, and Approvals snapshots that
a normal audit record would, so the operator-facing audit row shows
exactly what was attempted and what was approved. The "plan_drift:"
prefix is a stable token for downstream surfaces (audit UI filters,
alert rules) to distinguish drift refusals from generic execution
failures.

Extends TestExecuteCommandWithAuditRefusesPayloadDriftAgainstApprovedPlan
to assert the audit record exists with State=Failed and the plan_drift
error message after refusal. Updates the code-standards snippet check
for ErrActionPlanDrift to match gofmt's actual alignment in actions.go.
ai-runtime contract pinned with the audit-record-on-drift rule.
2026-05-08 22:20:01 +01:00
rcourtman a9e652e05b Refuse action execution when approved plan hash drifts from payload
PlanHash existed on ActionPlan as the contract for "the operator
approved exactly this (command, target, reason) combination" but the
broker never validated it: at execute time the freshly-recomputed hash
was overwritten by the approved plan's hash via mergeApprovedActionPlan
without comparison. A drifted payload (LLM re-emits with different
command, agent ID changes between approval and execute, malicious
injection) would run under a stale approval.

Fix: at the dispatch boundary in executeCommandWithAudit, recompute the
approval-equivalent hash from the actual payload using approvalPlanHash
(same function used at approval-creation time, so direct comparison is
meaningful), compare to plan.PlanHash, and refuse with a new
ErrActionPlanDrift error when they differ. The drift refusal also logs
at WARN level with action_id, approval_id, and both hashes so audit
review can see when drift was caught.

When approvedHash is empty (older approval records or contract paths
that did not author one), validation is skipped and existing behavior
is preserved.

Adds two tests:
- TestExecuteCommandWithAuditRefusesPayloadDriftAgainstApprovedPlan:
  approval is for "systemctl restart workload"; payload at execute
  time is "rm -rf /var/log/pulse"; expects ErrActionPlanDrift and no
  agent dispatch.
- TestExecuteCommandWithAuditAllowsMatchingPlanHash: same payload
  matches the approved hash; expects normal dispatch.

Updates two pre-existing tests that used stub PlanHash strings:
- TestExecuteCommandWithDeniedApprovalDoesNotDispatch: was testing the
  denial path with PlanHash:"sha256:test". Now uses the real approval-
  equivalent hash so denial fires (not drift) and the test still
  isolates the denial behavior.
- ControlledConsumesApprovedCommandWithResolvedRoutingTarget: same fix
  pattern.

Extends TestActionExecutionContractStaysAPIOwned in code_standards
test to pin ErrActionPlanDrift's existence in actions.go so future
refactors cannot silently downgrade drift into a generic error kind.

Native action path (executeNativeActionWithAudit) is left for a
follow-up: it has the same drift gap but uses actionPlanHashForParams
which is shaped differently from approvalPlanHash, so a sound fix
needs a coherent canonical hash function rather than just adding the
check. Contracts pinned in ai-runtime and unified-resources (new
Completion Obligation #20).
2026-05-08 22:11:31 +01:00
rcourtman 07d1ab51d1 Surface trust metrics on the Patrol page
Wire FindingsStore.GetTrustSummary through PatrolService and the
patrol-status API into the Patrol page so the operator can scan
"is Pulse useful?" at a glance. Adds a small Trust strip above the
Findings/Runs tab bar that renders compact signals: fixes verified,
auto-resolved, dismissed-as-noise, dismissed-as-expected, currently
active, and regressed-at-least-once. The strip is hidden when every
signal is zero so a fresh install sees no empty pill.

Plumbing:
- PatrolService.GetFindingsTrustSummary accessor (delegates to the
  store-level method shipped in the prior slice)
- PatrolStatusResponse carries Trust *FindingsTrustSummary; populated
  from the active patrol service, omitted when no service is available
  (snapshot semantics, not lifetime totals)
- TS FindingsTrustSummary mirror in api/patrol.ts and a trust field on
  PatrolStatus
- PatrolIntelligenceWorkspace reads state.patrolStatus()?.trust and
  conditionally renders the strip

Verification artifacts:
- internal/ai/patrol_test.go: TestPatrolService_GetFindingsTrustSummary
- internal/api/contract_test.go: TestContract_PatrolStatusTrustJSONSnapshot
  pinning the canonical wire shape
- frontend-modern/src/api/__tests__/patrol.test.ts: round-trip test for
  the trust block on the patrol-status response
- frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceWorkspace.test.ts:
  source-text test pinning state.patrolStatus()?.trust read,
  aria-label, and field names so future strip additions go through
  the FindingsTrustSummary contract first.
- frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts:
  pins that the per-finding context model does not synthesize impact
  from trust counters; trust is an aggregate operator-page concern,
  not a per-finding text source.

Updates the api-contracts, ai-runtime, patrol-intelligence,
agent-lifecycle, frontend-primitives, and storage-recovery contracts
to pin the trust block's shape, the strip's contract-first rule, and
the scope boundary (trust counters are advisory operator context, not
enrollment/storage/recovery action authority).
2026-05-08 21:11:24 +01:00
rcourtman cff4226531 Pass stored fingerprint into PVE diagnostic test client
The /api/diagnostics handler builds its own test client per PVE node
to run a live connectivity probe. The PBS branch already passed
node.Fingerprint into the test client config, but the PVE branch did
not. With VerifySSL=true and a self-signed Proxmox cert (the standard
configuration), tlsutil.CreateHTTPClientWithTimeout falls into
default-secure mode and validates against the system CA chain, which
fails the handshake even when the actual poller — which DOES pass
the fingerprint — is connecting fine.

The result was that /api/diagnostics reported delly + pi as
"Failed to connect to Proxmox API" while /api/resources was happily
ingesting all 27 workloads from the same hosts. Mirror the PBS
branch by passing node.Fingerprint into the PVE testCfg so the
diagnostic probe uses the same TLS verification path as the runtime
poller.

Add a regression test that spins up an httptest TLS server, captures
its leaf cert SHA-256, configures a PVE instance with VerifySSL=true
and that fingerprint, and asserts computeDiagnostics reports
Connected=true. The pre-fix code fails this with a "tls: bad
certificate" handshake error.
2026-05-08 20:58:32 +01:00
rcourtman a801bbf810 Add FindingsStore.GetTrustSummary snapshot for trust metrics
Adds FindingsTrustSummary struct and GetTrustSummary() method that
walks the in-memory findings store and returns a snapshot of how
currently-tracked findings have resolved: tracked, currently-active,
resolved (auto-resolved subset), fix-verified vs fix-failed, dismissed
broken out by reason (noise / expected / later), suppressed, and
regressed-at-least-once.

This is the data layer for the user's "trust metrics" arc — concrete
counts an operator can use to answer "do I trust Patrol?" Operators see
fix-verified and dismissed-as-noise grow over time as Patrol's analysis
gets sharper. The summary is intentionally a snapshot, not lifetime
totals; once findings are cleaned up they no longer contribute, so the
AutoResolved counter (which includes both Resolve(auto=true) and
UpdateInvestigationOutcome(fix_verified) paths) is a current-state
distribution, not a historical aggregate. The struct doc string is
explicit about this so downstream surfaces do not misframe it.

Adds a unit test covering each bucket (active, auto-resolved,
fix-verified, fix-failed, dismissed-as-noise, dismissed-as-expected,
regressed) with a fixture that exercises the real lifecycle methods
rather than mutating the store directly. Updates the ai-runtime
contract to pin the snapshot semantics and the AutoResolved-path
union.
2026-05-08 20:06:36 +01:00
rcourtman bd7d196c11 Mark failed PBS poll as failure and lock down with regression tests
The PBS poller's version-failure exit at monitor_pbs_pmg.go did not
set pollErr before returning, so even after fixing the defer-arg
capture in bf6261adc the deferred recordTaskResult still saw nil and
recorded the poll as a success. PMG's analogous path already sets
pollErr correctly. Mirror that here so the per-instance pollStatusMap,
the connections aggregator, and the circuit breaker all see PBS auth
and version failures as failures.

Add assertions to the existing PBS and PMG auth-failure tests that
the per-instance pollStatusMap entry has a zero LastSuccess and a
non-zero ConsecutiveFailures. The original tests covered downstream
state but not the recorder, which is why two distinct cases of this
class of bug went unnoticed.
2026-05-08 19:53:03 +01:00
rcourtman b5c8e00859 Preserve previous successful fix across regressions
Capture the prior InvestigationRecord.ProposedFix.Description into a
new Finding.PreviousResolvedFixSummary field at regression time, before
the InvestigationRecord is cleared. Without this capture the next
investigation starts from blank context whenever a finding regresses,
and operational memory of "what worked last time" is lost.

The summary propagates through:
- FindingsStore.Add regression branch (capture before clear)
- Finding.MarshalJSON / UnmarshalJSON (wire shape)
- Both Finding to UnifiedFinding conversion sites in router.go
- UnifiedFinding (struct + JSON shadow + Marshal/Unmarshal)
- UnifiedStore.AddFromAI update branch (non-empty overwrite)
- Assistant chat context as a "Previous Resolved Fix" line so the LLM
  sees what worked previously rather than blank-slate diagnosing each
  regression.

Adds a unit test that walks the full lifecycle (detect, resolve via
UpdateInvestigationRecord + ResolveWithReason, re-detect) and asserts
PreviousResolvedFixSummary is preserved while InvestigationRecord is
cleared, plus two chat-context tests covering the surfaces-when-set and
omits-when-empty cases. Adds a contract test pinning the canonical
"previous_resolved_fix_summary" JSON key. Updates the api-contracts,
ai-runtime, and the dependent agent-lifecycle, performance-and-
scalability, and storage-recovery contracts to pin the operational
memory propagation rule and its scope boundary.
2026-05-08 19:45:44 +01:00
rcourtman f530c76d17 Author Impact on AI-generated Patrol findings via tool schema
Extend the patrol_report_finding LLM tool schema with an optional
impact parameter and propagate it through PatrolFindingInput,
patrolFindingCreatorAdapter.CreateFinding, and into Finding.Impact so
LLM-authored Patrol findings carry consequence-if-ignored copy at
detection time alongside the curated catalogs already in place for
runtime failures and threshold alerts.

Updates the patrol system prompt with an "Authoring Impact" section
that instructs the LLM to write concrete operational consequences
(named workloads, jobs, recovery windows) rather than echoing severity
or category, and to leave impact empty rather than fabricate a
consequence when one is genuinely unknown. The eval-pass prompt gets a
shorter version of the same guidance.

Adds two unit tests: one covering an authored impact passing through
to PatrolFindingInput.Impact, and one covering the omitted-impact case
where the contract is honored verbatim with no synthesized default.
Updates the ai-runtime contract to pin the tool-schema authoring rule
and the no-fabrication invariant.
2026-05-08 19:28:02 +01:00
rcourtman a2c3dc77f1 Lift remediation-plan rollback into investigation records
When a Patrol finding has a generated remediation plan, the plan's
per-step Rollback strings are now aggregated into the durable
InvestigationRecord.Rollback field at record-build time. Previously
rollback metadata existed only nested inside RemediationStep.Rollback,
forcing operator-facing surfaces and Assistant prompt context to walk
into per-step payload to answer "what's the undo for the proposed fix?"

Adds AggregatePlanRollbackSteps in internal/ai/investigation_records.go
which deduplicates non-empty rollback strings from a plan's steps.
Wires the aggregation into the patrol_findings.go investigation-record
build site so rollback flows automatically when the PatrolService has a
RemediationEngine attached and the finding has an active plan.

Adds three sub-tests covering nil plan, empty-rollback-step skip, and
duplicate-rollback dedup. Updates the ai-runtime contract to pin the
RemediationPlan to InvestigationRecord rollback aggregation rule.
2026-05-08 18:17:19 +01:00
rcourtman bf6261adc6 Record poll error in PVE/PBS/PMG poll-result trackers
The deferred recordTaskResult call was passing pollErr as a function
argument, so it captured the value at defer-time (always nil) instead
of the value at execution time. Result: the per-instance pollStatusMap
treated every poll as a success — LastSuccess was set to "now" on
every cycle, ConsecutiveFailures stayed at zero, and the circuit
breaker never opened, even when the staleness tracker (which used a
proper closure) recorded the same poll as a failure.

The connections aggregator derives state from PollStatus.LastSuccess,
so the Connections UI reported broken PVE/PBS/PMG instances as
"active / verified / healthy" while no data was ingested. Wrap the
recordTaskResult call in a defer closure so it reads the live pollErr
at execution time, matching the pollMetrics and stalenessTracker
defers immediately above.
2026-05-08 18:14:20 +01:00
rcourtman 1cc20d5768 Author detection-time Impact for threshold alerts and propagate through stores
Extend Impact authoring to threshold alerts: convertAlertToFinding
calls a new generateImpact(alertType) that returns hand-authored
consequence-if-ignored copy keyed on alert type (cpu, memory, disk,
storage, temperature, offline, poweredOff, plus their aliases). Unknown
alert types return an empty string rather than synthesizing generic
text, matching the contract that impact must be authored, not invented.

Fix two propagation gaps in the unified store update paths:
- AddFromAlert update branch backfills Impact on existing findings that
  pre-date the Impact contract (description and recommendation
  intentionally remain non-refreshed so the addition does not change
  historical alert wording).
- AddFromAI update branch overwrites existing.Impact when the incoming
  finding has impact set, the same pattern already used for
  description and recommendation, so re-detected AI patrol findings
  carry freshly-classified impact text into the unified store.

Adds unit tests for generateImpact (one per alert type plus a
returns-empty-for-unknown case) and for the AddFromAI Impact
propagation path. Updates the ai-runtime contract to pin the threshold
alert impact catalog and the unified-store propagation rules.
2026-05-08 18:07:37 +01:00
rcourtman 10ff1c4dcf Surface Finding.Impact through UnifiedFinding to operators
Carry the Finding.Impact text added in the previous slice through the
Finding to UnifiedFinding boundary and onto the FindingsPanel surface
so the runtime-failure consequence-if-ignored copy is visible to the
operator. Add Impact to the UnifiedFinding struct, JSON snapshot, and
both Marshal/Unmarshal mirrors; copy f.Impact into both Finding to
UnifiedFinding conversion sites in router.go; mirror impact in the TS
UnifiedFindingRecord and Finding API types and the aiIntelligence
store normalizers; render an Impact line between Description and
Recommendation in FindingsPanel.

Also fix the FindingsStore.Add dedup-merge path so re-detected findings
overwrite existing.Impact alongside Description and Recommendation
rather than preserving the stale empty value left by an older binary.
Without this fix, a freshly-classified runtime failure with new Impact
text would be merged onto the persisted finding but the Impact field
would be silently dropped.

Verified end-to-end against the live runtime: triggered a Patrol run,
watched the runtime-failure finding regenerate, confirmed the
operator-visible card now renders "Impact: While Patrol cannot
analyze..." between Description and Recommendation. Updates the
api-contracts, ai-runtime, patrol-intelligence, and the dependent
agent-lifecycle, performance-and-scalability, and storage-recovery
contracts to pin the propagation rule and the dedup-merge invariant.
2026-05-08 17:31:19 +01:00
rcourtman 744b861614 Populate Patrol runtime-failure findings with Impact
Add Impact (consequence-if-ignored) to the Finding struct so
detection-time analysis can author operator-facing impact text alongside
the existing description and recommendation, and propagate that field
into the durable aicontracts.InvestigationRecord through
BuildFindingInvestigationRecord. Wire the Patrol runtime-failure
classification path (patrolRuntimeFailureFromError) to populate a
shared impact statement covering every failure cause: while Patrol
cannot analyze, alerts continue to fire without evidence or recommended
actions, and AI Intelligence summaries cannot refresh. The text is
constant across causes because the operational consequence of a
non-running Patrol does not change with the cause; only the
recommendation does. Updates the ai-runtime contract to pin the
detection-time impact authoring rule and forbid model-side
severity/category-derived impact synthesis.
2026-05-08 16:59:00 +01:00
rcourtman e7b5650233 Add impact and rollback to investigation records
Promote the seven-field investigation-record shape so Patrol findings
can carry consequence-if-ignored context and a record-level rollback
plan alongside the existing verification array. The shared
aicontracts.InvestigationRecord struct gains top-level Impact and
Rollback fields with matching TS mirrors, normalizes Rollback to an
empty slice, and the Patrol-owned investigation surface renders an
explicit "Impact not assessed" / "Rollback not specified" placeholder
so the operator-visible gap is conspicuous to both the operator and
Assistant when Patrol has not populated them. Backend default leaves
both empty rather than fabricating analysis from severity/category.
Also closes the existing Trigger.cause drift between Go and TS so
frontend handoff context preserves backend-attributed failure cause,
and updates the api-contracts, ai-runtime, frontend-primitives, and
patrol-intelligence subsystem contracts to pin the new shape.
2026-05-08 16:47:55 +01:00
rcourtman cb759e4113 Pin dual-source platform identity guardrails
Add three monitoring broadcast guardrail tests covering platform
identity for resources that have both an API/appliance facet and a
linked Pulse host agent:

- Proxmox VE node + agent (Pi case) stays on platformType=proxmox-pve
- TrueNAS appliance + agent stays on platformType=truenas
- Single-source Unraid agent host stays on platformType=agent

These pin the contract that the API/appliance facet wins over the
agent facet during platform type derivation, and that single-source
agent hosts do not promote OSName strings to platform identifiers.
2026-05-08 16:45:58 +01:00
rcourtman 797edfc6ab Preserve Patrol recommendation reasons in sessions 2026-05-08 15:33:14 +01:00
rcourtman aab3711765 Fix PVE version detection on agent hosts 2026-05-08 15:30:08 +01:00