Commit Graph

695 Commits

Author SHA1 Message Date
rcourtman 3073a50614 Harden the invocation-descriptor contract before the investigation profile
Four classifier ratchets from review of 67c2534c0:

The classification vocabulary is closed: descriptor validation rejects
any class outside the known workflow kinds and mutation targets (an
empty Mutation no longer registers), and InvocationPolicy.Allows
independently denies unknown mutation targets outright, so a class
that somehow bypassed validation still cannot execute. Descriptor
lookups and registration store deep copies, so callers can never
mutate the canonical table through shared case maps or static class
pointers. Registration rejects descriptor overrides for canonical tool
names; overrides exist only for genuinely non-canonical extension and
test names.

Projected governance now derives its action mode from mutation
targets, not workflow kinds, and recomputes it even when no enum value
was filtered; a projection whose remaining invocations mutate nothing
downgrades to scope-only approval metadata (registered scope-only
summaries are preserved). Docker check_updates reclassifies from
{write,none} to {read,none}: it queues a read-only scan, and the write
kind was driving the FSM into verification and making the read-only
Docker projection report mixed. Discovery consequently projects as
mode=read in governance manifests, which is the honest mutation-derived
mode. The pulse_file_edit governance summary no longer claims to read
files. The contract prose also names pulse_read's execution-intent
classifier as mandatory second-stage enforcement for exec, not merely
defense in depth: the static read/none descriptor cannot prove
arbitrary command text safe.

Proofs: open-vocabulary rejection, unknown-target policy denial,
descriptor copy isolation, canonical-override rejection, and the
read-only Docker read/scope-only projection.
2026-07-10 13:53:31 +01:00
rcourtman 67c2534c08 Replace the hard-coded tool classifier with registry-owned invocation descriptors
Every registered Pulse tool now carries a canonical invocation
descriptor (internal/agentcapabilities/invocation.go): static or
discriminator-based, classifying each invocation with a workflow kind
plus a mutation target (none / pulse_state / infrastructure). Mixed
descriptors must exactly cover their schema enum and registration
panics otherwise, so an unclassifiable tool cannot exist. Missing,
malformed, unknown, or fabricated discriminator values classify
fail-closed as infrastructure writes.

Provider projection and runtime enforcement consume the same
descriptor under one InvocationPolicy (control level plus the
request-local, non-serializable deny_infrastructure_mutations
restriction, isolated across executor clones): ListTools and
ListToolGovernance remove forbidden enum values, drop empty tools, and
recompute the offered action mode, while ToolRegistry.Execute blocks
forbidden invocations before the handler runs. This closes the mixed
tool control-level bypass, most seriously Docker action:update, which
previously fell through to direct execution at read-only, and fixes
the Kubernetes misclassification: the retired switch read the action
argument while the schema discriminator is type, so type:scale
classified as read.

pulse_file_edit is now write-only (append/write); file inspection
routes through pulse_read action=file, whose exec path keeps its
structural read-only execution-intent enforcement. ClassifyToolCall
consults the descriptor table first and retains only genuinely
non-registry compatibility cases. The deny restriction is deliberately
separate from autonomous mode, which only suppresses interactive
questions and grants no mutation authority.

Proofs: descriptor validation and fail-closed classification unit
tests, plus the invocation-policy regression suite (scale classifies
write and never invokes at read-only or under deny; Docker update
queues nothing at read-only; autonomous plus deny cannot mutate;
fabricated enum values fail at runtime; filtered projection and
runtime enforcement agree; executor clones keep request policies
isolated). Contracts and registry ownership updated for the new
shared invocation descriptor boundary.

Slice 3a of the typed-lifecycle ratchet; the patrol_investigation
execution profile and patrol_propose_action tool build on this
substrate next.
2026-07-10 13:31:11 +01:00
rcourtman 99dad2b511 Add a guided Ollama quickstart blessing qwen3:8b for Patrol
Ollama is the zero-cost AI path but the setup row offered only a Server
URL, and Patrol then failed on models that cannot emit tool_calls
(#1463, #847, #1425, #1152, #880). Bless qwen3:8b, the model family
Ollama's own tool-calling docs are written against, verified locally
against Patrol's real preflight: qwen3:8b emitted the tool call on
every run; qwen3:4b never did (0/4), so no low-RAM tag is suggested.

- Registry: SuggestedModel/Note/Equivalents on AIProviderDefinition,
  projected on /api/settings/ai providers; Ollama default model goes
  llama3.2 -> qwen3:8b.
- Provider row: copyable 'ollama pull qwen3:8b' block with hardware
  note, and a next-step hint when a successful test resolves a model
  outside the blessing set.
- Model resolution: exact-ID blessed preference, so pulling qwen3:8b
  makes it the auto-resolved Patrol model with no manual selection.
- Readiness copy names the blessed model (its contract pins landed
  with 94ccc7a0c's staging; this commit restores green).
- manual_ollama_preflight_test.go is the env-gated re-blessing
  harness; contracts updated for ai-runtime, api-contracts,
  frontend-primitives, and the agent-lifecycle/storage-recovery
  dependent boundaries; subsystem_lookup_test line pins follow the
  api-contracts.md insertion.
2026-07-10 13:02:26 +01:00
rcourtman 8d07089eb3 Finish the Assistant & Patrol settings rename to Pulse Intelligence
Commit cc948b022 fixed the Patrol preflight strings that gated keyless
onboarding; this sweeps the rest. Error messages, readiness checks, and
guidance copy that still pointed at the retired Assistant & Patrol
settings page now name the real surfaces: Pulse Intelligence settings
for the area as a whole, and the Provider & Models settings page for
provider-credential guidance, matching the phrasing cc948b022
established. Pinning tests updated in step, including three
ai_handlers_test.go assertions cc948b022 had already left stale.
2026-07-10 12:04:03 +01:00
rcourtman cc948b022c Route keyless Patrol setup to Provider & Models instead of a dead-end model check
A fresh install with no AI provider looped: the Patrol zero state sent
users to the Patrol model check, the check told them to enable Pulse
Assistant in a settings page that does not exist by that name, and the
Patrol model field silently degraded to a bare text input with no hint
that a provider key or Ollama server was the missing step (#1463, #847).

- Preflight and readiness copy now names the real surfaces (Provider &
  Models, Patrol settings) and the real first step: add an API key or an
  Ollama server.
- The Patrol zero-state CTA, header Fix setup link, and readiness
  banners route config-level causes (assistant_disabled,
  provider_not_configured) to Provider & Models; model-level causes
  keep the Check Patrol model action.
- The Patrol model field shows a linked zero-provider notice instead of
  a bare text input when no provider is configured.
- The preflight result box no longer renders the same failure message
  twice.
2026-07-10 11:38:23 +01:00
rcourtman 51155a42c9 Remove uncalled test helpers
Dead-code audit batch 3: hasPrefix in internal/ai/service_test.go and
the staticExternalAgentActivityProvider test double in
internal/api/agent_resource_context_test.go have no callers in any
test or production code.

Skipped from the audit list after re-verification:
createTestEncryptionKey in cmd/pulse/test_helpers_test.go (called nine
times by commands_integration_test.go under the integration build tag).
2026-07-10 00:29:10 +01:00
rcourtman f4c2fd0c38 Fail closed on unknown remediation lock state for autonomous dispatches
The AI action broker treated an unreadable operator lock as unlocked:
isResourceRemediationLocked returned (false, nil) with no audit store
wired, and the caller logged store errors then dispatched anyway. An
operator's NeverAutoRemediate=true could be silently ignored whenever
the policy store was missing or erroring, which is unacceptable while
Patrol and Assistant run at assisted or full autonomy.

Posture change at the dispatch decision point:
- isResourceRemediationLocked now reports unknown state (nil store or
  lookup failure) as an ErrRemediationLockStateUnknown-wrapped error
  instead of silently defaulting to unlocked.
- New checkRemediationLockForDispatch gate: dispatches without an
  approved human decision fail CLOSED on unknown lock state and
  surface "remediation lock state unknown; operator approval
  required". Human-approved dispatches keep the historical fail-open
  behavior with a warning log. A confirmed lock still refuses even
  approved dispatches, as before.
- executeNativeActionWithAudit (TrueNAS app start/stop/restart) now
  enforces the lock too; it previously skipped the check entirely.
- Refusals persist Failed audit records with stable
  remediation_lock_state_unknown: / resource_remediation_locked:
  ErrorMessage prefixes.
- ai-runtime subsystem contract updated to pin the new posture.

Tests cover store-error and nil-store at both autonomy postures on
both dispatch paths; routing/control tests now wire an in-memory
audit store since autonomous dispatch without one is refused.
2026-07-10 00:14:01 +01:00
rcourtman dc6b5c3197 Verify AI Kubernetes scale and Docker update actions after execution
Two AI write paths asserted unverified success while the Proxmox guest
and Docker start/stop/restart handlers already do read-after-write
checks. Bring both in line with that idiom:

- pulse_kubernetes scale: re-read the deployment's spec/ready replicas
  via kubectl through the same agent (bounded settle-and-retry window)
  and return a JSON response with a verification block instead of
  "Action complete - no verification needed".
- pulse_docker update: the docker agent already recreates the container,
  health-checks it, rolls back on failure, and acks a terminal command
  status; expose that status through a new Monitor lookup
  (GetDockerCommandStatus) plus UpdatesProvider.GetCommandStatus, and
  poll it within a bounded window. Responses now report verified
  success, verified failure (is_error), or an explicit inconclusive,
  never unverified success.

kubernetes_control_test.go also carries a small in-flight fix from the
parallel remediation-lock work (in-memory ActionAuditStore in the test
helper) that these tests require to run on this tree.
2026-07-10 00:05:22 +01:00
rcourtman 042e7ef966 Harden remaining CodeQL security boundaries 2026-07-09 19:46:40 +01:00
rcourtman af7f7c5d11 Fix Gemini Patrol tool-call readiness
Refs #1530
2026-07-06 23:01:16 +01:00
rcourtman c9c5f34eb4 Fix host metric percent normalization
Apply token-gated command policy when deriving connection fleet state.
2026-07-06 10:08:36 +01:00
rcourtman 8b1781b013 Ground Patrol run summaries in accepted findings
Refs #1512
2026-07-03 10:15:55 +01:00
rcourtman df67c01a64 Fix mobile relay runtime startup 2026-07-01 10:38:57 +01:00
rcourtman 229867636d Add resource priority to Patrol queue 2026-06-30 10:37:54 +01:00
rcourtman d8de95b91f Show custom provider model catalogs 2026-06-29 23:19:13 +01:00
rcourtman a801f456d6 Improve Patrol SMART disk evidence 2026-06-29 18:17:24 +01:00
rcourtman 8fff04eac5 fix(ai): query capacity forecast history under metrics-target ID not canonical ID
The deterministic capacity forecast queried utilization history using the
canonical resource ID (storage-<hash>), but the monitoring layer records
metrics under the metrics-target/source ID (MetricsTarget.ResourceID). For
most storage sources these two IDs diverge, so the forecast query always
found nothing and the feature stayed dormant even for pools with full
ingestion history (Proxmox, TrueNAS, PBS, agent-backed storage).

This splits the two ID roles in the precompute path: a new metricsID field
on patrolStoragePoolRow / patrolPrecomputeStorageSource carries the
metrics-target ID (resolved from SourceID() in the readState branch, or
MetricsTarget.ResourceID in the unified-resource branch) for history
queries, while the canonical id is still used for stamp-matching findings.

Adds a test proving the forecast fires when history exists only under the
metrics-target ID and that the resulting forecast resourceID stays
canonical so StampCapacityForecasts can still match it.

Contract (ai-runtime): the forecast must query history under the
metrics-target ID (StoragePoolView.SourceID()), not the canonical ID; the
forecast's own resourceID stays canonical for stamp-matching.

Refs lane: protection-posture-attention-queue
2026-06-27 22:18:09 +01:00
rcourtman d45715d083 feat(ai): surface deterministic capacity forecasts as first-class finding signals
Capacity forecasts (days-to-full, current usage, trend) were previously
computed deterministically but only fed into LLM prompt text. They never
reached the finding as structured data, so the frontend could not render a
verified urgency signal and the model's speculation was the only thing the
operator saw.

This persists CapacityForecast on Finding (marshal-mirror pair) and stamps
it post-analysis via a service join (StampCapacityForecasts). The forecast
filter now also keeps stable-high (>=80%) pools so the deterministic "no
fill trend" reading wins over model speculation, and fixes isQuiet wrongly
treating stable (-1) trends as filling.

Frontend maps the forecast through the UnifiedFinding view model and renders
a deterministic urgency line (Filling up / Stable / days-to-full · % used)
in the expanded finding detail.

Note: forecasts only populate for resources whose usage is ingested into
metrics history (Proxmox/Ceph storage, nodes, guests). Agent-host storage
(unraid pools) is not yet ingested as a time-series and remains a follow-up
to activate the feature for those pools.

Refs lane: protection-posture-attention-queue
2026-06-27 22:03:28 +01:00
rcourtman e197fa3db5 Handle JSON marshal error in agentic question tool response
Unchecked json.Marshal error could silently return empty string instead
of surfacing the encoding failure to the caller.
2026-06-27 15:28:32 +01:00
rcourtman 48cf663237 Strengthen evidence and impact guidance in patrol finding tool
The patrol_report_finding tool descriptions for evidence, impact, and
recommendation were too permissive, causing the LLM to frequently omit
them. Future patrol runs will produce better-scaffolded findings.

1. Rewrite tool descriptions to emphasize evidence as a trust anchor
   that should always be included, and impact as expected whenever the
   data supports it.
2. Add 'Authoring Evidence' section to the patrol system prompt with
   concrete examples, matching the existing 'Authoring Impact' section.
3. Add test verifying trust scaffolding guidance is in the system prompt.

Addresses checklist L48-50 (trust scaffolding).
2026-06-27 14:53:48 +01:00
rcourtman 63d7640a06 Fix missing impact text and incomplete alert type coverage in unified findings
1. Add Impact field to findingView struct — was completely missing from
   the unified findings API response serialization.
2. Expand generateImpact() to cover all ~25 alert types the system
   produces. Previously only 7 had curated impact text.
3. Fix naming mismatches: poweredOff/nodeOffline never matched actual
   alert types powered-off/host-offline.
4. Expand TypeCategoryMap with all missing alert types.
2026-06-27 14:43:09 +01:00
rcourtman 09fc029bd6 Canonicalize assistant provider registry
Add a registry-backed provider model for runtime metadata and chat-compatible transports.

Support Z.ai, Groq, Mistral, Cerebras, Together, and Fireworks through the shared chat-compatible client path while keeping native providers on their existing adapters. Expose provider metadata through settings and render the expanded provider set in the frontend.
2026-06-23 19:38:22 +01:00
rcourtman ee8a24e14a backend and governance: MCP contract, agent capabilities, API, and release-control
Manifest-backed MCP tools, prompts, and resources with surface affordance contracts; agent capability manifest and governance projection; API contract tests and capability route projection; operations-loop and intelligence-funnel telemetry; release-control subsystem documentation, registry, and tooling; licensing and configuration.
2026-06-23 17:26:15 +01:00
rcourtman 7235dcf504 Clarify Patrol event trigger runtime state
Expose runtime-blocked event triggers in Patrol status and surface the user-facing reason in the Patrol UI.
2026-06-15 10:08:54 +01:00
rcourtman 6725d6a784 Add governed Docker and Podman lifecycle actions
Refs #1034
2026-06-12 21:17:58 +01:00
rcourtman 8ce0f4a872 Show context-window usage in the last-turn summary (OpenCode parity)
OpenCode shows how full the model's context is next to the composer;
Pulse showed raw token counts with no sense of scale. The done event
now carries context_limit_tokens (providers.ContextWindowTokens for
the selected route) and the drawer's last-turn summary renders input
tokens as a percentage of the window: '8,500 tokens (6% of context)',
with the full breakdown in the hover detail. Without the limit the
summary renders exactly as before. Pinned in the plain-answer corpus
scenario.
2026-06-11 17:18:42 +01:00
rcourtman da10d9a5dd Mark the post-tool status handoff so silent reasoning never shows a stale tool status
After a tool round, the next model turn can reason server-side for tens
of seconds without streaming a single event. The footer kept the last
tool's status counting up ('Reading inventory. (40s)') while the model
was already composing the answer — observed live on the OpenRouter
reasoning route. OpenCode always shows a thinking state here; Pulse now
emits a model_processing workflow state at the start of every post-tool
turn ('Working on the response with the gathered results.'), which
model_thinking upgrades when reasoning deltas actually arrive.

Pinned by a new interaction-quality corpus scenario.
2026-06-11 16:52:02 +01:00
rcourtman 70a20f6ac7 Generate session titles from the first exchange (OpenCode parity)
Session titles were the truncated first prompt, so the picker filled
with 'Reply with exactly: OK' and five identical 'hows esphome' rows.
OpenCode background-generates a short title after the first exchange;
Pulse now does the equivalent: once the done event has reached the
client, the chat runtime asks the configured chat model for a <=50-char
title from the first user message and reply, then renames the session.

- Runs only on the first exchange; any customized title is left alone,
  with a re-check before persisting to lose races against a concurrent
  rename. Failure or timeout keeps the placeholder.
- Title requests pass the model-boundary sanitizer like normal turns
  and record usage as assistant_session_title.
- Synchronous after done (no user-visible latency, no goroutine racing
  service teardown), bounded at 20s for slow reasoning routes.

Verified by tests end-to-end (hook through Rename); live verification
on a real provider route pending while the dev backend sits in mock
assistant mode, which intercepts turns before the agentic path.
2026-06-11 16:21:58 +01:00
rcourtman ab01e56db0 Make session listing O(changed files), not O(all transcript bytes)
GET /api/ai/sessions fully re-parsed every session transcript on every
call, roughly three times over (handoff refresh List, per-session action
loads, final List). On a long-lived install the endpoint took tens of
seconds, pegged the CPU, and the dev watchdog kill-looped the backend.

- SessionStore.List() now serves summaries from a per-file cache
  validated by (modTime, size); only changed files are re-read, with a
  raw-skip decode that never deep-parses message payloads.
- The cache persists as .sessions_index.json next to the session files,
  so the first list after a restart is index-speed too (18ms on a 662MB
  legacy directory vs 23s before).
- writeSession/Delete keep cache and index coherent; external edits
  self-heal via the (modTime, size) check.
- ListSessions refreshes handoff action status only for the 20 newest
  sessions that carry actions, matching what the recent-sessions UI can
  surface; the send path already refreshes the active session on load.
- ai-runtime contract updated: listing-cost clause and bounded-refresh
  clause replace the unbounded refresh promise.
2026-06-11 14:11:17 +01:00
rcourtman a405bfc7e8 test(go): stamp parity storage fixture LastSeen; raise CI go test timeout
Two failures surfaced once CI reached the Go steps for the first time
in ~100 runs:

- TestParityStorageFields predates models.Storage.LastSeen (e2a036ce2,
  zero = never seen, no fabricated stamps), so the fixture ingested as
  never-seen and LastSeen() was honestly zero. Stamp the fixture like
  the real pollers do and assert exact round-trip, matching the docker
  host parity test.
- internal/api alone takes ~10m under -race on a fast machine, so the
  10m per-binary budget set when the package was smaller times out on
  CI runners. Raise to 25m. The package has no sleep-debt (all test
  sleeps are milliseconds); it is legitimately heavy.
2026-06-11 12:02:29 +01:00
rcourtman 8439ce6e6b Dedupe AI runtime guest-family, tool-pipeline, and provider clones
Clears the ten dupl pairs across internal/ai:

- patrol_intelligence.go, tools_query.go, tools_storage.go: VM and LXC
  system-container paths collapse into generics over read-state view
  method subsets (gatherGuestIntelligenceFromViews, canonicalGuestGetResult
  + guestViewGetResult, addCanonicalGuestSearchMatches +
  addGuestViewSearchMatches, appendGuestDiskSummaries).
- tools_file.go: append/write share executeFileMutation driven by
  fileMutationSpec (approval-command text, shell redirect, verification
  strategy stay per-action and verbatim).
- tools_kubernetes.go: deployment restart / pod delete share
  executeKubernetesResourceAction driven by kubernetesResourceAction.
- providers/anthropic.go + anthropic_oauth.go: message conversion shared
  via convertMessagesToAnthropic (the OAuth copy was annotated 'same as
  regular client').
- memory/changes.go + memory/remediation.go: history loading shared via
  the generic loadMemoryHistory in memory/paths.go (10 MiB cap, sort,
  missing-file semantics preserved via a found flag).
- findings.go and unified/alerts.go: Finding/findingJSON and
  UnifiedFinding/unifiedFindingJSON are deliberate marshal-mirror twins
  (AlertIdentifier json:"-" vs alert_identifier round-trip); merging
  would break every public literal. Suppressed with nolint:dupl and
  enforced instead by new reflect-based mirror-sync tests.

Contract Extension Points name the shared helpers and the mirror
invariant. Full ./internal/ai/... test tree passes.
2026-06-10 09:38:52 +01:00
rcourtman dad1152fc4 Surface finding-key collisions as lifecycle events instead of silent identity hijack
Patrol finding identity is the LLM-assigned key (resource+category+key
hash -> ID), and recordFinding adds by plain ID merge: when the LLM
reuses a key for a genuinely different issue on the same resource, the
new report silently overwrites the existing finding's title,
description, and evidence while inheriting its lifecycle — and on a
resolved finding, the reactivation counts as a regression of the OLD
issue, inflating the regression counter with a fiction.

Forking the key on dissimilar content would be worse: LLM titles vary
run to run, and splitting one real issue into duplicate findings is a
bigger trust hit than a conflated history. So the merge semantics stand,
and the identity shift is recorded honestly: when a same-ID
re-detection's title shares essentially no keywords with the existing
title (keywordOverlap <= findingIdentityShiftMaxTitleOverlap, 0.2 —
resource/category/key are equal by construction so text is the only
discriminating signal), FindingsStore.Add appends a content_replaced
lifecycle event preserving both titles in metadata and logs the
collision for frequency observability. Rephrasings and identical
re-detections stay event-free per the heartbeat rule. The UI renders
the event through the existing identifier-formatter fallback ("Content
replaced"); a dedicated label is patrol-intelligence UI work and lands
with that subsystem's own ceremony.

Teeth in findings_lifecycle_test.go: distinct-issue collision records
exactly one event with both titles; rephrased and identical
re-detections record none; the resolved-finding collision shows BOTH
content_replaced and regressed so the regression can be read for what
it is. Contract: Current State entry in ai-runtime.md. Full internal/ai
tree green.
2026-06-09 22:03:20 +01:00
rcourtman ab9552716c Auto-clear event/persistent Patrol findings on affirmative deterministic verification
The finding lifecycle was asymmetric around the deterministic-resolve
gate: the gate correctly blocks LLM resolves of event/persistent
findings (backup, reliability, security, general) when the verifier
still detects the signal, but nothing ever cleared such a finding when
the underlying issue WAS fixed — absence-based stale auto-resolve only
covers performance/capacity, so a fixed backup stayed an active finding
indefinitely unless the LLM happened to call patrol_resolve_finding.

reconcileStaleFindings now runs the deterministic verifier for seeded,
unreported event/persistent findings whose key has one, and resolves
ONLY on an affirmative "signal gone" verification. Still-present or
inconclusive results fail closed (same standard as the resolve gate);
verifications are capped per run (3) with deferred candidates logged
and retried next run. Test seam: PatrolService.verifyFixResolvedFn.

Two enabling repairs found during verification:
- hasDeterministicVerifierForKey listed 2 of the 7 keys the
  verifyFixDeterministically dispatch handles, so the LLM-resolve gate
  silently skipped existing verifiers for backup-stale and
  guest-unreachable findings. Now aligned; documented as the single
  source of truth for both consumers.
- The finding-key vocabulary was forked: the patrol_report_finding tool
  suggested keys (high-cpu, ...) the verifier switch never matched, so
  verification rarely engaged for new findings. normalizeFindingKey now
  aliases unambiguous directional synonyms onto the canonical verifier
  vocabulary (high-cpu -> cpu-high etc.; pbs-job-failed and node-offline
  deliberately NOT aliased — different resource models), and the tool
  description teaches the canonical keys.

NOT changed: the synthetic ai-patrol-error finding — verification showed
the reported "accumulation" is a non-problem (deterministic ID, store
merges repeats as heartbeats, successful runs auto-resolve it).

Teeth: 8 new tests in patrol_reconcile_test.go including the
idempotence invariant (repeat reconcile over unchanged state produces
zero resolves and zero lifecycle growth) and the cap-defers case.
Contract: verified stale-resolve clause added beside the
deterministic-resolve-gate in ai-runtime.md. Full internal/ai tree green.
2026-06-09 21:47:14 +01:00
rcourtman b707512e38 Clear all errcheck and gofmt violations so make lint gates on real findings
golangci-lint run ./... failed on ~190 pre-existing errcheck violations and
5 unformatted files, burying any new regression in noise. Fix all of them:

- Test files that hand-rolled mock-mode set/restore (vmware, truenas, and
  friends) now use the canonical setMockModeForTest/testutil.SetMockMode
  helper instead of drift copies that ignored SetEnabled errors.
- internal/mock and internal/monitoring tests get package-local
  mustSetEnabled/mustSetMockEnabled/mustSetMonitorMockMode helpers that
  fail the test on toggle errors.
- pkg/auth/sqlite_manager.go, pkg/metrics/store.go, pkg/server/server.go:
  rollbacks in defers use the explicit-discard idiom, migration renames and
  rollup commits log failures, the hosted reaper goroutine logs an error
  exit, shutdown mock-disable logs failures.
- Remaining test sites check errors with t.Fatalf/t.Errorf or explicitly
  discard best-effort calls (restore-chmods, handler-closure unmarshals)
  per existing repo style.
- gofmt: internal/api/maintenance_verification.go, internal/ai/demo.go and
  three findings test files.

Only dupl findings remain (44 pre-existing production-code duplication
pairs) — those need real refactors, not mechanical fixes.

Full test suites pass for every touched package.
2026-06-09 21:42:21 +01:00
rcourtman cb1ced8b8c Add the interaction-quality scenario corpus (chat-feel regression teeth)
Consolidates the OpenCode-restraint arc's user-visible promises into one
runnable corpus, mirroring the Discovery corpus pattern
(internal/servicediscovery/scenario_corpus_test.go). Each scenario
drives a full ExecuteStream turn against a scripted provider and pins
the browser-facing event stream: ordered/forbidden event types,
answer-text teeth, and payload teeth.

Six scenarios: clean plain-answer turn (done stamped with the model
route, no tool noise), greeting answered directly with tools offered
but unused, tool turn rendered as compact tool events with real tool
names and no provider call ids in the answer, clean no-narrative
fallback sentence (no raw JSON / call_ ids), invisible pre-event
provider retry (no error event), and exactly one clear error event on
terminal provider failure.

Teeth proven by mutation probes: a wrong ordered type and a wrong
answer assertion both fail with the promise named in the message.
Corpus documented in ai-runtime.md Current State as the canonical home
for interaction-quality regressions, so Patrol-phase churn on the
shared agentic loop cannot silently regress the chat feel.
2026-06-09 21:19:25 +01:00
rcourtman 945ed2198d Make the Assistant resolve a missing target before asking, not deflect
The system prompt framed ask-first as the only safe behavior for a
missing target ("Missing target information is not a safe default...
ask for the missing target"), so any "run X" / "check Y" request
without an explicit host deflected back to the operator — including on
single-host deployments with no real ambiguity. OpenCode-parity gap:
a competent operator looks first.

New policy: resolve-before-asking. Use read-only query/topology tools
to identify plausible targets; if exactly one plausible target matches,
run read-only diagnostics against it and name it in the answer; ask
only when several plausible targets remain or the action changes state.
Unchanged safety: placeholder targets (current_resource outside an
attached-resource turn) stay forbidden in all modes, never guess an
unresolved target, and write actions still need an explicit target.

Contract: resolve-before-asking entries in Extension Points and Current
State supersede the ask-first framing. Proof:
TestBuildSystemPrompt_CurrentResourceRequiresResourceHandoff pins the
new boundary strings. Live "just run X" behavior check is owed to the
interaction-quality corpus (next unit); dev-instance agent exec is
currently broken (known command-token issue), so it cannot be observed
end-to-end from here today.
2026-06-09 21:12:45 +01:00
rcourtman 36b9b2a53d Make Assistant tool selection model-owned; remove the prompt-keyword tool-scope router
assistantToolScopeForPrompt classified prompt wording into text-only /
query-only / full tool scopes — the prompt-keyword router the ai-runtime
contract forbids. It withheld every tool from greetings and exact-reply
turns, zeroed the manifest on false-positive phrases ("before using any
tools, tell me your plan"), filtered inventory prompts to pulse_query
only, and its query-only path injected a prefetched topology payload
into the user message and then withheld tools entirely.

Now every interactive turn that reaches the selected model carries the
full governed manifest from toolsForExecutionMode; the model decides
whether to answer, ask, or call tools. Removed with the router: the
query-only topology prefetch + text-only downgrade, the inventory
sanitizer allow-list span, and the summary-only pulse_query input
rewrite (preferSummaryOnlyQueries) that mutated model-chosen tool
inputs. The two contract-sanctioned behaviors survive unchanged: the
context-only resource handoff manifest boundary, and the deterministic
count-only local answer (pulse:local-inventory), now gated directly by
assistantPromptQualifiesForLocalInventoryCount whose false positives
fail safe — to the model, never away from it.

Contract: superseded the query-only/direct-text scoping paragraphs in
ai-runtime.md with the model-owned manifest rule. Proofs:
TestService_ExecuteStream_ToolManifestIsModelOwned,
TestService_ExecuteStream_InventoryBreakdownIsModelOwned,
TestAssistantPromptQualifiesForLocalInventoryCount. Full internal/ai +
internal/api trees green; chat package lints clean.
2026-06-09 21:07:28 +01:00
rcourtman d29d9684db Assert discovery freshness reaches the model end-to-end
Extend the cloud-context e2e test to stamp the discovery with an UpdatedAt and
assert "Last discovered: 2 days ago" reaches the model on both the handoff and
@-mention paths — proving freshness survives the full ExecuteStream path (cloud
policy, sanitizer allow-list, prompt injection), not just the formatter unit.
Test-only.
2026-06-09 11:02:39 +01:00
rcourtman 55dfdcb1d3 Instruct the Assistant to attribute facts and caveat staleness
The model now receives provenance — per-fact source/confidence and discovery
freshness — but nothing told it to use it, so answers stated facts the user
couldn't trace or weigh. Add a GROUNDING & PROVENANCE section to the base system
prompt: briefly attribute facts to their source ("Debian 12, per
/etc/os-release"), note recency for time-sensitive claims, do not present stale
context as current, and keep attribution concise rather than citing every line.

This is the prose-attribution layer of the provenance work (the visible part
chosen as the first, lowest-risk step over a heavier citations UI). Whether the
model attributes well in practice is a live-behavior question to verify by
exercising the Assistant — the prompt only guarantees the instruction reaches it.

- Test: TestBuildSystemPrompt_IncludesProvenanceGuidance.
- Contract: ai-runtime base prompt must instruct provenance attribution.
2026-06-09 10:53:05 +01:00
rcourtman 9ec52406ad Carry discovery freshness into the cloud-safe context
The pushed cloud-safe operational context told the model a resource's access
pattern, paths, and ports but not how OLD the discovery was — so the Assistant
could present a 2-week-old cached scan as current. For a monitoring assistant,
recency is the most important provenance signal.

FormatCloudSafeContext now appends "Last discovered: <age>" (via the existing
FormatDiscoveryAge helper, previously unused) when the timestamp is known, and
the push-path conversion (cloudSafeOperationalContext) carries UpdatedAt through.
A timestamp is non-identifying, so it adds no PII. Omitted when unknown.

- Tests: FormatCloudSafeContext freshness present/absent; cloudSafeOperationalContext
  carries UpdatedAt end of the push conversion (and still emits no PII).
- Contract: ai-runtime cloud-safe context must carry discovery age when known.

Follow-up: same freshness line on the local/full path (formatSingleDiscovery).
2026-06-09 10:46:10 +01:00
rcourtman a24e944f1b Carry per-fact discovery provenance through to the model
buildDiscoveryToolResponse rebuilt each fact as {category,key,value} and dropped
the Source (the command/origin that produced it) and Confidence the DiscoveryFact
already carries — so the model could state facts it couldn't attribute or weight.
Serialize both per fact (omitted when empty) so the Assistant can report "Debian
12, per /etc/os-release" instead of a bare, untraceable claim.

This is the first piece of the provenance/trust groundwork: make the model
*receive* the source metadata it needs to cite. Surfacing it to the user (UI) and
push-path discovery freshness are follow-ups.

- Test: TestBuildDiscoveryToolResponse_IncludesFactProvenance (source+confidence
  present when set, omitted when empty).
- Contract: ai-runtime section 8 documents the pulse_discovery fact provenance.
2026-06-09 10:35:29 +01:00
rcourtman ec91dd43ea Deliver cloud-safe operational context to the model on the resource handoff path
The drawer "Ask Assistant" handoff anchored a resource but never delivered its
operational context (access command, config/data/log paths, ports) to the model
— that only reached the model via @-mentions. Route handoff resources through
the same prefetch path so the proactive path matches the @-mention path on cloud
turns; PII (hostname/IP/alias) stays redacted at the model boundary.

- Reconcile the handoff Data Boundary directive: the model may use the PII-free
  operational context to answer and guide the user, while still never revealing
  raw hostnames/IPs/aliases/secrets. Action Boundary unchanged (read-only; any
  mutation goes through the governed approval flow).
- Fix a pre-existing clobber: the plain-text resource resolver overwrote the
  prefetch summary on the @-mention path, dropping the operational context
  before it reached the model. Run it only as a fallback when no structured
  mention resolved.
- Add the first end-to-end test asserting the operational context reaches a
  cloud model on BOTH the handoff and @-mention paths while PII is redacted.
- Update the ai-runtime contract to reflect the reconciled handoff data boundary,
  handoff operational-context parity, and the plain-text fallback ordering.
2026-06-09 10:18:52 +01:00
rcourtman bdb212744c Remove the cloud_context_privacy dial; fix cloud context to a lean posture
Per maintainer decision: the cloud-context-privacy feature was bloat. The real
fix for the "useless Assistant on cloud" problem was the earlier sensitivity
recalibration (ordinary workloads = Internal, not redacted); the dial layered a
configurable knob on top of an already-solved problem, guarding mostly-non-secret
data on a destination the operator opted into, and demanded every model-bound path
stay dial-aware (a standing leak surface). The privacy control users actually
understand is the choice of model — cloud provider vs. local Ollama.

Removed entirely:
- AIConfig.CloudContextPrivacy dial + constants + GetCloudContextPrivacy /
  NormalizeCloudContextPrivacy, AND the now-dead legacy
  ShareOperationalContextWithCloud boolean + ShouldShareOperationalContextWithCloud
  (internal/config/ai.go); the config-load migration (persistence.go).
- Both fields from the /api/settings/ai request/response, validation, and sync
  (ai_handlers.go) + the JSON contract snapshots.
- The "Cloud model privacy" 3-option UI control, form field, presentation copy,
  and CloudContextPrivacy type (frontend), plus their tests.
- The dial branching in the seam: chat/service.go cloudPrivacyLevel,
  CloudContextPolicy.Level + local_only suppression + the localOnly directive
  (context_prefetch.go), the inventory resourceLabel dial logic (resource_context*),
  and the modelboundary RedactLocalOnlyResourcesOnly option.

Fixed lean posture (no setting): a cloud-routed model receives real infrastructure
context, with two always-on invariants enforced by the model-boundary sanitizer —
credentials are always stripped, and local-only/Restricted resources (the floor)
never leave the local trust boundary. Local (Ollama) always full. The sanitizer's
default is now the local-only floor; it remains the universal backstop installed on
EVERY model-bound path (chat, session compaction, discovery/report/analysis via the
shared helper). Kept the two standalone fixes from this effort: compaction now
routes through the sanitizer, and directives no longer inject the "redacted by
policy" placeholder.

Governance: ai-runtime contract rewritten to a fixed-posture rule; api-contracts /
frontend-primitives / agent-lifecycle / storage-recovery dial references removed.
Tests updated to the floor-only behavior (local-only redacted, Sensitive flows,
secrets stripped). Full internal/ai/..., config, api suites green; frontend
type-check + tests + lint green.
2026-06-09 09:43:21 +01:00
rcourtman 38740f1dc4 Honor the cloud_context_privacy dial on non-chat model paths (increment 2 completion)
While investigating a "bazarr → Unknown Service" discovery report, found that
increment 2 only wired the dial into the interactive chat seam. The shared helper
(*Service).requestSanitizerForModel — used by discovery analysis, the report and
fleet narrators, quick analysis, and the ExecuteAgentic paths — always installed a
FULL-redaction sanitizer regardless of the dial. So at the "full" dial those paths
silently over-redacted governed resources: e.g. discovery could not identify a
governed service even though the operator chose full.

(Note: bazarr itself is classified Internal/cloud-summary, so it is NOT redacted —
its "Unknown Service" is a discovery service-identification matter, not redaction.
This fix addresses the governed-resource case the same gap would break.)

Fix: requestSanitizerForModel now resolves the dial from the config snapshot
(fail-closed to redacted when absent) and passes RedactLocalOnlyResourcesOnly() at
"full", exactly like the chat seam. Local (Ollama) still gets no sanitizer. The
local-only hard floor still protects must-not-leave resources at full.

Proof: TestRequestSanitizerForModel_HonorsCloudPrivacyDial — at full a Sensitive
(local-first) identifier flows while a Restricted (local-only) one stays redacted
and the bearer token is always stripped; at redacted both identifiers are redacted;
local model gets a nil sanitizer. Contract: ai-runtime universal backstop rule now
states the shared helper must honor the dial too (functional parity), not just
install the sanitizer. Full internal/ai/... suite green (23 packages).
2026-06-09 08:46:40 +01:00
rcourtman 8d33986b5e Sanitize session-compaction transcript at the model boundary (privacy audit fix)
A systematic audit of all model-bound paths (not just the ones touched reactively)
found a leak: SummarizeSession (internal/ai/chat/session_compaction.go) sent the
PERSISTED chat transcript to the chat model via provider.Chat WITHOUT the
dial-aware model-boundary sanitizer. The transcript is built from persisted
messages — original user prompts and tool outputs — which carry raw resource
identifiers (hostnames/IPs/names) regardless of how the live turns were redacted.
On a cloud chat model at redacted/local_only, that shipped identifiers to the
provider, contradicting the cloud_context_privacy dial. (Secrets were already
redacted at transcript-build time via safety; resource identifiers were not.)

Fix: run the compaction ChatRequest through modelboundary.RequestSanitizerForModel
with the same dial resolution as a normal turn — fail closed to redacted when no
config snapshot, RedactLocalOnlyResourcesOnly() at full, nil (no-op) for local
Ollama. Mirrors the interactive seam exactly.

Audit also checked: report/fleet narrators, quick analysis, ExecuteAgentic(Stream),
discovery analysis, and Patrol all already install the sanitizer (verified). The
Patrol preflight self-test sends a fixed payload with no resource content, so it
needs no sanitizer (verified static, not trusted from the audit summary). agentcontext
output flows through the sanitized agentic loop. So compaction was the one gap.

Contract: ai-runtime gains a UNIVERSAL backstop rule — every model-bound path that
carries infrastructure-derived content must install the dial-aware sanitizer;
session compaction named explicitly; static no-identifier probes exempted. Proof:
TestServiceSummarizeSessionRedactsResourceIdentifiersForCloud (a transcript
hostname is stripped in the captured compaction request at the redacted dial);
existing compaction tests stay green (fix is additive — empty-model path unchanged).
Full internal/ai/... suite green (23 packages).
2026-06-08 22:10:49 +01:00
rcourtman bc5da3f4c5 Make the inventory resource context obey the cloud_context_privacy dial (increment 2 completion)
Increment 2 wired the dial into the prefetch and the model-boundary sanitizer but
missed a third model-bound path: the broad inventory context builder
(internal/ai/resource_context.go buildUnifiedResourceContextForModel) rendered
resource display names through unifiedresources.ResourcePolicyLabel, which redacts
genuinely-sensitive names UNCONDITIONALLY — ignoring the dial and even local-vs-cloud.

Symptom (reported live): on a cloud model the Assistant surfaced "redacted by
policy" and tried to run pulse_query with it as a search term, because sensitive
resources appeared redacted in the inventory regardless of the dial.

Fix: unifiedResourcePolicyContext now carries the dial + a known-local flag, and a
new resourceLabel() renders names per the dial — known-local (Ollama) always real;
cloud real only at "full" and only for resources NOT routed local-only (the same
hard floor as the sanitizer); unknown/empty destination fails closed to the
governed label (preserves the safe default for the no-destination context path).
All 15 ResourcePolicyLabel call sites in the inventory builder route through it.
This also fixes a latent inconsistency where local (Ollama) models over-redacted
inventory names despite "local is always full".

Proof: TestUnifiedResourcePolicyContext_ResourceLabelDialAware (local real;
cloud-full sensitive real, local-only floored; cloud-redacted both governed). The
existing AI-safe-summary inventory tests (no-destination path) stay green via the
fail-closed unknown-destination branch. Governance: ai-runtime contract delta —
the seam is now THREE dial-aware paths, inventory builder included. Full
internal/ai/... suite green (23 packages).
2026-06-08 18:16:50 +01:00
rcourtman fe46632e26 Stop injecting the "redacted by policy" placeholder into model directives (increment 4)
The resource-context handoff directives in internal/ai/chat/service.go and
internal/ai/chat/plain_text_resource_context.go named the literal redaction
placeholder ("Do not copy 'redacted by policy' into any tool argument", "labels
may be redacted by policy"). Naming the phrase in the prompt made the model echo
it back to the user as if it were the resource name — the confusing leakage
flagged after the sensitivity recalibration.

Reword the directives neutrally ("a withheld or placeholder label", "some labels
may be withheld") and add an explicit instruction not to repeat a withheld
placeholder back as the resource identity. The current_resource handle remains
the authoritative target, and the tool resolver still accepts the placeholder as
a defensive alias, so behavior is unchanged — only the prompt wording.

Proof: the plain-text resource-context test now pins that the directive does NOT
inject the redaction placeholder (previously it asserted the opposite, which was
pinning the leakage). Governance: substantive ai-runtime contract delta adding a
redaction-placeholder-hygiene rule for Pulse-authored model-bound directives.
Full internal/ai/... suite green.
2026-06-08 17:16:53 +01:00
rcourtman 3e3c601317 Make the redaction seam obey the cloud_context_privacy dial (increment 2)
The dial now drives model-bound redaction directly, replacing the legacy
ShareOperationalContextWithCloud read. internal/ai/chat/service.go resolves
cloudPrivacyLevel once per turn (failing closed to "redacted" when no config
snapshot is present) and threads it into the prefetch and the model boundary:

- full: the model-bound resource-policy sanitizer is invoked with
  modelboundary.RedactLocalOnlyResourcesOnly(), so real identifiers (hostname,
  IP, alias, name) for ordinary (Internal) and Sensitive (local-first) resources
  reach the cloud model — the core "answer with real detail" win. Resources the
  policy engine routes local-only (Restricted) stay redacted as a HARD FLOOR a
  blanket dial must never override, so default-full never ships a must-not-leave
  resource to a cloud vendor. Prompt-secret sanitation (credentials) always runs.
- redacted: every policied resource's identifiers are redacted as before, and the
  prefetch surfaces the PII-free operational context for governed resources.
- local_only: the prefetch injects NO proactive infrastructure context to the
  cloud turn (only a transparency directive pointing at the setting / a local
  model), and the sanitizer still redacts identifiers as a backstop.

CloudContextPolicy now carries the dial Level (failing closed to redacted for
empty/unknown) instead of a ShareOperationalContext bool; sharesCloudOperationalContext
covers full+redacted, suppressesCloudContext covers local_only. The obsolete
"Share operational context with cloud models" transparency string is replaced by
a local_only directive referencing "Cloud model privacy".

modelboundary gains RedactLocalOnlyResourcesOnly() + localOnlyRoutedResources();
the resource-redaction pass narrows to the local-only floor at full while
prompt-secret sanitation is unconditional. Local (Ollama) models never reach the
sanitizer.

Governance: substantive ai-runtime contract delta describing the dial-driven seam
and the local-only floor. Proofs: modelboundary sanitizer tests (full keeps the
floor + redacts secrets; default redacts all identifiers), CloudContextPolicy
level semantics + prefetch full/redacted/local_only behavior, and the handoff
relationship test pinned to redacted. Full internal/ai/..., unifiedresources,
and agentcontext suites green.
2026-06-08 17:08:14 +01:00
rcourtman accebfb305 Infer resource_type from a canonical handle in pulse_query get
In the trace for 'hows esphome', the model called get with the canonical handle
'system-container-599a2e3...' as resource_id and no resource_type, and it failed
twice with 'resource_type is required' before recovering with 'get 102'. The user
sees those failed tool calls in the chat.

A canonical handle already encodes the type (unifiedresources/ids.go builds ids
as '<type>-<hash>'), so executeGetResource now infers resource_type from the
handle when it's omitted, via resourceTypeFromCanonicalID (the trailing hex hash
segment is unambiguous since no type word is all-hex). A bare numeric VMID still
requires an explicit type. Test TestResourceTypeFromCanonicalID covers it; full
internal/ai/tools green.
2026-06-08 15:49:09 +01:00
rcourtman 9573c6f625 Stop withholding tools from short resource lookups
User asked 'hows esphome' and the Assistant replied it had 'no infrastructure
context or diagnostic tools available' and asked them to run docker ps and say
where esphome lives — for a container (CT 102 esphome on delly) Pulse already
inventories.

Root cause: assistantPromptLooksConversational treated any prompt with <= 3 words
as chit-chat, routing it to the text-only scope that offers ZERO tools. So every
natural short lookup ('hows esphome', 'check frigate', 'grafana cpu') had its
tools withheld and the model genuinely couldn't query. This is the prompt-keyword
router anti-pattern the contract forbids.

Remove the word-count rule: only explicit greeting/meta prompts (hi, thanks, who
are you) are conversational; everything else is offered tools and the model
decides whether to use them. Verified live: 'hows esphome' now returns its real
status (Online, CPU 4.2%, mem 8.4%, no alerts) from Pulse data.

Regression test TestToolsForAssistantTurn_ShortResourceLookupGetsTools asserts
short lookups get tools while greetings stay text-only. Full internal/ai/chat green.
2026-06-08 15:36:58 +01:00