diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index d7b545793..3ab223ec0 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -1886,3 +1886,52 @@ The provider warning stayed visible and no retry, route switch or provider request was made. This is captured-response rendering, not real-model diagnosis or server persistence qualification. The exact scoped worker hook gates delivery through PR #1935. + + +## Shared Docker history identity, 2026-09-06 + +The preceding incident-read correction was pushed as +`580a246981c76b401e9007f5ac65c89355b64c6d` in PR #1935. Its live retained +records established the identity split addressed here. + +The canonical fix belongs to the shared monitor/store boundary. Exact full +Docker container references resolve through current registry identity. Retained +bindings survive inventory removal, and deterministic source-specific identities +allow legacy records to be found after restart. Names and abbreviated IDs are +not sufficient evidence. A small organization-scoped history alias index joins +readable records without rewriting event IDs, timestamps or metadata. Existing +canonical succession machinery was deliberately not used for these aliases +because it also moves operator state and action indexes. History matching must +not transfer authority. The alias index follows journal retention and separate +store connections read fresh bindings. + +Focused regression and race proofs passed on pulse-dev with Go1.26.8 and +GOMAXPROCS4. Full unifiedresources, monitoring and tools packages passed in +37.826s, 79.866s and 59.584s. They cover real alert-manager callbacks, recovery +after inventory removal, restart, replay, same-name controls, tenant isolation, +unchanged operator/approval records and registered Assistant tool reads. Scoped +history lookup measured 0.261–0.275ms with one alias and 0.317–0.336ms with +20,000 unrelated aliases, at 6,280 bytes and 94 allocations per read. These are +worker microbenchmarks, not fleet or frontend performance qualification. + +The verified worker Pro binary has SHA256 +`bb6d1508a5b4d23943c37dfc42198f132c0139805dcd1891ee18aca0a9f9dd54`. +It was installed into the local development stack and restarted healthy. Both +canonical and legacy timeline API queries now return the same seven retained +records for the removed storage fixture, including the original fired/resolved +records with exact unchanged content. The complete registered-tool rendering +matrix passed at `/patrol`, 1440x1000, 900x1000 and 390x1000. Six captured cases +include migrated history, bounded and empty results, and unavailable/failed +reads. Hover/focus, Enter/Space, deepest scrolling, Escape and controlled-session +reload preserved exact inputs, outputs and completed/failed states. Pixels were +inspected at all three widths. Source and binary hashes remained fixed. Private +receipts are `tmp/patrol-history-identity/browser/receipt.json` and +`live-history-proof.json`. Controlled session responses qualify rendering, not +server persistence or model diagnosis. The cached provider refusal remained +unchanged. No model request or infrastructure fault was made. The exact scoped +worker hook remains the delivery gate for PR #1935. + +This history correction does not qualify the failed ordinary storage diagnosis, +partial recovery claim, installed tmpfs collector, autonomous provider, approved +and rejected action outcomes, or independent Pro environments. The unused legacy +recorder/coordinator still needs retirement with its archives preserved. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index af9de87e9..abd1e8912 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10201,7 +10201,7 @@ }, { "id": "patrol-assistant-customer-outcome-qualification", - "summary": "The redesign goal remains open, with its executable plan and detailed receipts in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Patrol owns investigation and Assistant continues the same issue. Observations, hypotheses, accepted proposals, execution and independently verified outcomes remain distinct. The recorded baseline contains 127 paid installations, 71 Patrol-enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These do not establish representative success, false-alarm or missed-problem rates. Shared provenance/history/risk fixes and removal of proposal-as-proof and proxy completion policy landed through PR1928/1929. PR1934 merged canonical tool/transcript identity. PR1935 contains measurement-presence, capacity, canonical config-read, command-connectivity and tmpfs collection/query corrections through 6e18777d30f30b498def39d30016a697cabc4ea7. Exact worker hooks and named browser matrices passed, with remote landing pending. Real ordinary storage diagnosis failed by ruling out pressure without filesystem capacity evidence. Same-session recovery correctly identified present health and alert resolution but overstated continuous control health. Independent fault-intact, recovery, two-pass cleanup and persisted transcript checks passed. The current incident history correction replaces the primary legacy recorder read with the existing organization-pinned canonical resource timeline, preserving source/time semantics, bounded history, empty coverage and failed-read distinctions. Explicit legacy archive reads remain resource-bound. Its registered-tool SQLite, full tools package, focused race and final-source browser proofs pass. The exact scoped worker hook gates delivery through PR1935. Live reads of the removed storage fixture confirm that health alert fired/resolved records remain under the legacy Docker ID while creation/removal use the canonical app-container ID. This is partial incident-history remediation. The shared timeline writer and retained identity/history contract must repair that split, including removed resources, without an Assistant-local ID rewrite. The unused legacy recorder/coordinator startup needs retirement while preserving saved archives. Installed tmpfs collector and real-model interpretation remain unqualified. Other named residuals include unsupported filters, typed compatibility canonical-ID lookup, legacy direction availability, Docker-host history and responsive mount details. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain enforced. Separate paid-provider approval is pending, with no paid request or policy bypass. Ordinary Assistant is not autonomous qualification. Required local work still includes reliable interpretation, config-read/model retest, storage/backup and approved/rejected action outcomes. Independent volunteered Pro environments remain a separate wider-readiness gate.", + "summary": "The redesign goal remains open, with its executable plan and detailed receipts in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Patrol owns investigation and Assistant continues the same issue. Observations, hypotheses, accepted proposals, execution and independently verified outcomes remain distinct. The recorded baseline contains 127 paid installations, 71 Patrol-enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These do not establish representative success, false-alarm or missed-problem rates. Shared provenance/history/risk fixes and removal of proposal-as-proof and proxy completion policy landed through PR1928/1929. PR1934 merged canonical tool/transcript identity. PR1935 contains measurement-presence, capacity, canonical config-read, command-connectivity and tmpfs collection/query corrections through 6e18777d30f30b498def39d30016a697cabc4ea7. Exact worker hooks and named browser matrices passed, with remote landing pending. Real ordinary storage diagnosis failed by ruling out pressure without filesystem capacity evidence. Same-session recovery correctly identified present health and alert resolution but overstated continuous control health. Independent fault-intact, recovery, two-pass cleanup and persisted transcript checks passed. The current incident history correction replaces the primary legacy recorder read with the existing organization-pinned canonical resource timeline, preserving source/time semantics, bounded history, empty coverage and failed-read distinctions. Explicit legacy archive reads remain resource-bound. Its registered-tool SQLite, full tools package, focused race and final-source browser proofs pass. The primary history read was pushed as 580a246 in PR1935. The shared monitor/store identity correction now joins exact full Docker references with canonical container history through an organization-scoped history-only alias index. Original event content and approval/operator authority remain unchanged. Callback, removal/restart, replay, tenant/control and registered-tool tests, full affected packages, race checks and scoped lookup benchmarks passed. Final worker-build API proof returns the same seven retained homelab records under canonical and legacy queries, preserving the original fired/resolved events exactly. Six-case Playwright proof at desktop/intermediate/mobile widths passed. The exact scoped worker hook gates this delivery through PR1935. The unused legacy recorder/coordinator startup needs retirement while preserving saved archives. Installed tmpfs collector and real-model interpretation remain unqualified. Other named residuals include unsupported filters, typed compatibility canonical-ID lookup, legacy direction availability, Docker-host history and responsive mount details. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain enforced. Separate paid-provider approval is pending, with no paid request or policy bypass. Ordinary Assistant is not autonomous qualification. Required local work still includes reliable interpretation, config-read/model retest, storage/backup and approved/rejected action outcomes. Independent volunteered Pro environments remain a separate wider-readiness gate.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-09-05", diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 2533243f0..93a73e38d 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -87,6 +87,15 @@ existing cache policy. The legacy untyped-error fallback is unchanged. Retaining cached inventory does not establish a successful poll or fresh backup evidence. +Docker alert lifecycle events pass through the shared resource history identity +writer. A full Docker source reference must reach the same canonical container +history as inventory changes, including recovery after inventory removal and +restart. Existing alert lifecycle event IDs remain unchanged so replay cannot +duplicate retained events. Same-name containers and abbreviated IDs must not +join another container's history. The real alert-manager callback path is +covered by `TestDockerAlertTimelineUsesCanonicalHistoryIdentity` in +`internal/monitoring/monitor_alert_handling_test.go`. + Verification: `TestPollPBSBackups_PreservesCacheOnTransientDatastoreError` and `TestPollPBSBackups_DropsStaleCacheOnTerminalDatastoreError` in `internal/monitoring/monitor_backups_readstate_test.go` exercise actual HTTP diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 0d47d4b63..3c603599e 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -668,14 +668,14 @@ ] }, { - "path": "internal/agentcapabilities/patrol_scope_tools.go", - "rationale": "the typed Patrol resource-scope evidence mapping is both the AI runtime least-manifest projection contract and the canonical API/agent vocabulary that keeps scoped Watch and investigation tool reductions aligned across the Pulse and enterprise boundary", - "subsystems": [ - "ai-runtime", - "api-contracts" - ] - }, - { + "path": "internal/agentcapabilities/patrol_scope_tools.go", + "rationale": "the typed Patrol resource-scope evidence mapping is both the AI runtime least-manifest projection contract and the canonical API/agent vocabulary that keeps scoped Watch and investigation tool reductions aligned across the Pulse and enterprise boundary", + "subsystems": [ + "ai-runtime", + "api-contracts" + ] + }, + { "path": "internal/agentcapabilities/projection.go", "rationale": "the agent capability external-tool projection helper, normalized manifest-owned surface tool contract resolution and tools-affordance gating, manifest-owned resource-context route and argument vocabulary, operator-state capability and route vocabulary, finding workflow capability and lifecycle argument vocabulary including resolution and dismissal notes, governed action capability, route, and argument vocabulary, manifest-owned tool title and outputSchema projection, structured Pulse capability _meta, and shared tool behavior hints are both the canonical API manifest projection contract and the AI runtime adapter projection for Pulse Assistant and MCP-facing agent tools, with MCP annotation and metadata wire names confined to adapter-edge aliases", "subsystems": [ @@ -1458,8 +1458,8 @@ "internal/hostagent/action_runner_client_test.go" ] }, - { - "id": "agent-privilege-helper-runtime", + { + "id": "agent-privilege-helper-runtime", "label": "typed no-network agent privilege helper proof", "match_prefixes": [ "internal/agenthelper/" @@ -1471,11 +1471,11 @@ "test_prefixes": [ "internal/agenthelper/" ], - "exact_files": [ - "cmd/pulse-agent-helper/main_test.go", - "internal/agenthelper/container_inventory_test.go", - "internal/agenthelper/update_activation_test.go" - ] + "exact_files": [ + "cmd/pulse-agent-helper/main_test.go", + "internal/agenthelper/container_inventory_test.go", + "internal/agenthelper/update_activation_test.go" + ] }, { "id": "native-pve-action-qualification", @@ -1736,9 +1736,9 @@ "internal/hostagent/docker_lifecycle_test.go", "internal/hostagent/issue1595_sas_collection_test.go", "internal/hostagent/observer_delivery_test.go", - "internal/hostagent/package_updates_test.go", - "internal/hostagent/privilege_helper_client_test.go", - "internal/hostagent/send_report_test.go", + "internal/hostagent/package_updates_test.go", + "internal/hostagent/privilege_helper_client_test.go", + "internal/hostagent/send_report_test.go", "internal/hostagent/smartctl_standby_guard_test.go", "internal/hostagent/storage_cleanup_test.go", "internal/hostagent/unraid_test.go", @@ -1754,11 +1754,11 @@ ], "allow_same_subsystem_tests": false, "test_prefixes": [], - "exact_files": [ - "scripts/installtests/agent_state_dir_lifecycle_test.go", - "scripts/installtests/install_sh_test.go", - "scripts/installtests/safe_profile_migration_test.go" - ] + "exact_files": [ + "scripts/installtests/agent_state_dir_lifecycle_test.go", + "scripts/installtests/install_sh_test.go", + "scripts/installtests/safe_profile_migration_test.go" + ] }, { "id": "windows-agent-installer-runtime", @@ -3220,10 +3220,10 @@ "test_prefixes": [ "frontend-modern/src/api/__tests__/" ], - "exact_files": [ - "frontend-modern/src/types/api.ts", - "internal/api/action_runner_credentials_test.go", - "internal/api/ai_handlers_more_test.go", + "exact_files": [ + "frontend-modern/src/types/api.ts", + "internal/api/action_runner_credentials_test.go", + "internal/api/ai_handlers_more_test.go", "internal/api/ai_handlers_patrol_actions_additional_test.go", "internal/api/alerting/external_probe_notifications_test.go", "internal/api/audit_handlers_test.go", @@ -4803,14 +4803,14 @@ ], "allow_same_subsystem_tests": false, "test_prefixes": [], - "exact_files": [ - "pulse-enterprise:scripts/validate-pro-release-line_test.sh", - "scripts/installtests/backfill_release_assets_test.go", - "scripts/installtests/build_release_assets_test.go", - "scripts/installtests/release_ldflags_test.go", - "scripts/installtests/safe_profile_migration_test.go", - "scripts/release_control/secure_runtime_attestation_v6_test.py", - "scripts/release_control/secure_runtime_attestation_v7_test.py" + "exact_files": [ + "pulse-enterprise:scripts/validate-pro-release-line_test.sh", + "scripts/installtests/backfill_release_assets_test.go", + "scripts/installtests/build_release_assets_test.go", + "scripts/installtests/release_ldflags_test.go", + "scripts/installtests/safe_profile_migration_test.go", + "scripts/release_control/secure_runtime_attestation_v6_test.py", + "scripts/release_control/secure_runtime_attestation_v7_test.py" ] }, { @@ -5056,8 +5056,8 @@ "tests/integration/tests/16-dev-runtime-recovery.spec.ts" ] }, - { - "id": "shell-installer-runtime", + { + "id": "shell-installer-runtime", "label": "shell installer runtime proof", "match_prefixes": [], "match_files": [ @@ -5065,11 +5065,11 @@ ], "allow_same_subsystem_tests": false, "test_prefixes": [], - "exact_files": [ - "scripts/installtests/agent_state_dir_lifecycle_test.go", - "scripts/installtests/install_sh_test.go", - "scripts/installtests/safe_profile_migration_test.go" - ] + "exact_files": [ + "scripts/installtests/agent_state_dir_lifecycle_test.go", + "scripts/installtests/install_sh_test.go", + "scripts/installtests/safe_profile_migration_test.go" + ] } ], "match_files": null @@ -5461,21 +5461,21 @@ "frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts" ] }, - { - "id": "compact-info-card-consumers", - "label": "compact information card consumer proof", - "match_prefixes": [], - "match_files": [ - "frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx" - ], - "allow_same_subsystem_tests": false, - "test_prefixes": [], - "exact_files": [ - "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts" - ] - }, - { - "id": "workload-presentation-helpers", + { + "id": "compact-info-card-consumers", + "label": "compact information card consumer proof", + "match_prefixes": [], + "match_files": [ + "frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts" + ] + }, + { + "id": "workload-presentation-helpers", "label": "workload presentation helper proof", "match_prefixes": [], "match_files": [ @@ -6485,11 +6485,11 @@ "frontend-modern/src/components/Settings/useRBACFeatureGateState.ts", "frontend-modern/src/components/Settings/useRolesPanelState.ts", "frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts", - "frontend-modern/src/types/rbac.ts", + "frontend-modern/src/types/rbac.ts", "frontend-modern/src/utils/organizationRolePresentation.ts", "frontend-modern/src/utils/organizationSettingsPresentation.ts", "frontend-modern/src/utils/orgUtils.ts", - "frontend-modern/src/utils/rbacPresentation.ts", + "frontend-modern/src/utils/rbacPresentation.ts", "internal/api/access_control_handlers.go", "internal/api/enterprise_extension_rbac_admin.go", "internal/api/org_handlers.go", @@ -6514,8 +6514,8 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/api/orgs.ts", - "frontend-modern/src/api/rbac.ts", - "frontend-modern/src/types/rbac.ts" + "frontend-modern/src/api/rbac.ts", + "frontend-modern/src/types/rbac.ts" ], "allow_same_subsystem_tests": false, "test_prefixes": [], @@ -6604,8 +6604,8 @@ "frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts", "frontend-modern/src/utils/organizationRolePresentation.ts", "frontend-modern/src/utils/organizationSettingsPresentation.ts", - "frontend-modern/src/utils/orgUtils.ts", - "frontend-modern/src/utils/rbacPresentation.ts" + "frontend-modern/src/utils/orgUtils.ts", + "frontend-modern/src/utils/rbacPresentation.ts" ], "allow_same_subsystem_tests": false, "test_prefixes": [], @@ -6619,8 +6619,8 @@ "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", "frontend-modern/src/utils/__tests__/organizationRolePresentation.test.ts", "frontend-modern/src/utils/__tests__/organizationSettingsPresentation.test.ts", - "frontend-modern/src/utils/__tests__/orgUtils.test.ts", - "frontend-modern/src/utils/__tests__/rbacPresentation.test.ts" + "frontend-modern/src/utils/__tests__/orgUtils.test.ts", + "frontend-modern/src/utils/__tests__/rbacPresentation.test.ts" ] }, { @@ -7411,7 +7411,7 @@ "pkg/audit/sqlite_logger.go", "pkg/auth/agent_credentials.go", "pkg/auth/rbac.go", - "pkg/auth/rbac_manager.go", + "pkg/auth/rbac_manager.go", "pkg/auth/sqlite_manager.go", "pkg/extensions/audit_admin.go", "pkg/server/server.go", @@ -7682,7 +7682,7 @@ "match_prefixes": [], "match_files": [ "pkg/auth/rbac.go", - "pkg/auth/rbac_manager.go", + "pkg/auth/rbac_manager.go", "pkg/auth/sqlite_manager.go", "pkg/server/server.go" ], @@ -7693,7 +7693,7 @@ "internal/api/rbac_tenant_provider_test.go", "internal/api/security_regression_test.go", "pkg/auth/rbac_manager_test.go", - "pkg/auth/sqlite_manager_queryplan_test.go", + "pkg/auth/sqlite_manager_queryplan_test.go", "pkg/auth/sqlite_manager_test.go", "pkg/server/server_test.go" ] @@ -8086,6 +8086,7 @@ "internal/unifiedresources/adapter_coverage_test.go", "internal/unifiedresources/adapters_test.go", "internal/unifiedresources/ceph_pool_health_contract_test.go", + "internal/unifiedresources/history_identity_test.go", "internal/unifiedresources/host_storage_cleanup_test.go", "internal/unifiedresources/monitor_adapter_read_state_test.go", "internal/unifiedresources/views_test.go" @@ -8483,6 +8484,7 @@ "exact_files": [ "internal/monitoring/issue1595_collection_trust_test.go", "internal/unifiedresources/availability_link_test.go", + "internal/unifiedresources/history_identity_test.go", "internal/unifiedresources/kubernetes_registry_test.go", "internal/unifiedresources/pbs_pmg_registry_test.go", "internal/unifiedresources/registry_merge_policy_test.go", @@ -8520,6 +8522,19 @@ "internal/unifiedresources/action_policy_provenance_test.go" ] }, + { + "id": "resource-history-identity", + "label": "resource history identity and authority isolation proof", + "match_prefixes": [], + "match_files": [ + "internal/unifiedresources/history_identity.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/unifiedresources/history_identity_test.go" + ] + }, { "id": "unified-resource-runtime-support", "label": "unified resource runtime support proof", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 077ac9f72..78dd32a8f 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -4812,6 +4812,17 @@ recent-change slice plus facet counts it actually renders. The store now also owns a `resource_changes` persistence table with `RecordChange` and `GetRecentChanges` methods so change history is queryable by canonical ID and time window. +Docker alert source references containing an exact full container ID resolve at +`MonitorAdapter.RecordChange` through the current registry, then a retained +history binding, then the deterministic source-specific container identity. +Names and shortened IDs cannot establish this binding. `history_identity.go` +owns a history-only alias index in the organization-scoped resource store. +Legacy event rows retain their IDs, resource references and timestamps. Reads +expand aliases and canonical predecessor eras without changing operator state, +action requests, approvals, links or exclusions. Separate monitor, API and +Assistant store handles must see current persisted aliases. Missing identity +storage is an error, not evidence of empty history. Retention removes an alias +only after neither identity has retained journal records. That same shared timeline vocabulary now includes the `activity` change kind for provider-read breadcrumbs such as VMware tasks and events, plus the `vmware_adapter` source-adapter token for canonical provenance drill-down. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index a87940a56..293c6f6c2 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,15 +1,18 @@ { "version": 1, - "base_sha": "6e18777d30f30b498def39d30016a697cabc4ea7", - "verified_at": "2026-09-06T14:26:55.462Z", + "base_sha": "580a246981c76b401e9007f5ac65c89355b64c6d", + "verified_at": "2026-09-06T15:06:56.468Z", "result": "passed", "changed_paths": [], "content_sha256": {}, "backend_content_sha256": { + "internal/unifiedresources/history_identity.go": "3934ac49acce29b16dd671769719112208be053a8658c678f798645a6a38395d", + "internal/unifiedresources/monitor_adapter.go": "5b51089bac8e85e7c776956c17471599ec15add4e5e8502cd8687c4f788fcdb1", + "internal/unifiedresources/store.go": "018176a4762b06adf52c78128f8351ca93819cbfa10bf2242535fef3d7c59191", "internal/ai/tools/tools_knowledge.go": "d98ea1bab481acce5eee4988d74d364dc73c4068ca33725d843b54117cc9cc85", "internal/ai/tools/executor.go": "c476d6aec1f7925467f1547f0219e6c6265639a62b397649adc82a8ed87b7e4a" }, - "binary_sha256": "4929aeb869db54122bc5525352d3126c0e9fa7c4847e3fdfa741500600b5e00d", + "binary_sha256": "bb6d1508a5b4d23943c37dfc42198f132c0139805dcd1891ee18aca0a9f9dd54", "rendering_content_sha256": { "frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c", "frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371", @@ -33,13 +36,13 @@ } ], "states": [ - "Five captured registered-tool results: retained canonical lifecycle, bounded/truncated history, empty retained history, unavailable store, and failed store read.", - "Observation and optional occurrence timestamps, source metadata, empty arrays, coverage limitations and failed tool status survive rendering.", - "Existing provider route warning remains visible. No retry, route switch, model request or infrastructure mutation was performed." + "Six captured registered-tool results: migrated legacy Docker fired event plus canonical recovery, retained lifecycle, bounded history, empty history, unavailable store and failed read.", + "Exact event IDs, original resource references, observation/occurrence times and source metadata survive expansion. Empty coverage and failed tool states remain distinct.", + "Actual removed homelab container canonical and legacy timeline APIs return the same seven records, preserving both previously captured fired/resolved records exactly. Provider refusal remains enforced." ], "interactions": [ - "Hover/focus and Enter expansion, exact input/output comparison, deepest output scroll and pixel inspection at desktop/intermediate/mobile widths, Space collapse and Escape.", - "Full reload, controlled session selection, reopen each result and compare exact retained input/output and completed/failed state.", - "Final verified Pro build installed locally, healthy restart, fixed source/binary hashes before and after. Private receipt: tmp/patrol-canonical-history/browser/receipt.json. Captured response/session fixtures qualify rendering, not real-model diagnosis or backend persistence." + "Hover/focus, Enter expansion, exact input/output comparison, deepest scrolling, Space collapse and Escape at /patrol, 1440x1000, 900x1000 and 390x1000. Actual pixels inspected at all three widths.", + "Full reload, controlled session selection, reopening all six results and exact retained input/output and completed/failed comparison.", + "Final verified worker Pro build installed locally and restarted healthy. Source/binary hashes fixed before and after. Private proof: tmp/patrol-history-identity/browser/receipt.json and live-history-proof.json. Captured tool/session rendering does not qualify real-model diagnosis or server persistence. No provider call or fault injection." ] } diff --git a/internal/ai/tools/incident_history_test.go b/internal/ai/tools/incident_history_test.go index 08df16b58..0b8ff365f 100644 --- a/internal/ai/tools/incident_history_test.go +++ b/internal/ai/tools/incident_history_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" "time" @@ -14,6 +15,38 @@ import ( type failedIncidentHistoryStore struct{ unifiedresources.ResourceStore } +func TestIncidentHistoryRetainsLegacyDockerLifecycle(t *testing.T) { + dir := t.TempDir() + store, err := unifiedresources.NewSQLiteResourceStore(dir, "incident-test") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + container := strings.Repeat("b", 64) + legacy := "docker:tower/" + container + canonical := unifiedresources.SourceSpecificID(unifiedresources.ResourceTypeAppContainer, unifiedresources.SourceDocker, "tower/container/"+container) + start := time.Now().UTC().Add(-time.Hour).Truncate(time.Second) + occurred := start.Add(-time.Minute) + fired := unifiedresources.ResourceChange{ID: "legacy-fired", ResourceID: legacy, ObservedAt: start.Add(time.Minute), OccurredAt: &occurred, Kind: unifiedresources.ChangeAlertFired, SourceType: unifiedresources.SourceHeuristic, Reason: "Container unhealthy"} + require.NoError(t, store.RecordChange(fired)) + require.NoError(t, store.Close()) + store, err = unifiedresources.NewSQLiteResourceStore(dir, "incident-test") + require.NoError(t, err) + resolved := unifiedresources.ResourceChange{ID: "canonical-resolved", ResourceID: canonical, ObservedAt: start.Add(3 * time.Minute), Kind: unifiedresources.ChangeAlertResolved, SourceType: unifiedresources.SourceHeuristic} + require.NoError(t, store.RecordChange(resolved)) + exec := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: store}) + input := map[string]interface{}{"action": "incidents", "resource_id": canonical, "since": start.Format(time.RFC3339), "limit": float64(50)} + result, err := exec.registry.Execute(context.Background(), exec, agentcapabilities.PulseKnowledgeToolName, input) + require.NoError(t, err) + require.False(t, result.IsError, result.Content) + var got struct { + Events []unifiedresources.ResourceChange `json:"events"` + } + require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &got)) + require.Equal(t, []unifiedresources.ResourceChange{resolved, fired}, got.Events) + capture, err := json.Marshal(map[string]any{"case": "migrated Docker lifecycle", "input": input, "result": result}) + require.NoError(t, err) + t.Logf("INCIDENT_EVIDENCE %s", capture) +} + func (failedIncidentHistoryStore) GetRecentChanges(string, time.Time, int) ([]unifiedresources.ResourceChange, error) { return nil, errors.New("history store unavailable") } diff --git a/internal/monitoring/monitor_alert_handling_test.go b/internal/monitoring/monitor_alert_handling_test.go index 7c61f5146..ea2c40375 100644 --- a/internal/monitoring/monitor_alert_handling_test.go +++ b/internal/monitoring/monitor_alert_handling_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" @@ -16,8 +17,78 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/notifications" unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" + "github.com/stretchr/testify/require" ) +func TestDockerAlertTimelineUsesCanonicalHistoryIdentity(t *testing.T) { + dir := t.TempDir() + store, err := unifiedresources.NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + manager := alerts.NewManagerWithDataDir(t.TempDir()) + t.Cleanup(manager.Stop) + config := manager.GetConfig() + config.Enabled = true + config.ActivationState = alerts.ActivationPending + config.TimeThresholds = map[string]int{} + config.SuppressionWindow = 0 + manager.UpdateConfig(config) + containerID := strings.Repeat("f", 64) + host := models.DockerHost{ID: "history-host", Hostname: "history-host", LastSeen: time.Now(), Containers: []models.DockerContainer{ + {ID: containerID, Name: "worker", State: "running", Health: "unhealthy"}, + {ID: strings.Repeat("a", 64), Name: "worker", State: "running", Health: "healthy"}, + }} + registry := unifiedresources.NewRegistry(store) + registry.IngestSnapshot(models.StateSnapshot{DockerHosts: []models.DockerHost{host}}) + adapter := unifiedresources.NewMonitorAdapter(registry) + monitor := &Monitor{alertManager: manager, resourceStore: adapter} + manager.SubscribeLifecycleCallback(monitor.handleAlertLifecycleEvent) + manager.CheckDockerHost(host) + canonicalID := unifiedresources.SourceSpecificID(unifiedresources.ResourceTypeAppContainer, unifiedresources.SourceDocker, host.ID+"/container/"+containerID) + filters := unifiedresources.ResourceChangeFilters{Kinds: []unifiedresources.ChangeKind{unifiedresources.ChangeAlertFired, unifiedresources.ChangeAlertResolved}} + changes, err := store.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters) + require.NoError(t, err) + require.Len(t, changes, 1) + require.Equal(t, unifiedresources.ChangeAlertFired, changes[0].Kind) + require.Equal(t, canonicalID, changes[0].ResourceID) + var fired alerts.Alert + for _, alert := range manager.GetActiveAlerts() { + if alert.Type == "docker-container-health" { + fired = alert + } + } + require.NotEmpty(t, fired.ID) + // Recovery is emitted after the monitored container has left the registry. + // Its exact retained source binding must still select the original resource. + monitor.resourceStore = unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(store)) + host.Containers[0].Health = "healthy" + manager.CheckDockerHost(host) + changes, err = store.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters) + require.NoError(t, err) + require.Len(t, changes, 2) + require.Equal(t, unifiedresources.ChangeAlertResolved, changes[0].Kind) + require.Equal(t, canonicalID, changes[0].ResourceID) + monitor.recordAlertTimelineChange(&fired, unifiedresources.ChangeAlertFired, fired.StartTime, "") + monitor.recordAlertTimelineChange(&fired, unifiedresources.ChangeAlertResolved, *changes[0].OccurredAt, "") + again, err := store.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters) + require.NoError(t, err) + require.Equal(t, changes, again) + controlID := unifiedresources.SourceSpecificID(unifiedresources.ResourceTypeAppContainer, unifiedresources.SourceDocker, host.ID+"/container/"+host.Containers[1].ID) + control, err := store.GetRecentChangesFiltered(controlID, time.Time{}, 10, filters) + require.NoError(t, err) + require.Empty(t, control) + require.NoError(t, store.Close()) + restarted, err := unifiedresources.NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + defer restarted.Close() + afterRestart, err := restarted.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters) + require.NoError(t, err) + require.Equal(t, changes, afterRestart) + encoded, err := json.Marshal(afterRestart) + require.NoError(t, err) + t.Logf("DOCKER_HISTORY_LIFECYCLE %s", encoded) +} + func TestMonitor_HandleAlertFired_Extra(t *testing.T) { // 1. Alert is nil m1 := &Monitor{} diff --git a/internal/unifiedresources/history_identity.go b/internal/unifiedresources/history_identity.go new file mode 100644 index 000000000..e71c71a2c --- /dev/null +++ b/internal/unifiedresources/history_identity.go @@ -0,0 +1,161 @@ +package unifiedresources + +import ( + "database/sql" + "encoding/hex" + "fmt" + "strings" +) + +// legacyDockerHistoryIdentity accepts the source identifier emitted by Docker +// alerts only when it contains a complete container ID. Names and short IDs +// cannot establish durable identity after inventory removal. +func legacyDockerHistoryIdentity(ref string) (sourceID, canonicalID string, ok bool) { + ref = strings.TrimSpace(ref) + if !strings.HasPrefix(ref, "docker:") { + return "", "", false + } + host, container, found := strings.Cut(strings.TrimPrefix(ref, "docker:"), "/") + if !found || host == "" || strings.TrimSpace(host) != host || len(container) != 64 || strings.ToLower(container) != container { + return "", "", false + } + if _, err := hex.DecodeString(container); err != nil { + return "", "", false + } + sourceID = host + "/container/" + container + if host == "container" { // DockerResourceID's explicit hostless form. + sourceID = container + } + return sourceID, SourceSpecificID(ResourceTypeAppContainer, SourceDocker, sourceID), true +} + +// resourceHistoryIdentityWriter binds a source reference to the canonical +// resource of one event. It affects history lookup only, never operator state, +// action requests, approvals or execution identities. +type resourceHistoryIdentityWriter interface { + RecordChangeWithSourceIdentity(change ResourceChange, sourceID string) error + ResolveHistorySourceIdentity(sourceID string) (string, bool, error) +} + +func (s *SQLiteResourceStore) ResolveHistorySourceIdentity(sourceID string) (string, bool, error) { + var id string + err := s.db.QueryRow(`SELECT canonical_id FROM resource_history_aliases WHERE source_id = ?`, sourceID).Scan(&id) + if err == sql.ErrNoRows { + return "", false, nil + } + return id, err == nil, err +} + +func (m *MemoryStore) ResolveHistorySourceIdentity(sourceID string) (string, bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + id, ok := m.historyAliases[sourceID] + return id, ok, nil +} + +func (s *SQLiteResourceStore) RecordChangeWithSourceIdentity(change ResourceChange, sourceID string) error { + sourceID = CanonicalResourceID(sourceID) + canonicalID := CanonicalResourceID(change.ResourceID) + if sourceID == "" || canonicalID == "" || sourceID == canonicalID { + return s.RecordChange(change) + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin resource history identity: %w", err) + } + defer tx.Rollback() + // Registry resolution is authoritative when available. Rebinding a source + // reference affects subsequent history reads without rewriting past events. + if _, err := tx.Exec(`INSERT INTO resource_history_aliases (source_id, canonical_id) VALUES (?, ?) + ON CONFLICT(source_id) DO UPDATE SET canonical_id = excluded.canonical_id`, sourceID, canonicalID); err != nil { + return fmt.Errorf("record resource history identity: %w", err) + } + if err := recordChangeSQL(tx, change, s.resourceChangesHasTimestamp); err != nil { + return err + } + return tx.Commit() +} + +func (m *MemoryStore) RecordChangeWithSourceIdentity(change ResourceChange, sourceID string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.historyAliases == nil { + m.historyAliases = make(map[string]string) + } + if sourceID = CanonicalResourceID(sourceID); sourceID != "" && sourceID != change.ResourceID { + m.historyAliases[sourceID] = change.ResourceID + } + return m.recordChangeLocked(change) +} + +// migrateResourceHistoryAliases adds an index for exact legacy Docker event +// identities, including resources already removed from live inventory. The +// event rows and all authority-bearing tables remain unchanged. +func (s *SQLiteResourceStore) migrateResourceHistoryAliases() error { + if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS resource_history_aliases ( + source_id TEXT PRIMARY KEY, canonical_id TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS idx_resource_history_aliases_canonical ON resource_history_aliases(canonical_id);`); err != nil { + return fmt.Errorf("initialize resource history identities: %w", err) + } + rows, err := s.db.Query(`SELECT DISTINCT canonical_id FROM resource_changes WHERE canonical_id GLOB 'docker:*'`) + if err != nil { + return fmt.Errorf("read legacy resource history identities: %w", err) + } + aliases := make(map[string]string) + for rows.Next() { + var sourceID string + if err := rows.Scan(&sourceID); err != nil { + rows.Close() + return err + } + if _, canonicalID, ok := legacyDockerHistoryIdentity(sourceID); ok { + aliases[sourceID] = canonicalID + } + } + readErr := rows.Err() + rows.Close() // The store has one connection. Release it before writing. + if readErr != nil { + return readErr + } + for sourceID, canonicalID := range aliases { + if _, err := s.db.Exec(`INSERT OR IGNORE INTO resource_history_aliases (source_id, canonical_id) VALUES (?, ?)`, sourceID, canonicalID); err != nil { + return fmt.Errorf("index legacy resource history identity: %w", err) + } + } + return nil +} + +// expandHistoryAliases reads persisted identity bindings each time so separate +// monitor, API and Assistant store handles see new bindings immediately. The +// indexed traversal is restricted to the requested identities, not the fleet. +func (s *SQLiteResourceStore) expandHistoryAliases(ids []string) ([]string, error) { + if len(ids) == 0 { + return ids, nil + } + seeds := make([]string, len(ids)) + args := make([]any, len(ids)) + for i, id := range ids { + seeds[i], args[i] = "(?)", id + } + rows, err := s.db.Query(`WITH RECURSIVE history_ids(id) AS ( + VALUES `+strings.Join(seeds, ",")+` + UNION SELECT a.canonical_id FROM resource_history_aliases a JOIN history_ids h ON a.source_id = h.id + UNION SELECT a.source_id FROM resource_history_aliases a JOIN history_ids h ON a.canonical_id = h.id + UNION SELECT s.old_canonical_id FROM canonical_id_successions s JOIN history_ids h ON s.new_canonical_id = h.id + ) SELECT id FROM history_ids`, args...) + if err != nil { + return nil, fmt.Errorf("read resource history identities: %w", err) + } + defer rows.Close() + var expanded []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + expanded = append(expanded, id) + } + return expanded, rows.Err() +} diff --git a/internal/unifiedresources/history_identity_test.go b/internal/unifiedresources/history_identity_test.go new file mode 100644 index 000000000..21c4512db --- /dev/null +++ b/internal/unifiedresources/history_identity_test.go @@ -0,0 +1,175 @@ +package unifiedresources + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/stretchr/testify/require" +) + +func TestHistoryIdentityLegacyDockerReference(t *testing.T) { + container := strings.Repeat("a", 64) + for _, ref := range []string{"docker:host/" + container, "docker:container/" + container} { + source, id, ok := legacyDockerHistoryIdentity(ref) + require.True(t, ok) + require.Equal(t, SourceSpecificID(ResourceTypeAppContainer, SourceDocker, source), id) + } + for _, ref := range []string{"docker:host/worker", "docker:host/" + container[:12], "docker:host/" + strings.ToUpper(container), "docker:/" + container, "docker:host/" + strings.Repeat("z", 64), "docker:host", "vm:host/" + container} { + _, _, ok := legacyDockerHistoryIdentity(ref) + require.False(t, ok, ref) + } +} + +// Exercise the actual scoped history read as the unrelated identity index grows. +// Timing is reported for qualification, without a machine-dependent pass threshold. +func BenchmarkHistoryIdentityQuery(b *testing.B) { + for _, size := range []int{1, 20000} { + b.Run(fmt.Sprintf("aliases-%d", size), func(b *testing.B) { + store, err := NewSQLiteResourceStore(b.TempDir(), "benchmark") + require.NoError(b, err) + b.Cleanup(func() { require.NoError(b, store.Close()) }) + tx, err := store.db.Begin() + require.NoError(b, err) + for i := 0; i < size; i++ { + _, err := tx.Exec(`INSERT INTO resource_history_aliases (source_id, canonical_id) VALUES (?, ?)`, fmt.Sprintf("legacy-%d", i), fmt.Sprintf("app-container-%d", i)) + require.NoError(b, err) + require.NoError(b, recordChangeSQL(tx, ResourceChange{ID: fmt.Sprintf("event-%d", i), ResourceID: fmt.Sprintf("legacy-%d", i), ObservedAt: time.Now(), Kind: ChangeAlertFired}, store.resourceChangesHasTimestamp)) + } + require.NoError(b, tx.Commit()) + b.ResetTimer() + for i := 0; i < b.N; i++ { + got, err := store.GetRecentChanges("app-container-0", time.Time{}, 50) + if err != nil || len(got) != 1 { + b.Fatalf("scoped history: count=%d err=%v", len(got), err) + } + } + }) + } +} + +func TestHistoryIdentityMigrationPreservesEventsAndAuthority(t *testing.T) { + dir := t.TempDir() + store, err := NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + legacy := "docker:tower/" + strings.Repeat("b", 64) + _, canonical, _ := legacyDockerHistoryIdentity(legacy) + now := time.Now().UTC().Truncate(time.Second) + event := ResourceChange{ID: "legacy-fired", ResourceID: legacy, ObservedAt: now, Kind: ChangeAlertFired, SourceType: SourcePulseDiff, Reason: "container unhealthy", Metadata: map[string]any{"alert_id": "health-test"}} + require.NoError(t, store.RecordChange(event)) + require.NoError(t, store.SetResourceOperatorState(ResourceOperatorState{CanonicalID: legacy, NeverAutoRemediate: true, Note: "keep authority binding"})) + _, err = store.db.Exec(`INSERT INTO action_audits (id, action_id, canonical_id, request_id, created_at, updated_at, state, request_json, plan_json) + VALUES ('history-action', 'history-action', ?, 'request-1', ?, ?, 'pending', '{"binding":"original"}', '{}')`, legacy, now, now) + require.NoError(t, err) + require.NoError(t, store.Close()) + // No inventory survives this restart. The legacy full ID still identifies + // the same container, and the original event is never rewritten. + store, err = NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + for _, id := range []string{legacy, canonical} { + got, err := store.GetRecentChanges(id, now.Add(-time.Minute), 10) + require.NoError(t, err) + require.Equal(t, []ResourceChange{event}, got) + count, err := store.CountRecentChanges(id, now.Add(-time.Minute)) + require.NoError(t, err) + require.Equal(t, 1, count) + kinds, err := store.CountRecentChangesByKind(id, now.Add(-time.Minute)) + require.NoError(t, err) + require.Equal(t, 1, kinds[ChangeAlertFired]) + } + state, found, err := store.GetResourceOperatorState(legacy) + require.NoError(t, err) + require.True(t, found) + require.True(t, state.NeverAutoRemediate) + require.Equal(t, "keep authority binding", state.Note) + _, found, err = store.GetResourceOperatorState(canonical) + require.NoError(t, err) + require.False(t, found) + var actionID, request, eventID string + require.NoError(t, store.db.QueryRow(`SELECT canonical_id, request_json FROM action_audits WHERE id = 'history-action'`).Scan(&actionID, &request)) + require.Equal(t, legacy, actionID) + require.Equal(t, `{"binding":"original"}`, request) + require.NoError(t, store.db.QueryRow(`SELECT canonical_id FROM resource_changes WHERE id = 'legacy-fired'`).Scan(&eventID)) + require.Equal(t, legacy, eventID) +} + +func TestHistoryIdentitySeparateHandlesSeeBindingAndReplay(t *testing.T) { + dir := t.TempDir() + writer, err := NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + defer writer.Close() + reader, err := NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + defer reader.Close() + legacy := "docker:tower/" + strings.Repeat("c", 64) + _, canonical, _ := legacyDockerHistoryIdentity(legacy) + now := time.Now().UTC().Truncate(time.Second) + old := ResourceChange{ID: "first", ResourceID: legacy, ObservedAt: now, Kind: ChangeAlertFired, SourceType: SourcePulseDiff} + require.NoError(t, writer.RecordChange(old)) + got, err := reader.GetRecentChanges(canonical, time.Time{}, 10) + require.NoError(t, err) + require.Empty(t, got) + replayed := old + replayed.ResourceID = canonical + require.NoError(t, writer.RecordChangeWithSourceIdentity(replayed, legacy)) + second := ResourceChange{ID: "second", ResourceID: canonical, ObservedAt: now.Add(time.Second), Kind: ChangeAlertResolved, SourceType: SourcePulseDiff} + require.NoError(t, writer.RecordChangeWithSourceIdentity(second, legacy)) + require.NoError(t, writer.RecordChangeWithSourceIdentity(second, legacy)) + for _, id := range []string{legacy, canonical} { + got, err := reader.GetRecentChanges(id, time.Time{}, 10) + require.NoError(t, err) + require.Equal(t, []ResourceChange{second, old}, got) + } + // The same source identifier in a different organization cannot see this binding. + other, err := NewSQLiteResourceStore(dir, "other-org") + require.NoError(t, err) + defer other.Close() + got, err = other.GetRecentChanges(canonical, time.Time{}, 10) + require.NoError(t, err) + require.Empty(t, got) +} + +func TestHistoryIdentityMonitorAdapterUsesExactContainerIdentity(t *testing.T) { + store := NewMemoryStore() + container := strings.Repeat("d", 64) + host := models.DockerHost{ID: "tower", Hostname: "tower", LastSeen: time.Now(), Containers: []models.DockerContainer{{ID: container, Name: "worker", State: "running"}}} + registry := NewRegistry(store) + registry.IngestSnapshot(models.StateSnapshot{DockerHosts: []models.DockerHost{host}}) + adapter := NewMonitorAdapter(registry) + legacy := "docker:tower/" + container + _, canonical, _ := legacyDockerHistoryIdentity(legacy) + for i, ref := range []string{legacy, "docker:tower/worker", "docker:tower/" + container[:12]} { + require.NoError(t, adapter.RecordChange(ResourceChange{ID: ref, ResourceID: ref, Kind: ChangeAlertFired, ObservedAt: time.Now().Add(time.Duration(i) * time.Second)})) + } + got, err := store.GetRecentChanges(canonical, time.Time{}, 10) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, canonical, got[0].ResourceID) + // A retained authoritative binding survives loss of the registry. + require.NoError(t, store.RecordChangeWithSourceIdentity(ResourceChange{ID: "binding", ResourceID: "app-container-retained", ObservedAt: time.Now()}, legacy)) + removed := NewMonitorAdapter(NewRegistry(store)) + require.NoError(t, removed.RecordChange(ResourceChange{ID: "after-removal", ResourceID: legacy, Kind: ChangeAlertResolved, ObservedAt: time.Now()})) + got, err = store.GetRecentChanges("app-container-retained", time.Time{}, 10) + require.NoError(t, err) + require.Len(t, got, 2) + require.Equal(t, "app-container-retained", got[0].ResourceID) +} + +func TestHistoryIdentityRetentionAndUnavailableLookup(t *testing.T) { + store, err := NewSQLiteResourceStore(t.TempDir(), "default") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + legacy := "docker:tower/" + strings.Repeat("e", 64) + _, canonical, _ := legacyDockerHistoryIdentity(legacy) + require.NoError(t, store.RecordChangeWithSourceIdentity(ResourceChange{ID: "expired", ResourceID: canonical, ObservedAt: time.Now().Add(-2 * resourceChangesRetention)}, legacy)) + store.pruneOldRecords() + _, found, err := store.ResolveHistorySourceIdentity(legacy) + require.NoError(t, err) + require.False(t, found) + require.NoError(t, store.Close()) + _, err = store.GetRecentChanges(canonical, time.Time{}, 10) + require.Error(t, err) +} diff --git a/internal/unifiedresources/monitor_adapter.go b/internal/unifiedresources/monitor_adapter.go index 6bd9fc70e..edb0b0f4d 100644 --- a/internal/unifiedresources/monitor_adapter.go +++ b/internal/unifiedresources/monitor_adapter.go @@ -111,6 +111,33 @@ func (a *MonitorAdapter) RecordChange(change ResourceChange) error { if registry == nil || registry.store == nil { return nil } + sourceRef := change.ResourceID + if sourceID, derivedID, ok := legacyDockerHistoryIdentity(sourceRef); ok { + // Use exact source identity when inventory is present, including any + // canonical identity merge. Full Docker IDs remain derivable after removal. + registry.mu.RLock() + resolvedID := registry.bySource[SourceDocker][sourceID] + registry.mu.RUnlock() + if resolvedID != "" { + change.ResourceID = resolvedID + } else { + change.ResourceID = derivedID + if history, ok := registry.store.(resourceHistoryIdentityWriter); ok { + id, found, err := history.ResolveHistorySourceIdentity(sourceRef) + if err != nil { + return err + } + if found { + change.ResourceID = id + } + } + } + } + if sourceRef != change.ResourceID { + if writer, ok := registry.store.(resourceHistoryIdentityWriter); ok { + return writer.RecordChangeWithSourceIdentity(change, sourceRef) + } + } return registry.store.RecordChange(change) } diff --git a/internal/unifiedresources/store.go b/internal/unifiedresources/store.go index 4c9a9247c..bbbf6cc65 100644 --- a/internal/unifiedresources/store.go +++ b/internal/unifiedresources/store.go @@ -609,6 +609,9 @@ func (s *SQLiteResourceStore) initSchema() error { if err := s.ensureResourceChangesIndexes(); err != nil { return err } + if err := s.migrateResourceHistoryAliases(); err != nil { + return err + } if err := s.migrateResourceIdentitiesSchema(); err != nil { return err } @@ -1428,6 +1431,13 @@ func (s *SQLiteResourceStore) pruneOldRecords() { } else if affected > 0 { totalDeleted += affected } + // History-only aliases need not outlive all records for either identifier. + // This never removes canonical identity pins or authority-bearing state. + if _, err := s.db.Exec(`DELETE FROM resource_history_aliases + WHERE NOT EXISTS (SELECT 1 FROM resource_changes WHERE canonical_id = resource_history_aliases.source_id) + AND NOT EXISTS (SELECT 1 FROM resource_changes WHERE canonical_id = resource_history_aliases.canonical_id)`); err != nil { + log.Printf("unifiedresources: failed to prune resource history identities: %v", err) + } res, err = s.db.Exec( `DELETE FROM action_audits WHERE created_at < ?`, @@ -1614,10 +1624,10 @@ func (s *SQLiteResourceStore) queryResourceIdentityPins() ([]ResourceIdentityPin // history. Resources without pins (Proxmox guests, record-declared eras) // merge through the durable canonical_id_successions record instead. Unknown // IDs expand to themselves. -func (s *SQLiteResourceStore) resourceChangeIDSet(canonicalID string) []string { +func (s *SQLiteResourceStore) resourceChangeIDSet(canonicalID string) ([]string, error) { canonicalID = CanonicalResourceID(canonicalID) if canonicalID == "" { - return nil + return nil, nil } s.identityPinMu.Lock() @@ -1631,7 +1641,7 @@ func (s *SQLiteResourceStore) resourceChangeIDSet(canonicalID string) []string { pins := s.identityPinCache s.identityPinMu.Unlock() - return expandResourceChangeIDs(canonicalID, pins, s.successionMap()) + return s.expandHistoryAliases(expandResourceChangeIDs(canonicalID, pins, s.successionMap())) } func expandResourceChangeIDs(canonicalID string, pins []ResourceIdentityPin, successors map[string]string) []string { @@ -1757,7 +1767,11 @@ func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since conditions := []string{} canonicalID = CanonicalResourceID(canonicalID) if canonicalID != "" { - conditions, args = appendRecentChangeResourceCondition(conditions, args, s.resourceChangeIDSet(canonicalID), filters.IncludeRelated) + ids, err := s.resourceChangeIDSet(canonicalID) + if err != nil { + return nil, err + } + conditions, args = appendRecentChangeResourceCondition(conditions, args, ids, filters.IncludeRelated) } else { conditions = append(conditions, observedAtExpr+" >= ?") args = append(args, since) @@ -1882,8 +1896,12 @@ func (s *SQLiteResourceStore) CountRecentChanges(canonicalID string, since time. } func (s *SQLiteResourceStore) CountRecentChangesFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (int, error) { + ids, err := s.resourceChangeIDSet(canonicalID) + if err != nil { + return 0, err + } query, args := buildRecentChangeCountQuery( - s.resourceChangeIDSet(canonicalID), + ids, since, filters, "SELECT COUNT(*) FROM resource_changes", @@ -1907,8 +1925,12 @@ func (s *SQLiteResourceStore) CountRecentChangesByKind(canonicalID string, since } func (s *SQLiteResourceStore) CountRecentChangesByKindFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeKind]int, error) { + ids, err := s.resourceChangeIDSet(canonicalID) + if err != nil { + return nil, err + } query, args := buildRecentChangeCountQuery( - s.resourceChangeIDSet(canonicalID), + ids, since, filters, "SELECT COALESCE(kind, ''), COUNT(*) FROM resource_changes", @@ -1950,9 +1972,13 @@ func (s *SQLiteResourceStore) CountRecentChangesBySourceType(canonicalID string, } func (s *SQLiteResourceStore) CountRecentChangesBySourceTypeFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceType]int, error) { + ids, err := s.resourceChangeIDSet(canonicalID) + if err != nil { + return nil, err + } sourceTypeExpr := s.resourceChangesSourceTypeExpr() query, args := buildRecentChangeCountQuery( - s.resourceChangeIDSet(canonicalID), + ids, since, filters, "SELECT "+sourceTypeExpr+", COUNT(*) FROM resource_changes", @@ -1994,9 +2020,13 @@ func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapter(canonicalID stri } func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapterFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceAdapter]int, error) { + ids, err := s.resourceChangeIDSet(canonicalID) + if err != nil { + return nil, err + } sourceAdapterExpr := s.resourceChangesSourceAdapterExpr() query, args := buildRecentChangeCountQuery( - s.resourceChangeIDSet(canonicalID), + ids, since, filters, "SELECT "+sourceAdapterExpr+", COUNT(*) FROM resource_changes", @@ -3352,6 +3382,7 @@ type MemoryStore struct { loopReports map[string]LoopReport identityPins map[string]ResourceIdentityPin canonicalSuccessions map[string]string + historyAliases map[string]string } func NewMemoryStore() *MemoryStore { @@ -3422,7 +3453,11 @@ func (m *MemoryStore) resourceChangeIDSetLocked(canonicalID string) []string { for _, pin := range m.identityPins { pins = append(pins, pin) } - return expandResourceChangeIDs(canonicalID, pins, m.canonicalSuccessions) + if resolved := m.historyAliases[canonicalID]; resolved != "" { + canonicalID = resolved + } + ids := expandResourceChangeIDs(canonicalID, pins, m.canonicalSuccessions) + return appendSupersededChangeIDs(ids, m.historyAliases) } func (m *MemoryStore) AddLink(link ResourceLink) error { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 197272676..36c799ae3 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -4464,6 +4464,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "internal/monitoring/issue1595_collection_trust_test.go", "internal/unifiedresources/availability_link_test.go", + "internal/unifiedresources/history_identity_test.go", "internal/unifiedresources/kubernetes_registry_test.go", "internal/unifiedresources/pbs_pmg_registry_test.go", "internal/unifiedresources/registry_merge_policy_test.go", @@ -4494,6 +4495,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "internal/monitoring/issue1595_collection_trust_test.go", "internal/unifiedresources/availability_link_test.go", + "internal/unifiedresources/history_identity_test.go", "internal/unifiedresources/kubernetes_registry_test.go", "internal/unifiedresources/pbs_pmg_registry_test.go", "internal/unifiedresources/registry_merge_policy_test.go",