f62f35e24 restored the v5 used | cache | free memory split for Proxmox
nodes and guests, but standalone host agents still reported a flat
used/free pair, so the Machines page memory bar could not show the
reclaimable segment. Flagged by the Machines page v5 parity audit.
- Host agent reports cacheBytes (gopsutil Available minus Free); the
ZFS ARC adjustment recomputes free so used + cache + free still
covers the total.
- ApplyHostReport maps the field into models.Memory.Cache and clamps
inconsistent or older-agent reports so used + cache never exceeds
total.
- AgentMemoryMeta carries cache onto unified resources so the frontend
agent payload exposes it.
- Mock generic hosts split a third of non-used pages as cache, and the
node-linked host conversion now holds the invariant instead of
stacking the node's cache on top of a recomputed free.
- Contracts: monitoring, unified-resources, and storage-recovery now
document the split (also covering the f62f35e24 node/guest surface,
which landed without contract deltas).
Three more findings from the v5-parity audit of the pools table, all
mirroring the disks-table fixes in 809e2c900:
- The Source column rendered an identical PVE badge on every row: the
surface's only live embedding (the Proxmox page) forces
source=proxmox-pve, so the column was pure noise. Drop it and surface
the record's platform as a Source row in the expansion's Configuration
card; the FilterBar source chip still covers unforced embeddings, and
persisted sort-by-source selections fold back to priority.
- PVE-configured PBS storage rows showed raw agent/storage resource ids
in the Host column because the backup-repository host-label chain
skipped the owning node and fell through to parentId. Name-bearing
candidates (parent name, PBS instance name, PVE node, storage nodes)
now come before raw ids.
- Rows tightened from 38px to the 32px platform table standard, and the
freed Source width redistributed across Storage/State/Type/Host/Usage.
Also drops the row model's never-rendered platformLabel/platformToneClass/
compactImpact/compactIssue/compactIssueSummary fields - the same
extracted-but-unrendered drift flagged in #1471.
Verified live in mock mode: headers Storage/State/Type/Host/Protection/
Usage/Growth, backup-vault hosts show pve1-3, expansion shows Source PVE,
rows measure 32px.
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.
Every Ceph-backed storage appeared twice on the pools table: once as the
PVE storage entry (online, mounted capacity) and once as the
cluster-internal pool row synthesized from Ceph telemetry (degraded, raw
pool accounting) - same name, conflicting state and usage, no visual
relationship. Found by the v5-parity audit; v5 rendered one row with a
cluster-health badge.
consolidateCephClusterPoolRecords now collapses the synthesized pool
rows (type ceph on the cluster pseudo-node) into the PVE rows that
mount them, matched by backing-pool detail or name. The pool row's
worse health is lifted onto the survivor (state cell, status detail,
issue summary) so a degraded cluster stays visible on the table, and
unmounted pool rows are kept so standalone-monitored clusters do not
lose their only capacity row. Raw per-pool accounting stays on the
Ceph tab's cluster drawer. Registry synthesis and pool alerting are
untouched.
Also pins the zfsSummary devices field in useStoragePoolDetailModel's
test, missed by b53e1a3e0.
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.
The unified-resource adapter flattened models.ZFSPool to four scalars
(state + R/W/C totals), so scan status and the per-device report never
reached the frontend. The storage detail's ZFS card, the usage bar's
scrub/resilver overlay and ZFS tooltip, and ZFS-device-to-disk matching
were all dead code on the unified path: a degraded pool rendered as a
one-word State cell with no way to see which device failed or that a
resilver was running. Found by the v5-parity audit of the pools table
(same pipeline disease as #1471 on the disks table).
- Add StorageMeta.ZFSPool and populate it in resourceFromStorage
(normalized copy, not aliased).
- Serialize it in monitorStoragePlatformData so the /api/state path
carries it alongside the existing flat scalars.
- Map it through normalizeStorageMeta and the storage record adapter
(meta first, flat platformData fallback).
- Render the per-device report (name, vdev type, state, R/W/C errors,
message) in the detail's ZFS Pool card - the v6 home for what v5
showed via the health-map dots and the degraded-pool warning row.
- Pin the end-to-end path in code_standards_test and update the
unified-resources, monitoring, storage-recovery, and
frontend-primitives contracts.
Verified live in mock mode: degraded pool now shows the resilver scan,
pool error totals, per-device states, and the bar pulse overlay.
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.
Nodes that set up SSH temperature monitoring before v6.0.0-rc.6 have an
authorized_keys entry locked to `sensors -j`, so the pulse-sensors wrapper
never runs and SMART (SATA/SAS) disk temperatures silently never arrive;
only NVMe shows via kernel hwmon (what the #1471 reporters hit). The server
already detected the legacy payload but only logged it.
- models.Temperature gains LegacySensorsFormat, set by parseSensorsJSON
when the payload is raw sensors -j, and preserved across the host-agent
merge; it rides the existing temperatureDetails projection on unified
node resources.
- New PlatformOutdatedSensorSetupNotice on the Proxmox page, data-gated:
collection succeeded AND payload was legacy AND the node has SATA/SAS
disks without temperatures, so nodes whose disk temps arrive via a host
agent stay clean. Copy points at re-running the node setup script, which
replaces the locked key entry and installs the wrapper.
- monitoring.md contract extended with the legacy-format marker behavior.
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.
Two silent-downgrade paths in the v5→v6 migration are now visible and
self-healing:
License load/decrypt failure (was: one log line, no UI state): when
license.enc exists but cannot be read, getTenantComponents now persists
a terminal commercial_migration state with reason
persisted_license_unreadable, so the licence panel and global banner
tell the customer to re-enter their v5 key instead of leaving them to
discover missing Pro features.
Failed startup exchange (was: once per process, Community until manual
restart): exchange failures classified as pending — license-server
blips, DNS failures, rate limits — now schedule a background retry loop
with backoff (30s → 30m cap) that re-attempts the exchange until it
succeeds, hits a terminal classification, or the org's service activates
through another path. The loop stops cleanly on manual activation and
StopAllBackgroundLoops.
Registers the new files in the subsystem registry (cloud-paid
commercial-migration verification policy + shared ownership) and pins
both behaviors in the cloud-paid and api-contracts contracts.
v5.0.0 derived the license.enc AES key as sha256("pulse-license-" +
material) where material was the raw /etc/machine-id file content —
trailing newline included — with hostname and a fixed dev fallback as
alternates. v6 trims machine-id before hashing and dropped both
fallbacks, so licenses activated on v5.0.0 (before .license-key shipped
in v5.0.1) could never be decrypted and the December 2025 lifetime
cohort silently booted as Community.
Collect the raw v5 key materials at construction and try them verbatim
as legacy sha256 candidates during decrypt, alongside the existing
trimmed HKDF/legacy derivations. The write path is unchanged: persistent
random key + HKDF only. Pins the cloud-paid persistence contract to the
full v5.0.x compatibility-loader material set.
Canonical IDs for merged-source hosts (PVE node + pulse-agent) were minted
from whichever identity keys the creating record happened to carry: the
agent record knows the machine ID, the Proxmox node record only knows
cluster+hostname. The registry rebuilds from scratch every tick, so a boot
window where the agent had not checked in yet minted a cluster-keyed ID
(agent-7a62... for delly) while steady state minted a machine-keyed one
(agent-bdd4...). Every restart re-ran the race, fragmenting the
resource_changes journal into per-boot eras (9.4k vs 6.1k rows for the
same host) and silently truncating report availability and UI timelines.
Fix, in the layer that owns identity:
- Persist identity pins (canonical_id <-> machine_id/dmi/cluster/hostname)
in the previously schema-only resource_identities table, written by the
store-backed registry after monitor-adapter rebuilds, diff-aware so
steady-state ticks cost no writes.
- Complete weak incoming identities from the pins before matching and ID
derivation, so a node-only boot window derives the same machine-keyed
canonical ID as steady state. Derivation itself is unchanged; ephemeral
nil-store registries behave exactly as before.
- Expand change-journal reads (Get/Count families, SQLite and memory) to
the full era set recomputed from the pinned identity keys, healing
historical journals at query time with no row migration. Reads keyed by
a stale era ID resolve to the same merged timeline.
Regression tests cover both ingest orders, restart simulation via the
monitor adapter, era ID derivation, and era-merged journal reads on both
store implementations. Contracts updated: unified-resources obligation 25
(durable identity pins), monitoring obligation 10 (adapter rebuild
persistence).
Docs and frontend half of the licensing-policy client work from the v6
upgrade sweep (license findings 5 and 6); the Go classifier and grant
refresh fix land separately once the shared pre-commit guard unblocks.
A 409 MAX_INSTALLATIONS during v5 key migration rendered as 'another v6
activation handoff is still settling. Retry activation from this
instance.' Retrying can never succeed until a slot is freed server-side,
so that copy stranded users. The notice now says the key is already
active on its maximum number of v6 installations and points at
support@pulserelay.pro to free a slot, via the new
exchange_installation_limit reason and free_installation_slot action.
docs/UPGRADE_v6.md gains a 'Breaking Change: Paid Licensing Requires
Connectivity' section: v5 validated keys fully offline, v6 needs
periodic connectivity to license.pulserelay.pro, with roughly 10 days of
offline tolerance (72 hour grant plus 7 day grace) before paid features
drop to Community, plus air-gap guidance and the migrated-key
installation cap. docs/releases/V6_CHANGELOG.md gains the matching
breaking-change bullet.
Note: the two frontend files also carry two small additive hunks from
the parallel persisted-license-unreadable slice (a copy case and its
test, inert until its Go side lands); they share these files and are
included to avoid splitting them mid-flight.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
Two GA-blocking bugs in the auto-update install path:
The pulse-update.service unit was rendered through an unquoted heredoc
containing $${PULSE_SERVICE_NAME}, which bash expands to the installer's
PID, so the unit shipped with ExecCondition checking a service like
"24757{PULSE_SERVICE_NAME}". The condition always failed and systemd
silently skipped every scheduled run, for fresh installs and upgrades
alike. The heredoc now renders the detected service name directly:
ExecCondition=/bin/sh -c 'systemctl is-active --quiet pulse'.
Upgrades also never refreshed the updater assets: a v5 box with
auto-updates enabled keeps pulse-update.timer and autoUpdateEnabled=true,
so the update/reinstall flows never re-ran setup_auto_updates and the
v5.1-pinned /usr/local/bin/pulse-auto-update.sh stayed in place, logging
"Already running latest version" forever instead of selecting v6
releases. The asset-install half of setup_auto_updates is now a shared
install_auto_update_assets(), and a new refresh_auto_updates() rewrites
the helper script and units unconditionally whenever the timer already
exists, without touching system.json or the timer's enabled state. All
five install flows (update, reinstall, --version, --source, fresh) are
wired.
Tests now render the real unit and execute the rendered ExecCondition
against a recording systemctl stub instead of asserting source-text
fragments, pin refresh_auto_updates behavior (stale helper replaced,
system.json and enablement untouched), and pin the call-site wiring.
The deployment-installability contract records both invariants.
Note: rewritten message only — the original local commit carried a
parallel agent's licensing commit message due to a shared-index race;
the tree is byte-identical to the hook-verified original.
v5 documented PORT as a legacy alias for FRONTEND_PORT. v6 dropped the
fallback silently, so an install configured with PORT=8080 upgrades to
v6 and comes up on 7655 with no explanation, unreachable behind its
existing port mapping or reverse proxy.
Honor PORT again at lowest precedence (FRONTEND_PORT wins when both are
set) and log a deprecation warning at startup, mirroring the existing
legacy DISABLE_AUTH handling. Document PORT as deprecated in
CONFIGURATION.md and note the compatibility behavior in the v6
changelog.
Polling direction (provider to client site, 8006/8007) versus agent
check-in direction, per-client steps with the guided pveum setup command,
privilege-separated token caveat, TOFU certificate pinning, and the
overlapping-RFC1918 note that container-per-client isolation makes moot.
The variable signs hosted entitlement leases; the trial-activation name is
left over from the retired trial era and reads as trial machinery to an
operator generating their licensing root key. The provider MSP bundle has
no installed base yet, so the canonical rename is free today and frozen
the moment the first design partner installs.
- Control plane reads CP_ENTITLEMENT_SIGNING_PRIVATE_KEY first and falls
back to CP_TRIAL_ACTIVATION_PRIVATE_KEY, so existing Pulse-hosted cloud
deployments (deploy/cloud, hibernated snapshot) keep working unchanged.
- deploy/provider-msp (.env.example, compose, setup.sh), MSP.md, and the
install-test pins use the canonical name; error messages name it too.
- deploy/cloud intentionally keeps the legacy name: that stack historically
signed hosted trial activations, and its snapshot predates the rename.
- MSP.md now leads with deploy/provider-msp/ (compose stack, setup.sh,
upgrade.sh, run-install-proof.sh), documents the HTTPS requirement
(__Host- portal session cookie) and the pulse.provider-msp.role labels
workspace provisioning requires, and explains the licence/lease chain
including licence-expiry behavior.
- setup.sh derives and prints the lease signing public key the provider
MSP licence must bind (also via --print-lease-signing-public-key), and
the missing-licence error now includes it with request instructions.
- .env.example documents the CP_TRIAL_ACTIVATION_PRIVATE_KEY binding.
Two gaps found by exercising the MSP pilot path live on a throwaway
multi-tenant instance:
1. CheckAccess granted any authenticated principal access to the default
org, so a token bound to a client org could read the provider's own
default-org estate if it leaked from a client site. Org-bound tokens
now fall through to the explicit binding check for the default org;
authenticated users and legacy unbound tokens keep default-org access,
and binding "default" explicitly still grants it.
2. The webhook private-target allowlist (instance-wide system setting)
only ever reached the default org's notification manager on
startup/reload, and only the request-context org on settings update.
Tenant orgs' webhooks to private targets (per-client Gotify over VPN,
the canonical MSP alert route) failed SSRF validation with no org-side
remedy, and any allowlist died with a restart. Settings updates and
reloads now fan out to every live tenant manager via the new
MultiTenantMonitor.ForEachMonitor, and tenant monitors inherit the
persisted allowlist and public URL at creation.
Both fixes verified live: org-bound token vs default org returns 403;
client-org webhooks to a private target succeed after restart and for
orgs created after the allowlist was saved. MSP.md validation checklist
gains the default-org probe and the allowlist guidance; MULTI_TENANT.md
documents the binding semantics. Contracts updated for api-contracts,
security-privacy, and monitoring with adjacency notes for
agent-lifecycle, storage-recovery, and performance-and-scalability.
Hosted tenant containers received PULSE_TENANT_ID but not
PULSE_TENANT_NAME, so alert webhook payloads from provider-hosted client
runtimes fell back to the raw tenant ID instead of a human-readable
workspace label. Resolve the display name from the tenant registry at
container-create time via a ManagerConfig resolver and stamp it
alongside the tenant ID. Display-name changes after creation apply on
the next runtime rollout, which recreates the container with freshly
resolved env.
Adds TestContract_MetadataGetPayloadsUseZeroRecordsInsteadOf404: empty
guest/docker metadata maps must serialize as {} (never null) and a
missing resource must return a 200 zero record echoing the requested ID
(never a 404). This is the proof companion to the
metadata_handlers_shared.go consolidation in the previous commit — it
was authored with that change but lost to a shared-index race at commit
time.
Alert webhook payloads now carry the tenant that fired them, so MSP/PSA
receivers (ConnectWise and similar) can route tickets by client without
inferring the tenant from which webhook endpoint fired.
- WebhookPayloadData gains TenantID/TenantName, exposed to custom
templates as {{.TenantID}}/{{.TenantName}}.
- Defaults come from PULSE_TENANT_ID/PULSE_TENANT_NAME (already injected
into provider-hosted client runtimes; name falls back to ID).
- Shared-process multi-tenant orgs override via a lazy org-backed
resolver wired in MultiTenantMonitor.GetMonitor, so display-name
renames are picked up without restart.
- Generic service template emits a tenant block when identity is set,
omits it otherwise; single-tenant payloads are unchanged.
- Notifications and monitoring subsystem contracts updated with the
tenant-identity ownership boundary; guardrail test pins the org
wiring.
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.
Clears the six dupl pairs in internal/monitoring:
- poll_providers.go: PVE/PBS/PMG listInstances / describeInstances /
connectionStatus closures collapse into generic sortedClientNames,
describeProviderInstances, and providerConnectionStatuses helpers; the
PBS/PMG adapters are now built by newPrefixedPollProvider from a
prefixedPollProviderSpec (prefix drives status keys, health keys, and
the fallback instance name).
- truenas_poller.go / vmware_poller.go: the Start scheduling loop
(double-start guard, stopped-channel handshake, sync+poll cadence)
moves to startPollerLoop, and the active-connection config policy
(enabled instances keyed by trimmed connection ID, defaults applied)
moves to loadActiveInstanceConfigs, both in the new
platform_poller_shared.go.
- monitor_polling_vm.go / monitor_polling_containers.go: traditional
polling now records guest series via the existing canonical
recordGuestMetric helper (io/network sentinels preserve the historical
cpu/memory/disk-only behavior on this path); the source-shape
guardrails in canonical_guardrails_test.go and
memory_source_catalog_test.go pin the new delegation.
- mock_metrics_history.go: native and TrueNAS disk seeding share one
seedDiskTelemetry closure.
Proof: new TestTrueNASPollerDoubleStartKeepsSingleLoop pins the shared
lifecycle loop; new TestSeedMockMetricsHistory_DiskTelemetryParityAcrossNativeAndTrueNAS
pins four-series parity for both disk sources. Contract Extension Points
name the shared wiring layer and poller scaffold.
Full internal/monitoring test suite passes.
The k8s workload/config adapters in unifiedresources each hand-rolled the
same Resource literal (Technology, LastSeen, UpdatedAt, Kubernetes facet,
label tags) plus namespacedKubernetesIdentity return; dupl flagged the
StatefulSet/Job and ReplicaSet/DaemonSet clones. Fold the scaffold into
namespacedKubernetesResource and delegate all 19 namespaced adapters
(Service included) to it; cluster-scoped kinds (PV, StorageClass,
Namespace) keep bespoke identity construction.
Contract: unified-resources Extension Points now name the shared scaffold
as the way new namespaced kinds assemble resources. Proof: new
TestNamespacedKubernetesResourceScaffold pins the scaffold fields and
namespaced hostname identity.
Full internal/unifiedresources test suite passes.
Completes the deferred UI half of dad1152fc: the content_replaced
lifecycle event (emitted when a same-key re-detection's text is
substantially different from the existing finding — a key collision)
now renders as "Re-detected with different details" in the finding
timeline instead of the identifier-formatter fallback, so the operator
reads what actually happened in plain language. The event metadata
already carries the previous and new titles.
Ceremony this line deferred from the backend commit: patrol-intelligence
contract Current State entry (lifecycle label map rule + the new label),
ai-runtime cross-reference updated, and the label pinned in
FindingsPanel.test.ts lifecycleLabels (the subsystem's accepted proof).
111 FindingsPanel tests green.
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.
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.