diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 48db52c8c..aaf7e57dd 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -187,6 +187,7 @@ Every field is listed below with the reason it exists. Nothing else is included | Pulse Intelligence approved action refusals: target changed 30d | `1` | Count pre-dispatch refusals caused by target or inventory drift after planning without sending target, inventory, or command details | | Pulse Intelligence approved action refusals: prerequisite 30d | `1` | Count pre-dispatch refusals caused by an unavailable inspection, busy or unhealthy package manager, or failed bounded preflight without sending raw errors, targets, or command details | | Pulse Intelligence approved action refusals: contract 30d | `1` | Count pre-dispatch refusals caused by an invalid typed action contract without sending payloads, targets, or command details | +| Pulse Intelligence approved action refusals: uncoded 30d | `1` | Count pre-dispatch refusals that arrived with no machine reason code, which is what an agent older than the typed refusal contract reports, without sending raw error or action content | | Pulse Intelligence approved action refusals: other 30d | `1` | Count pre-dispatch refusals not covered by the fixed categories without sending raw error or action content | | Pulse Intelligence verified finding resolutions 30d | `1` | Count approved Patrol-origin actions whose execution succeeded and whose linked finding postcondition was independently confirmed, without sending finding IDs, action IDs, evidence, resources, or fix details | | Pulse Intelligence approved action last failure reason 30d | `plan_drift` | See one fixed machine reason code for the most recent approved-action failure in the current 30-day telemetry window without sending error text, action output, command text, resource IDs, or actors | diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 00127eac3..473ce1f48 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -923,7 +923,12 @@ agent timestamp. It cannot approve, admit, reserve, execute, or verify a mutation. Current agents advertise protocol version 1 at registration; a server with the transport but an older connected agent fails concrete feasibility closed, while internal executors that predate the optional -transport retain their compatibility path. Dispatch still repeats all local +transport retain their compatibility path. An agent older than the bounded +refusal-code vocabulary reports pre-mutation refusals with no reason code at +all. The API layer records those under the `preflight_refused` legacy aggregate +and aggregate telemetry counts them as `uncoded` rather than `other`, so the +share of the fleet still awaiting an agent capable of typed refusals stays +measurable and is never mistaken for a categorization failure. Dispatch still repeats all local preconditions after durable admission because preflight evidence is not a lease and target state may race after approval. Proxmox VM and LXC lifecycle affordances follow the same adjacent boundary: diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index ecc31bcaa..9a3a586e3 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -9312,14 +9312,23 @@ together: `type Ping struct` in `internal/telemetry/telemetry.go`, the Retired columns stay in the live receiver database rather than being dropped: migrations only add, the columns hold real rows from the schema-v6 window, and nothing writes them once the receiver struct loses the fields. -### Telemetry payload parity spans three surfaces at schema v8 +### Telemetry payload parity spans three surfaces at schema v9 -Schema v8 preserves the same three-surface parity requirement and adds +Schema v8 preserved the same three-surface parity requirement and added content-free approved-action refusal counters for target change, prerequisite failure, and invalid typed contracts. These counters partition agent-side pre-mutation refusals that previously accumulated in `other`; no raw error, command, target, resource identifier, or action payload is exported. +Schema v9 adds the content-free `uncoded` refusal counter and completes that +partition. A pre-dispatch refusal that carries no machine reason code is +recorded under the legacy `preflight_refused` aggregate, which is what every +agent older than the typed refusal contract reports, and it must not be counted +as `other`. `other` means a typed code this server does not recognise, so +collapsing the two made a partition starved by agent rollout read exactly like +a partition that is wrong. The `uncoded` counter is a count only; the refusal's +error text, command, target, and action payload stay on the install. + ### Per-tenant resource stores are released on offboarding and shutdown `ResourceHandlers.getStore` opens a SQLite handle per org and caches it for the diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 08652de3f..fb034299d 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -2104,9 +2104,14 @@ actor, and every audit row read stay on the install. ### Telemetry ingestion matches the released sender while storage stays compatible -The active outbound contract is schema v8. It adds content-free approved-action -refusal counters for target change, prerequisite failure, and invalid typed -contract so agent-side pre-mutation failures no longer collapse into `other`. +The active outbound contract is schema v9. Schema v8 added content-free +approved-action refusal counters for target change, prerequisite failure, and +invalid typed contract so agent-side pre-mutation failures no longer collapse +into `other`. Schema v9 completes that split with a content-free `uncoded` +counter for refusals that carried no machine reason code at all, which is what +every agent older than the typed refusal contract reports. Without it a split +starved by agent rollout is indistinguishable from a broken one, because both +present as `other` absorbing every refusal. The earlier draft schema-v8 `business_estate` field was reverted and must not remain in the license server's accepted ping struct merely because a private receiver build and database migration briefly carried it. Existing deployed databases need no diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a5990e3d1..69a45f83e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -369,6 +369,15 @@ verification stdout, command text, resource IDs, or backup identities. The legac the legacy completed/resolved/value `proActivation*` fields mirror the same Patrol control outcome classifier as compatibility aliases and do not create a second storage/recovery signal. + +Persisted Patrol run history carries an uncapped per-UTC-day run tally beside +its capped newest-first run list. The list is bounded for the operator-facing +history and saturates within hours on a normally scheduled install, so it +cannot serve as a run count over a reporting window; the tally advances from a +persisted high-water mark, is pruned to its retention bound, and is a count of +runs only. It never records run identity, scope resource IDs, finding IDs, +evidence, or narrative, and it is not recovery coverage, backup verification, +or storage-health proof. First-party workflow starter activity recorded through shared `internal/api/` handlers, including Pro activation entry-point telemetry for the same operations-loop prompt, is likewise API/privacy/commercial activation evidence diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index 48db52c8c..aaf7e57dd 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -187,6 +187,7 @@ Every field is listed below with the reason it exists. Nothing else is included | Pulse Intelligence approved action refusals: target changed 30d | `1` | Count pre-dispatch refusals caused by target or inventory drift after planning without sending target, inventory, or command details | | Pulse Intelligence approved action refusals: prerequisite 30d | `1` | Count pre-dispatch refusals caused by an unavailable inspection, busy or unhealthy package manager, or failed bounded preflight without sending raw errors, targets, or command details | | Pulse Intelligence approved action refusals: contract 30d | `1` | Count pre-dispatch refusals caused by an invalid typed action contract without sending payloads, targets, or command details | +| Pulse Intelligence approved action refusals: uncoded 30d | `1` | Count pre-dispatch refusals that arrived with no machine reason code, which is what an agent older than the typed refusal contract reports, without sending raw error or action content | | Pulse Intelligence approved action refusals: other 30d | `1` | Count pre-dispatch refusals not covered by the fixed categories without sending raw error or action content | | Pulse Intelligence verified finding resolutions 30d | `1` | Count approved Patrol-origin actions whose execution succeeded and whose linked finding postcondition was independently confirmed, without sending finding IDs, action IDs, evidence, resources, or fix details | | Pulse Intelligence approved action last failure reason 30d | `plan_drift` | See one fixed machine reason code for the most recent approved-action failure in the current 30-day telemetry window without sending error text, action output, command text, resource IDs, or actors | diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 8f09b7137..3e9d2fc2e 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -24396,3 +24396,34 @@ func TestContract_ResourceListReportsPlatformAdmission(t *testing.T) { t.Fatalf("provider-owned agent must not admit the standalone page, got %+v", admission) } } + +// The schema-v9 approved-action refusal partition must keep an uncoded refusal +// out of the "other" bucket. `other` means a typed reason code this server does +// not recognise; a refusal that carried no reason code at all is what every +// agent older than the typed refusal contract reports, and counting the two +// together makes a partition starved by agent rollout look like a broken one. +func TestApprovedActionRefusalPartitionSeparatesUncodedFromOther(t *testing.T) { + // An agent result with no reason code lands on the legacy aggregate. + if got := actionPreflightReasonCode(""); got != actionRefusalUncoded { + t.Fatalf("actionPreflightReasonCode(\"\") = %q, want %q", got, actionRefusalUncoded) + } + if got := actionPreflightReasonCode(" "); got != actionRefusalUncoded { + t.Fatalf("actionPreflightReasonCode(whitespace) = %q, want %q", got, actionRefusalUncoded) + } + // A typed code from a current agent is preserved rather than aggregated. + if got := actionPreflightReasonCode("target_state_changed"); got != "target_state_changed" { + t.Fatalf("typed reason code was not preserved, got %q", got) + } + + // The legacy aggregate is counted as uncoded, never as other. + if got := pulseIntelligenceApprovedActionRefusalCategory(actionRefusalUncoded); got != "uncoded" { + t.Fatalf("category(%q) = %q, want uncoded", actionRefusalUncoded, got) + } + if got := pulseIntelligenceApprovedActionRefusalCategory("pre_dispatch_refused"); got != "uncoded" { + t.Fatalf("category(pre_dispatch_refused) = %q, want uncoded", got) + } + // An unrecognised typed code stays in other, so the two remain separable. + if got := pulseIntelligenceApprovedActionRefusalCategory("some_future_typed_code"); got != "other" { + t.Fatalf("category(some_future_typed_code) = %q, want other", got) + } +} diff --git a/internal/api/docker_container_action_result.go b/internal/api/docker_container_action_result.go index e72828545..1d12acebb 100644 --- a/internal/api/docker_container_action_result.go +++ b/internal/api/docker_container_action_result.go @@ -205,6 +205,11 @@ func dockerContainerUpdateFactSummary(facts agentexec.DockerContainerUpdateResul return summary } +// actionRefusalUncoded is the legacy aggregate recorded when a pre-mutation +// refusal arrives with no machine reason code. Agents older than the typed +// refusal contract report every refusal this way. +const actionRefusalUncoded = "preflight_refused" + // actionPreflightReasonCode preserves typed agent refusals while retaining the // legacy aggregate for older agents that do not yet send a reason_code. func actionPreflightReasonCode(reasonCode string) string { @@ -212,7 +217,7 @@ func actionPreflightReasonCode(reasonCode string) string { if agentexec.IsActionRefusalReasonCode(reasonCode) { return reasonCode } - return "preflight_refused" + return actionRefusalUncoded } func shortDockerDigest(digest string) string { diff --git a/internal/api/telemetry_pulse_intelligence.go b/internal/api/telemetry_pulse_intelligence.go index 4427a0366..b4b650e5b 100644 --- a/internal/api/telemetry_pulse_intelligence.go +++ b/internal/api/telemetry_pulse_intelligence.go @@ -140,6 +140,8 @@ func accumulatePulseIntelligenceApprovedActionOutcomes(snapshot *telemetry.Pulse snapshot.ApprovedActionRefusalsPrerequisite30d++ case "contract": snapshot.ApprovedActionRefusalsContract30d++ + case "uncoded": + snapshot.ApprovedActionRefusalsUncoded30d++ default: snapshot.ApprovedActionRefusalsOther30d++ } @@ -204,6 +206,13 @@ func pulseIntelligenceApprovedActionRefusalCategory(reason string) string { return "prerequisite" case "action_contract_invalid": return "contract" + case actionRefusalUncoded, "pre_dispatch_refused": + // The refusal carried no machine reason code, so there is nothing to + // categorize. This is what an agent older than the typed refusal + // contract reports, and it must stay separable from a code this server + // simply does not recognise: the first says the split is waiting on + // agent rollout, the second says the split is wrong. + return "uncoded" default: return "other" } diff --git a/internal/api/telemetry_pulse_intelligence_test.go b/internal/api/telemetry_pulse_intelligence_test.go index 167d04f2c..b06c26ab7 100644 --- a/internal/api/telemetry_pulse_intelligence_test.go +++ b/internal/api/telemetry_pulse_intelligence_test.go @@ -340,15 +340,17 @@ func TestGetPulseIntelligenceActionTelemetry_AttributesApprovedActionFailureCaus got.ApprovedActionRefusalsTargetChanged30d != 0 || got.ApprovedActionRefusalsPrerequisite30d != 0 || got.ApprovedActionRefusalsContract30d != 0 || + got.ApprovedActionRefusalsUncoded30d != 0 || got.ApprovedActionRefusalsOther30d != 0 { t.Fatalf( - "refusal categories = %d/%d/%d/%d/%d/%d/%d, want 1/0/0/0/0/0/0", + "refusal categories = %d/%d/%d/%d/%d/%d/%d/%d, want 1/0/0/0/0/0/0/0", got.ApprovedActionRefusalsPlanStale30d, got.ApprovedActionRefusalsPolicy30d, got.ApprovedActionRefusalsCapability30d, got.ApprovedActionRefusalsTargetChanged30d, got.ApprovedActionRefusalsPrerequisite30d, got.ApprovedActionRefusalsContract30d, + got.ApprovedActionRefusalsUncoded30d, got.ApprovedActionRefusalsOther30d, ) } @@ -461,6 +463,8 @@ func TestPulseIntelligenceApprovedActionRefusalCategory(t *testing.T) { "package_manager_unhealthy": "prerequisite", "cleanup_preflight_failed": "prerequisite", "action_contract_invalid": "contract", + "preflight_refused": "uncoded", + "pre_dispatch_refused": "uncoded", "future_reason": "other", } for reason, want := range tests { diff --git a/internal/config/patrol_run_tally_test.go b/internal/config/patrol_run_tally_test.go new file mode 100644 index 000000000..ceeb4122f --- /dev/null +++ b/internal/config/patrol_run_tally_test.go @@ -0,0 +1,99 @@ +package config + +import ( + "testing" + "time" +) + +// A patrol run history that has already saturated the operator-facing cap must +// still report the true run count for a thirty-day telemetry window. +func TestPatrolRunTallySurvivesHistoryCap(t *testing.T) { + p := NewConfigPersistence(t.TempDir()) + now := time.Now().UTC() + since := now.AddDate(0, 0, -30) + + // Patrol every five minutes for three days, saving the newest-first + // capped window after each run exactly as the run history store does. + const cap = 100 + total := 0 + runs := make([]PatrolRunRecord, 0, cap) + for at := now.AddDate(0, 0, -3); at.Before(now); at = at.Add(5 * time.Minute) { + runs = append([]PatrolRunRecord{{ID: at.Format(time.RFC3339Nano), StartedAt: at, CompletedAt: at}}, runs...) + if len(runs) > cap { + runs = runs[:cap] + } + total++ + if err := p.SavePatrolRunHistory(runs); err != nil { + t.Fatalf("SavePatrolRunHistory: %v", err) + } + } + + loaded, err := p.LoadPatrolRunHistory() + if err != nil { + t.Fatalf("LoadPatrolRunHistory: %v", err) + } + if len(loaded.Runs) != cap { + t.Fatalf("retained runs = %d, want the capped %d", len(loaded.Runs), cap) + } + if got := loaded.PatrolRunsSince(since); got != total { + t.Fatalf("PatrolRunsSince = %d, want %d (the cap would have reported %d)", got, total, cap) + } +} + +// Repeated saves of an overlapping newest-first window must not double count. +func TestPatrolRunTallyDoesNotDoubleCount(t *testing.T) { + p := NewConfigPersistence(t.TempDir()) + now := time.Now().UTC() + runs := []PatrolRunRecord{ + {ID: "c", StartedAt: now.Add(-1 * time.Hour), CompletedAt: now.Add(-1 * time.Hour)}, + {ID: "b", StartedAt: now.Add(-2 * time.Hour), CompletedAt: now.Add(-2 * time.Hour)}, + {ID: "a", StartedAt: now.Add(-3 * time.Hour), CompletedAt: now.Add(-3 * time.Hour)}, + } + for i := 0; i < 4; i++ { + if err := p.SavePatrolRunHistory(runs); err != nil { + t.Fatalf("SavePatrolRunHistory: %v", err) + } + } + loaded, err := p.LoadPatrolRunHistory() + if err != nil { + t.Fatalf("LoadPatrolRunHistory: %v", err) + } + if got := loaded.PatrolRunsSince(now.AddDate(0, 0, -30)); got != 3 { + t.Fatalf("PatrolRunsSince = %d, want 3 after four saves of the same window", got) + } +} + +// An install upgrading into the tally has no tallied days yet, so the counter +// must fall back to the run list rather than reporting zero. +func TestPatrolRunsSinceFallsBackToHistory(t *testing.T) { + now := time.Now().UTC() + data := &PatrolRunHistoryData{Runs: []PatrolRunRecord{ + {ID: "a", StartedAt: now.Add(-2 * time.Hour), CompletedAt: now.Add(-2 * time.Hour)}, + {ID: "b", StartedAt: now.Add(-1 * time.Hour), CompletedAt: now.Add(-1 * time.Hour)}, + {ID: "old", StartedAt: now.AddDate(0, 0, -40), CompletedAt: now.AddDate(0, 0, -40)}, + }} + if got := data.PatrolRunsSince(now.AddDate(0, 0, -30)); got != 2 { + t.Fatalf("PatrolRunsSince = %d, want 2", got) + } +} + +// Days that fall out of the retention window must be pruned so the tally stays +// bounded no matter how long an install runs. +func TestPatrolRunTallyPrunesOldDays(t *testing.T) { + now := time.Now().UTC() + data := &PatrolRunHistoryData{DailyRuns: map[string]int{ + PatrolRunTallyDayKey(now.AddDate(0, 0, -400)): 12, + PatrolRunTallyDayKey(now.AddDate(0, 0, -40)): 7, + PatrolRunTallyDayKey(now.AddDate(0, 0, -1)): 3, + }} + advancePatrolRunTally(data, nil, now) + if _, ok := data.DailyRuns[PatrolRunTallyDayKey(now.AddDate(0, 0, -400))]; ok { + t.Fatal("400-day-old tally day was not pruned") + } + if _, ok := data.DailyRuns[PatrolRunTallyDayKey(now.AddDate(0, 0, -40))]; ok { + t.Fatal("40-day-old tally day was not pruned") + } + if data.DailyRuns[PatrolRunTallyDayKey(now.AddDate(0, 0, -1))] != 3 { + t.Fatal("yesterday's tally day was pruned") + } +} diff --git a/internal/config/persistence.go b/internal/config/persistence.go index fa4c09744..d12a590d2 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -2653,6 +2653,98 @@ type PatrolRunHistoryData struct { Version int `json:"version"` LastSaved time.Time `json:"last_saved"` Runs []PatrolRunRecord `json:"runs"` + + // DailyRuns counts completed patrol runs per UTC day. Runs is deliberately + // capped for the operator-facing history, so on any install patrolling on a + // normal schedule it saturates hours into a thirty-day reporting window and + // cannot answer "how often did Patrol actually run". This tally is the + // uncapped answer and costs one small integer per retained day. + DailyRuns map[string]int `json:"daily_runs,omitempty"` + // RunTallyThrough is the newest run observation already folded into + // DailyRuns. History is persisted as a full newest-first list on every + // debounced save, so the tally advances from this high-water mark rather + // than re-counting runs it has already seen. + RunTallyThrough time.Time `json:"run_tally_through,omitempty"` +} + +// patrolRunTallyRetentionDays bounds DailyRuns. It exceeds the thirty-day +// telemetry window by a day so the day the window opens on is still present +// when the tally is read. +const patrolRunTallyRetentionDays = 31 + +// PatrolRunTallyDayKey renders the UTC day key used by PatrolRunHistoryData.DailyRuns. +func PatrolRunTallyDayKey(at time.Time) string { + return at.UTC().Format("2006-01-02") +} + +// patrolRunObservedAt reports when a run should be counted, preferring the +// completion time and falling back to the start time for runs that never +// recorded one. +func patrolRunObservedAt(run PatrolRunRecord) time.Time { + if !run.CompletedAt.IsZero() { + return run.CompletedAt + } + return run.StartedAt +} + +// advancePatrolRunTally folds runs newer than the existing high-water mark into +// the daily tally and prunes days outside the retention window. +func advancePatrolRunTally(data *PatrolRunHistoryData, runs []PatrolRunRecord, now time.Time) { + if data == nil { + return + } + if data.DailyRuns == nil { + data.DailyRuns = make(map[string]int, patrolRunTallyRetentionDays) + } + newest := data.RunTallyThrough + for _, run := range runs { + observedAt := patrolRunObservedAt(run) + if observedAt.IsZero() || !observedAt.After(data.RunTallyThrough) { + continue + } + data.DailyRuns[PatrolRunTallyDayKey(observedAt)]++ + if observedAt.After(newest) { + newest = observedAt + } + } + data.RunTallyThrough = newest + + cutoff := PatrolRunTallyDayKey(now.UTC().AddDate(0, 0, -patrolRunTallyRetentionDays)) + for day := range data.DailyRuns { + if day < cutoff { + delete(data.DailyRuns, day) + } + } +} + +// PatrolRunsSince counts patrol runs observed at or after since. It prefers the +// uncapped daily tally and falls back to the capped run list, taking whichever +// is larger so an install that has not yet accumulated a tally never reports +// fewer runs than it did before the tally existed. The tally is day-granular, +// so the day containing since is counted whole. +func (data *PatrolRunHistoryData) PatrolRunsSince(since time.Time) int { + if data == nil { + return 0 + } + fromHistory := 0 + for _, run := range data.Runs { + observedAt := patrolRunObservedAt(run) + if observedAt.IsZero() || observedAt.Before(since) { + continue + } + fromHistory++ + } + fromTally := 0 + sinceDay := PatrolRunTallyDayKey(since) + for day, count := range data.DailyRuns { + if day >= sinceDay { + fromTally += count + } + } + if fromTally > fromHistory { + return fromTally + } + return fromHistory } // PatrolRunRecord represents a single patrol check run @@ -3481,13 +3573,20 @@ func (c *ConfigPersistence) LoadWorkflowPromptActivityHistory() (*WorkflowPrompt ) } -// SavePatrolRunHistory persists patrol run history to disk +// SavePatrolRunHistory persists patrol run history to disk, carrying the +// uncapped daily run tally forward across the capped run list it replaces. func (c *ConfigPersistence) SavePatrolRunHistory(runs []PatrolRunRecord) error { + now := time.Now() data := PatrolRunHistoryData{ Version: 1, - LastSaved: time.Now(), + LastSaved: now, Runs: runs, } + if existing, err := c.LoadPatrolRunHistory(); err == nil && existing != nil { + data.DailyRuns = existing.DailyRuns + data.RunTallyThrough = existing.RunTallyThrough + } + advancePatrolRunTally(&data, runs, now) return saveHistoryData(c, c.aiPatrolRunsFile, data, len(runs), "patrol run history", "Patrol run history") } @@ -3510,6 +3609,9 @@ func (c *ConfigPersistence) LoadPatrolRunHistory() (*PatrolRunHistoryData, error if data.Runs == nil { data.Runs = make([]PatrolRunRecord, 0) } + if data.DailyRuns == nil { + data.DailyRuns = make(map[string]int) + } }, func(data *PatrolRunHistoryData) error { jsonData, err := json.Marshal(data) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index c0e2def87..e7d18de4c 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -150,7 +150,11 @@ const ( // Schema v8 splits agent-side pre-mutation refusals into target-change, // prerequisite, and invalid-contract categories so the legacy "other" // bucket no longer hides actionable product failures. - TelemetrySchemaVersion = 8 + // Schema v9 separates refusals that carried no machine reason code at all + // from refusals whose code is simply unrecognised. Agents that predate the + // typed refusal contract report the former, and folding them into "other" + // made a starved split indistinguishable from a broken one. + TelemetrySchemaVersion = 9 ) type installIDRecord struct { @@ -343,6 +347,7 @@ type Ping struct { PulseIntelligenceApprovedActionRefusalsTargetChanged30d int `json:"pulse_intelligence_approved_action_refusals_target_changed_30d"` PulseIntelligenceApprovedActionRefusalsPrerequisite30d int `json:"pulse_intelligence_approved_action_refusals_prerequisite_30d"` PulseIntelligenceApprovedActionRefusalsContract30d int `json:"pulse_intelligence_approved_action_refusals_contract_30d"` + PulseIntelligenceApprovedActionRefusalsUncoded30d int `json:"pulse_intelligence_approved_action_refusals_uncoded_30d"` PulseIntelligenceApprovedActionRefusalsOther30d int `json:"pulse_intelligence_approved_action_refusals_other_30d"` PulseIntelligenceVerifiedFindingResolutions30d int `json:"pulse_intelligence_verified_finding_resolutions_30d"` PulseIntelligenceApprovedActionLastFailureReason30d string `json:"pulse_intelligence_approved_action_last_failure_reason_30d,omitempty"` @@ -483,6 +488,7 @@ type Snapshot struct { PulseIntelligenceApprovedActionRefusalsTargetChanged30d int PulseIntelligenceApprovedActionRefusalsPrerequisite30d int PulseIntelligenceApprovedActionRefusalsContract30d int + PulseIntelligenceApprovedActionRefusalsUncoded30d int PulseIntelligenceApprovedActionRefusalsOther30d int PulseIntelligenceVerifiedFindingResolutions30d int PulseIntelligenceApprovedActionLastFailureReason30d string @@ -528,7 +534,11 @@ type PulseIntelligenceActionSnapshot struct { ApprovedActionRefusalsTargetChanged30d int ApprovedActionRefusalsPrerequisite30d int ApprovedActionRefusalsContract30d int - ApprovedActionRefusalsOther30d int + // ApprovedActionRefusalsUncoded30d counts pre-dispatch refusals that + // arrived without any machine reason code, which is what an agent older + // than the typed refusal contract reports. + ApprovedActionRefusalsUncoded30d int + ApprovedActionRefusalsOther30d int // VerifiedFindingResolutions30d counts completed, approved Patrol-origin // actions whose postcondition was independently confirmed. No finding or // action identity leaves the runtime. @@ -1078,6 +1088,7 @@ func applySnapshot(base Ping, fn SnapshotFunc) Ping { ping.PulseIntelligenceApprovedActionRefusalsTargetChanged30d = s.PulseIntelligenceApprovedActionRefusalsTargetChanged30d ping.PulseIntelligenceApprovedActionRefusalsPrerequisite30d = s.PulseIntelligenceApprovedActionRefusalsPrerequisite30d ping.PulseIntelligenceApprovedActionRefusalsContract30d = s.PulseIntelligenceApprovedActionRefusalsContract30d + ping.PulseIntelligenceApprovedActionRefusalsUncoded30d = s.PulseIntelligenceApprovedActionRefusalsUncoded30d ping.PulseIntelligenceApprovedActionRefusalsOther30d = s.PulseIntelligenceApprovedActionRefusalsOther30d ping.PulseIntelligenceVerifiedFindingResolutions30d = s.PulseIntelligenceVerifiedFindingResolutions30d ping.PulseIntelligenceApprovedActionLastFailureReason30d = s.PulseIntelligenceApprovedActionLastFailureReason30d diff --git a/pkg/server/telemetry_pulse_intelligence.go b/pkg/server/telemetry_pulse_intelligence.go index 722d67363..d2773c20a 100644 --- a/pkg/server/telemetry_pulse_intelligence.go +++ b/pkg/server/telemetry_pulse_intelligence.go @@ -49,6 +49,7 @@ func applyPulseIntelligenceTelemetrySnapshot( snap.PulseIntelligenceApprovedActionRefusalsTargetChanged30d = actionSnapshot.ApprovedActionRefusalsTargetChanged30d snap.PulseIntelligenceApprovedActionRefusalsPrerequisite30d = actionSnapshot.ApprovedActionRefusalsPrerequisite30d snap.PulseIntelligenceApprovedActionRefusalsContract30d = actionSnapshot.ApprovedActionRefusalsContract30d + snap.PulseIntelligenceApprovedActionRefusalsUncoded30d = actionSnapshot.ApprovedActionRefusalsUncoded30d snap.PulseIntelligenceApprovedActionRefusalsOther30d = actionSnapshot.ApprovedActionRefusalsOther30d snap.PulseIntelligenceVerifiedFindingResolutions30d = actionSnapshot.VerifiedFindingResolutions30d snap.PulseIntelligenceApprovedActionLastFailureReason30d = actionSnapshot.ApprovedActionLastFailureReason30d @@ -224,6 +225,11 @@ func applyPulseIntelligencePatrolRunSnapshot(snap *telemetry.Snapshot, persisten if err != nil || history == nil { return } + // Run volume comes from the uncapped daily tally. Counting history.Runs + // directly saturates at the operator-facing history cap, which on an install + // patrolling on a normal schedule is reached within hours of a thirty-day + // window rather than at its end. + snap.PulseIntelligencePatrolRuns30d += history.PatrolRunsSince(since) for _, run := range history.Runs { observedAt := run.CompletedAt if observedAt.IsZero() { @@ -232,7 +238,6 @@ func applyPulseIntelligencePatrolRunSnapshot(snap *telemetry.Snapshot, persisten if observedAt.IsZero() || observedAt.Before(since) { continue } - snap.PulseIntelligencePatrolRuns30d++ if run.NewFindings > 0 { snap.PulseIntelligencePatrolNewFindings30d += run.NewFindings } diff --git a/pkg/server/telemetry_pulse_intelligence_test.go b/pkg/server/telemetry_pulse_intelligence_test.go index af2da749f..ed04dd7aa 100644 --- a/pkg/server/telemetry_pulse_intelligence_test.go +++ b/pkg/server/telemetry_pulse_intelligence_test.go @@ -1039,3 +1039,34 @@ func TestApplyPulseIntelligenceAdoptionSnapshot_CompleteLoopRequiresIssueEvidenc }) } } + +// Patrol run volume must reflect every run in the window, not the operator +// history cap the runs are stored under. +func TestApplyPulseIntelligencePatrolRunSnapshotIgnoresHistoryCap(t *testing.T) { + persistence := config.NewConfigPersistence(t.TempDir()) + now := time.Now().UTC() + since := now.AddDate(0, 0, -30) + + const historyCap = 100 + total := 0 + runs := make([]config.PatrolRunRecord, 0, historyCap) + for at := now.AddDate(0, 0, -2); at.Before(now); at = at.Add(10 * time.Minute) { + runs = append([]config.PatrolRunRecord{{ID: at.Format(time.RFC3339Nano), StartedAt: at, CompletedAt: at}}, runs...) + if len(runs) > historyCap { + runs = runs[:historyCap] + } + total++ + if err := persistence.SavePatrolRunHistory(runs); err != nil { + t.Fatalf("SavePatrolRunHistory: %v", err) + } + } + if total <= historyCap { + t.Fatalf("test needs more runs than the cap, got %d", total) + } + + snap := &telemetry.Snapshot{} + applyPulseIntelligencePatrolRunSnapshot(snap, persistence, since) + if snap.PulseIntelligencePatrolRuns30d != total { + t.Fatalf("PulseIntelligencePatrolRuns30d = %d, want %d", snap.PulseIntelligencePatrolRuns30d, total) + } +}