diff --git a/docs/AI.md b/docs/AI.md index 0db5677fc..d11ab87ad 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -121,6 +121,13 @@ only inside selected context and receives policy-shaped summaries and references. Assistant can explain the item, but it cannot create lifecycle truth, invent action authority, or hide uncertainty. +Open items can be acknowledged directly. Suppression is temporary and requires +an operator reason plus a bounded expiry; it never resolves the detector's +finding. Eligible Pulse Pro Docker health items can offer a governed restart. +That journey uses the canonical action plan, approval, execution, audit, and +verification APIs. A successful command does not resolve the attention item; +fresh detector evidence must still confirm recovery. + ### What Patrol Sees Every patrol run passes the LLM comprehensive context about your environment: diff --git a/docs/API.md b/docs/API.md index 364eb0a26..22c47b542 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1066,9 +1066,27 @@ Runs a focused investigation for an alert payload (used by the UI). - Returns one attention item with its operational record, lifecycle timeline, typed evidence, recommended next step, relationships, and protection posture. -- All three attention routes require `monitoring:read`. They are read-side - projections only; alert lifecycle mutations remain owned by the canonical - alert routes. +- `GET /api/ai/patrol/attention/{id}/evidence/{evidenceId}` + - Returns one exact retained evidence envelope with its current freshness. + - Returns `410 attention_evidence_detail_expired` when the operational + record still links the ID but the bounded detail has expired. +- `POST /api/ai/patrol/attention/{id}/acknowledge` +- `POST /api/ai/patrol/attention/{id}/unacknowledge` +- `POST /api/ai/patrol/attention/{id}/suppress` + - Body: `{ "reason": "...", "expiresAt": "" }`. + - The expiry must be in the future and no more than 30 days away. +- `POST /api/ai/patrol/attention/{id}/unsuppress` +- `POST /api/ai/patrol/attention/{id}/actions/restart/plan` + - Creates or replays the one server-owned Docker restart plan attached to + this operational record and its exact evidence IDs. + - Requires the canonical action authorization, an eligible server-side + offer, and the Pulse Pro `ai_autofix` entitlement. Clients cannot supply + command authority or override the target. +- Attention reads require `monitoring:read`; lifecycle mutations require + `monitoring:write`. Action decision and execution use `/api/actions` and + retain their existing action-specific scopes. +- Attention IDs and evidence IDs are opaque and can contain slashes. Clients + must path-escape each ID. - `GET /api/ai/patrol/autonomy` - `PUT /api/ai/patrol/autonomy` - `GET /api/ai/patrol/status` diff --git a/docs/OPERATIONAL_TRUST.md b/docs/OPERATIONAL_TRUST.md new file mode 100644 index 000000000..af24e836e --- /dev/null +++ b/docs/OPERATIONAL_TRUST.md @@ -0,0 +1,202 @@ +# Operational Trust + +Operational Trust is Pulse v6's shared model for answering five operator +questions: + +1. What needs attention now? +2. What evidence supports that conclusion? +3. Is the affected resource protected by usable recovery history? +4. What changed in the issue lifecycle? +5. Can Pulse offer a narrow action, and did fresh evidence verify its result? + +Alerts, the Patrol attention queue, navigation counts, attached availability +checks, notifications, protection posture, and governed actions project the +same canonical records. None of those views owns a second writable lifecycle. + +## Lifecycle states + +| State | Operator meaning | +| :--- | :--- | +| `observing` | Evidence is being confirmed. This is not yet active work. | +| `open` | Current evidence supports an operational issue. | +| `acknowledged` | An operator has seen the issue. The issue is not resolved. | +| `suppressed` | The issue is temporarily removed from active attention with an actor, reason, and bounded expiry. | +| `resolving` | Recovery evidence exists, but the detector has not yet confirmed normal health. | +| `resolved` | Fresh detector evidence confirms that the issue no longer applies. | +| `stale` | The prior issue remains relevant, but its collection evidence is no longer current. | +| `unknown` | Permissions, completeness, provider state, or identity prevent a stronger conclusion. | + +Missing observations never resolve an open record. Collector disconnects move +existing work to `stale`; permission or provider uncertainty moves it to +`unknown`. A successful action result also does not close a record. Only fresh +detector evidence can confirm recovery. + +Acknowledgement is reversible and does not reduce the active issue truth. +Suppression requires a non-empty reason and an expiry no more than 30 days in +the future. The Patrol UI offers shorter 1-hour, 24-hour, and 7-day choices by +default. + +## Evidence + +Every evidence envelope records: + +- a stable opaque evidence ID; +- provider, collector, and optional provider instance; +- one canonical resource ID or one unresolved provider-scoped reference; +- observation, ingestion, and optional validity times; +- completeness, confidence, and permission state; +- an optional bounded payload reference and identity-correlation proof. + +`fresh`, `complete`, and `confirmed` are independent dimensions. Partial, +denied, unavailable, stale, inferred, ambiguous, and unknown evidence is +represented explicitly and never upgraded to healthy by a client. + +The attention detail response contains the retained envelopes needed for the +normal operator journey. An authorized client can request one exact envelope +through the evidence endpoint. If the record still links the evidence ID but +its bounded detail has expired, the endpoint returns `410 +attention_evidence_detail_expired`; it does not pretend the evidence never +existed. + +## Protection posture + +Protection posture is evaluated server-side per canonical subject resource: + +- `protected`: current usable protection satisfies policy; +- `attention`: protection exists but freshness, verification, or provider + outcome needs attention; +- `unprotected`: sufficient evidence confirms that required protection is + absent; +- `unknown`: identity, history, permissions, or collection coverage cannot + support a stronger claim. + +The response explains the conclusion, preserves provider-specific state, and +links the recovery and repository resources involved. Platform tables fetch +posture in batches of at most 200 resource IDs. They must not issue one network +request per row. + +## Availability + +An availability check attaches to an existing unified resource only when an +explicit link resolves or identity correlation yields exactly one candidate. +The relationship carries a stable relationship ID and the evidence ID that +supports it. Ambiguous checks remain standalone. An attached check appears on +the owning platform row and detail; it is not duplicated as another inventory +resource. + +Availability success is time-bounded. A stale successful observation is +`stale`, not healthy. A failure enters the same alert lifecycle and Patrol +attention queue as other detectors. + +## Patrol workflow + +The normal operator path is: + +1. Open Patrol from the monitor shell. +2. Review the urgency-ordered active queue. +3. Select an item for impact, next step, resource, evidence, protection, and + lifecycle detail. +4. Acknowledge it, or temporarily suppress it with a reason and expiry. +5. If an eligible Pulse Pro action is offered, review the server-owned plan, + approve it, run it, and inspect execution and verification separately. + +A calm state appears only when the lifecycle evaluation succeeded, coverage is +current, and no active item exists. A failed read or partial coverage never +becomes a calm claim. + +The first governed action is a Docker container restart. It is offered only +for a uniquely identified container with fresh confirmed unhealthy evidence, +declared executor readiness, the required authorization scope, and the +`ai_autofix` entitlement. The action framework owns plan hashing, approval, +idempotent execution, durable audit, restart reconciliation, and verification. + +## API and authorization + +Read routes require `monitoring:read`: + +- `GET /api/ai/patrol/attention` +- `GET /api/ai/patrol/attention/summary` +- `GET /api/ai/patrol/attention/{id}` +- `GET /api/ai/patrol/attention/{id}/evidence/{evidenceId}` + +Lifecycle mutations require `monitoring:write`: + +- `POST /api/ai/patrol/attention/{id}/acknowledge` +- `POST /api/ai/patrol/attention/{id}/unacknowledge` +- `POST /api/ai/patrol/attention/{id}/suppress` +- `POST /api/ai/patrol/attention/{id}/unsuppress` + +Suppression body: + +```json +{ + "reason": "Planned host maintenance", + "expiresAt": "2026-07-20T08:00:00Z" +} +``` + +Planning an offered restart requires the action scopes enforced by the +canonical action API and an active `ai_autofix` entitlement: + +```text +POST /api/ai/patrol/attention/{id}/actions/restart/plan +``` + +Action decision, execution, detail, and audit use `/api/actions`. + +All IDs are opaque. Clients must path-escape operational-record and evidence +IDs because canonical IDs can contain `/`, `:`, and provider-specific +segments. + +## Metrics + +The `/metrics` listener exposes Operational Trust counters and histograms under +`pulse_operational_trust_*`. Labels use closed, low-cardinality vocabularies; +resource IDs, evidence IDs, provider-instance names, actors, and destination +IDs never appear as labels. + +Useful alerts include: + +- sustained growth in `active_count_mismatch_total`; +- notification `failed` or `dead_letter` outcomes; +- growing stale, unavailable, denied, or partial evidence observations; +- identity `ambiguous` or `unresolved` outcomes; +- action verification `contradicted`, `inconclusive`, or `timed_out` outcomes. + +## Upgrade and compatibility + +Operational Trust migrations are additive. Existing alert, notification, +recovery, relationship, availability, and action records are normalized on +read or migrated in their owning stores. Read-side compatibility fields remain +supported where older clients need them, but new writes go only through the +canonical lifecycle, recovery, unified-resource, notification, and action +owners. + +The Pulse Mobile primary backlog uses `/api/ai/patrol/attention` and preserves +operational record, evidence, and action-verification identity. Its old finding +shape is now a local display adapter, not a writable source of truth. + +Before upgrading: + +1. back up the Pulse data directory; +2. confirm that the v6 process can write its alert, notification, recovery, and + action database directories; +3. confirm supported clients can read additive JSON fields; +4. expose the metrics listener to a protected scraper if rollout telemetry is + required; +5. verify Pulse Pro entitlement connectivity before relying on action offers. + +After upgrading: + +1. confirm the Patrol navigation count matches the active queue; +2. inspect an active item through its deepest evidence and protection detail; +3. acknowledge and unacknowledge a test item; +4. verify a bounded suppression returns to active attention; +5. confirm stale collection remains visible rather than resolving; +6. exercise notification retry/dead-letter monitoring; +7. if using Pulse Pro actions, complete a review/approve/run/verify journey. + +If a migration fails, stop the upgraded process, preserve the data directory +and logs, and restore the prior release with the pre-upgrade data backup. Do +not delete lifecycle, evidence, notification, recovery, or action records to +force startup. diff --git a/docs/UPGRADE_v6.md b/docs/UPGRADE_v6.md index 74eee91e3..0981c6664 100644 --- a/docs/UPGRADE_v6.md +++ b/docs/UPGRADE_v6.md @@ -294,6 +294,37 @@ Practical recommendation: - Before upgrading, keep console access available so you can retry activation from the v6 license panel if the exchange service is temporarily unavailable. +## Operational Trust migration + +Pulse v6 consolidates alerts, Patrol attention, evidence, protection posture, +attached availability checks, notifications, and governed action verification +onto one Operational Trust lifecycle. See +[`OPERATIONAL_TRUST.md`](OPERATIONAL_TRUST.md) for the operator contract and +post-upgrade checks. + +The migrations are additive: + +- existing alert state is normalized into operational records and transitions; +- notification delivery keeps exact operational-record and transition links; +- recovery points and provider observations materialize provider-aware + protection posture; +- unified-resource relationships and availability facets gain stable evidence + linkage; +- action audit records preserve execution and verification as separate truth. + +Supported legacy JSON fields remain readable where older clients need them, +but the primary v6 runtime has one writable owner for each domain. Pulse Mobile +now reads the canonical Patrol attention queue. Operators should upgrade +desktop and mobile clients together when they depend on acknowledgement, +evidence, protection, or action-verification parity. + +Before the upgrade, back up the Pulse data directory and confirm the service +account can write the alert, notification, recovery, and action stores. After +startup, verify that the Patrol navigation count matches the active queue, +inspect one evidence/protection drill-down, and confirm stale collection does +not appear resolved. Pulse Pro users should also verify entitlement +connectivity before relying on restart offers. + ### Multi-Tenant (Opt-In) Multi-tenant mode is opt-in and additionally license-gated: diff --git a/docs/monitoring/PROMETHEUS_METRICS.md b/docs/monitoring/PROMETHEUS_METRICS.md index b98a12955..51e348cb9 100644 --- a/docs/monitoring/PROMETHEUS_METRICS.md +++ b/docs/monitoring/PROMETHEUS_METRICS.md @@ -78,6 +78,31 @@ In Docker and Kubernetes you must expose `9091` explicitly if you want to scrape | `pulse_alerts_rate_limited_total` | Counter | Alerts suppressed due to rate limiting. | | `pulse_alert_duration_seconds` | Histogram | Time from alert fire to resolve (by `type`). | +## Operational Trust + +Operational Trust metrics use closed low-cardinality labels. They never label +series with resource, operational-record, evidence, actor, provider-instance, +or notification-destination IDs. + +| Metric | Type | Description | +| :--- | :--- | :--- | +| `pulse_operational_trust_observation_to_open_seconds` | Histogram | Time from the first canonical observation to an open operational record. | +| `pulse_operational_trust_open_to_notification_enqueue_seconds` | Histogram | Time from an open transition to durable notification enqueue. | +| `pulse_operational_trust_evidence_observations_total` | Counter | Evidence observations by bounded `source` and state (`current`, `stale`, `unknown`, `partial`, `unavailable`, `partial_permission`, `denied`, or `other`). | +| `pulse_operational_trust_identity_correlations_total` | Counter | Resource correlation outcomes (`attached`, `standalone`, `ambiguous`, `unresolved`, or `other`). | +| `pulse_operational_trust_protection_posture_evaluations_total` | Counter | Protection posture evaluations by state. | +| `pulse_operational_trust_protection_posture_evaluation_failures_total` | Counter | Posture evaluation failures by bounded reason. | +| `pulse_operational_trust_notification_delivery_total` | Counter | Transition-linked delivery outcomes (`queued`, `retry`, `sent`, `failed`, `dead_letter`, `cancelled`, or `other`). | +| `pulse_operational_trust_active_count_mismatch_total` | Counter | Detected disagreement between the canonical active records and attention projection. | +| `pulse_operational_trust_action_offers_total` | Counter | Action-offer projections by eligibility. | +| `pulse_operational_trust_action_verification_total` | Counter | Verification outcomes, including confirmed, contradicted, inconclusive, timed out, and not attempted. | + +Example rollout alerts: + +- `increase(pulse_operational_trust_active_count_mismatch_total[15m]) > 0` +- `increase(pulse_operational_trust_notification_delivery_total{outcome="dead_letter"}[15m]) > 0` +- `increase(pulse_operational_trust_action_verification_total{outcome=~"contradicted|timed_out"}[30m]) > 0` + ## 🚨 Alerting Examples - **High Error Rate**: `rate(pulse_http_request_errors_total[5m]) > 0.05` - **Stale Node**: `pulse_monitor_node_poll_staleness_seconds > 300` diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index ed243644a..fd4c092f2 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -4660,3 +4660,15 @@ deliver a command itself or add an agent wire shape. Actual dispatch retains the canonical action executor, exact agent/resource binding, command-enabled token policy, durable attempt/receipt, timeout, and restart-reconciliation boundaries. + +### Operational Trust lifecycle mutation boundary + +The attention evidence and lifecycle mutation handlers under `internal/api/` +remain resource-monitoring consumers. Acknowledge, unacknowledge, suppress, and +unsuppress call the alerts-owned operational lifecycle and persist through the +active-alert store; they do not mint agent credentials, alter observer config, +or dispatch commands. Evidence reads require `monitoring:read`, lifecycle +mutations require `monitoring:write`, and slash-containing canonical record IDs +are parsed as opaque identities. Governed action planning still enters the +existing action lifecycle and entitlement boundary before any agent executor +is considered. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index fb96d6525..fe87c81e1 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -6635,3 +6635,14 @@ review. Verification presentation is derived only from the durable canonical action state and `ActionResultV2`: confirmed, contradicted, inconclusive, and not-attempted remain distinct. The projection has no authority to resolve the operational record. + +### Operational Trust rollout telemetry and lifecycle controls + +Patrol exposes evidence-backed lifecycle controls only for the selected +canonical attention item. Acknowledge is reversible. Suppression requires a +reason and bounded expiry. Neither action changes detector truth, and stale or +unknown collection state remains stronger than a retained legacy +acknowledgement flag. `internal/ai/patrol_metrics.go` compares the canonical +active summary with the projected state set and increments one unlabeled +low-cardinality mismatch counter on contradiction. It never labels metrics with +resource, record, evidence, actor, or provider-instance identity. diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 0662a477a..89942ad1a 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -1137,3 +1137,21 @@ That same ownership also governs acknowledgement and manual-clear cleanup. Clearing an alert through the canonical alerts runtime must remove both legacy public-id tracking and canonical-state acknowledgement records so old aliases cannot keep an alert acknowledged after the canonical alert has been removed. + +### Operational Trust writable lifecycle + +`internal/alerts/active_lifecycle.go` is the single writable owner for +acknowledge, unacknowledge, suppress, unsuppress, collection-stale, +collection-unknown, resolving, and collection-restored transitions. Every +writer is idempotent under retry, retains new evidence without duplicating a +same-state transition, persists through the active-alert store, and survives +restart. Explicit stale, unknown, resolving, or suppressed state takes +precedence over the legacy `Alert.Acknowledged` projection. The canonical +record's `LastObservedAt` is the maximum of retained record, alert, and evidence +timestamps so a fresh outage observation cannot be rolled back by an older +legacy alert timestamp. + +Suppression is bounded and reasoned, leaves the default active queue, and +remains inspectable. Expiry or explicit unsuppression returns the record to its +detector-owned state; it never resolves it. Only fresh sufficient recovery +evidence may enter resolving, and only detector recovery may resolve. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 1952172b2..5a24d6298 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -7844,3 +7844,23 @@ plan is replayed rather than duplicated. Decision, execution, receipt, reconciliation, result, and verification remain on the existing `/api/actions` contract. Provider success does not mutate the operational record; only fresh detector recovery evidence may resolve it. + +### Operational Trust evidence and lifecycle mutation transport + +The canonical selected-item transport now includes: + +1. `GET /api/ai/patrol/attention/{id}/evidence/{evidenceId}` under + `monitoring:read`; retained evidence returns its typed envelope and + freshness, while an expired referenced envelope returns typed `410 Gone` + instead of false absence or health. +2. `POST /api/ai/patrol/attention/{id}/acknowledge` and + `/unacknowledge` under `monitoring:write`. +3. `POST /api/ai/patrol/attention/{id}/suppress` with a non-empty reason and + bounded future `expiresAt`, and `/unsuppress`, under `monitoring:write`. + +Every mutation reloads the canonical active record, delegates to the +alerts-owned lifecycle writer, and returns the refreshed shared detail +projection. The browser cannot submit operational state, transition, +evidence, actor, or subject identity. Action offers and planning additionally +require the canonical `ai_autofix` entitlement and return the normal +license-required `402` contract when it is absent. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 163950d04..7aef46344 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5101,3 +5101,15 @@ Protection table controls continue to compose shared filter, table, status, counter, loading, error, disclosure, and inline-detail primitives. The bounded batch hook retains fulfilled values during refresh and issues at most one request per 200 resource rows, rather than placing a request under each row. + +### Operational Trust attention interaction boundary + +The Patrol selected-detail surface owns the smallest lifecycle interaction +set: acknowledge, return to open, temporary suppression with a required reason +and one of the bounded 1-hour, 24-hour, or 7-day durations, and return to +active. Every mutation refreshes both the shared detail and list projection. +The queue retains screen-reader names, keyboard activation, focus restoration +after node replacement, reduced-motion behavior, and a no-overflow phone +layout. Raw evidence history, lifecycle timeline, provider limitations, and +action audit remain one disclosure or shared review deeper; no platform table +or Assistant surface recreates these controls. diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index 48a294dbe..58b4f95d5 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -244,3 +244,15 @@ channel targeting. `api-contracts`: they are the product-facing control surface for notification-management transport, while canonical payload-shape governance still remains explicit in the shared API contract boundary. + +### Operational Trust delivery observability + +Notification queue rows retain the exact operational record and lifecycle +transition IDs through grouping, retry, restart, send, cancellation, and dead +letter. Restart during a queued retry must reopen the same durable delivery +with the same transition links; it must not synthesize a new operational +transition. `internal/notifications/queue.go` records bounded queue, +retry/sent/failed/dead-letter/cancelled outcomes and open-to-enqueue latency +without destination, record, resource, or evidence labels. Delivery state +remains notification truth only and cannot resolve or reopen the alert +lifecycle. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index f941539ac..365248021 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -2109,3 +2109,15 @@ nor its action authorizes a reboot. Contradicted, inconclusive, or health-unknow postconditions remain actionable; only canonical confirmed postcondition truth may support resolution. Package-manager internals remain forensic detail rather than the least-expert default. + +### Operational Trust attention queue completion + +Patrol is the named monitor-first attention destination over one canonical +operational lifecycle. Active, open, acknowledged, suppressed, stale/unknown, +resolved, unavailable-evaluation, and honest calm states share one summary and +detail projection. The selected detail owns reversible acknowledgement and +bounded reasoned suppression, while Assistant remains explanation-only selected +context. Evidence, impact, relationships, protection posture, lifecycle +timeline, action origin, and verification truth remain linked to the same +record. Mobile consumes the same list/detail and acknowledgement contracts; it +does not revive the retired primary findings feed. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index b43c6ade1..7179f7211 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1941,3 +1941,19 @@ the indexed `origin_json` operational-record expression; MemoryStore performs one bounded pass. The summary path performs no registry, action-store, executor-readiness, or action-origin work. Readiness checks remain local to the already-bounded visible page and do not issue browser requests per row. + +### Operational Trust rollout bounds + +The completed Operational Trust path preserves these explicit bounds: + +1. active-count and summary evaluation use the in-memory canonical projection; +2. attention pages are capped at 200 items and a 10,000-record projection has + a five-second shared-CI ceiling; +3. recovery posture joins batch at 200 subjects and platform tables issue one + bounded request rather than one request per row; +4. action enrichment occurs after pagination and uses one registry, authority, + and latest-origin batch; +5. evidence and recovery retention remain store-owned, indexed, bounded, and + covered by query-plan and retention tests; +6. Operational Trust telemetry uses bounded enumerated labels only and never + introduces resource-cardinality labels. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 3f16f7698..716359def 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -1444,3 +1444,15 @@ origin, target, evidence set, handler, actor, or parameters. The server binds the exact operational record and policy-shaped evidence IDs internally. Unauthorized, stale, partial, permission-limited, ambiguous, unsupported, or executor-unready records return no offer and no cross-resource detail. + +### Operational Trust evidence and mutation authorization + +Evidence detail is tenant-scoped through the selected canonical record and +requires `monitoring:read`; lifecycle mutation requires `monitoring:write`. +Unknown record/evidence pairs do not permit cross-resource enumeration, and an +expired referenced envelope returns typed expiry without disclosing another +record. Acknowledge and suppression actors are server-derived. Suppression +accepts only a non-empty reason and bounded future expiry. Action offer and +planning require the `ai_autofix` entitlement in addition to current RBAC, +evidence, resource-capability, executor-readiness, approval, and action-policy +checks. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 64f837911..6709e63f0 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2180,6 +2180,17 @@ while storage detail drawers and filter controls must route summary series IDs, source tones, and disk metrics through the shared storage helpers instead of reconstructing them from local table state. +### Operational Trust posture rollout + +Protection posture remains a provider-aware, subject-linked read model over +recovery points and provider collection evidence. Invalid policy falls back to +the canonical bounded default and records a bounded failure reason; invalid, +partial, permission-denied, failed, stale, missing, or unknown provider +evidence cannot produce `protected`. Evaluations record only the four canonical +posture states. Platform and Patrol consumers use bounded batch posture reads, +retain evidence and repository links, and never infer protection from the +existence of a row, snapshot, or provider connection. + The adjacent Operational Trust attention action transport does not turn protection posture into mutation authority. The selected item may display storage/recovery-owned posture beside a separately declared Docker restart diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 7ab89279c..8b156a357 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1815,6 +1815,17 @@ through the canonical resource model, but unified-resource consumers must not reintroduce removed workload aliases or feature-local resource-type shims just to satisfy one table, drawer, or badge surface. +### Operational Trust relationship identity + +Operational Trust relationships use a stable normalized relationship ID and +may bind an exact evidence ID. Availability attachment, hosting, storage, +protection, and membership relationships normalize through the unified +registry; ambiguous identity remains unattached and emits only a bounded +correlation outcome. Attached availability is a facet of the known resource +and must not create a duplicate standalone resource or second primary +presentation. Supported legacy relationship payloads may omit the additive ID, +but all primary runtime projections author the canonical stable identity. + Patrol attention resource navigation carries the canonical subject resource ID through shared route builders as an opaque query value. Attention deep links carry the canonical operational-record ID separately. Neither link may derive diff --git a/frontend-modern/src/api/__tests__/patrolAttention.test.ts b/frontend-modern/src/api/__tests__/patrolAttention.test.ts index 1e4f017f6..fb5296015 100644 --- a/frontend-modern/src/api/__tests__/patrolAttention.test.ts +++ b/frontend-modern/src/api/__tests__/patrolAttention.test.ts @@ -5,10 +5,15 @@ vi.mock('@/utils/apiClient', () => ({ })); import { + acknowledgePatrolAttention, getPatrolAttention, getPatrolAttentionDetail, + getPatrolAttentionEvidence, getPatrolAttentionSummary, planPatrolAttentionAction, + suppressPatrolAttention, + unacknowledgePatrolAttention, + unsuppressPatrolAttention, } from '@/api/patrolAttention'; import { apiFetchJSON } from '@/utils/apiClient'; @@ -33,6 +38,40 @@ describe('Patrol attention API', () => { await getPatrolAttentionDetail('record/one'); expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/record%2Fone'); + + await getPatrolAttentionEvidence('record/one', 'evidence/two'); + expect(fetchMock).toHaveBeenLastCalledWith( + '/api/ai/patrol/attention/record%2Fone/evidence/evidence%2Ftwo', + ); + }); + + it('uses canonical item-scoped lifecycle mutations with bounded suppression input', async () => { + await acknowledgePatrolAttention('record/one'); + expect(fetchMock).toHaveBeenLastCalledWith( + '/api/ai/patrol/attention/record%2Fone/acknowledge', + { method: 'POST', body: '{}' }, + ); + + await unacknowledgePatrolAttention('record/one'); + expect(fetchMock).toHaveBeenLastCalledWith( + '/api/ai/patrol/attention/record%2Fone/unacknowledge', + { method: 'POST', body: '{}' }, + ); + + await suppressPatrolAttention('record/one', 'Maintenance window', '2026-07-20T08:00:00Z'); + expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/record%2Fone/suppress', { + method: 'POST', + body: JSON.stringify({ + reason: 'Maintenance window', + expiresAt: '2026-07-20T08:00:00Z', + }), + }); + + await unsuppressPatrolAttention('record/one'); + expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/record%2Fone/unsuppress', { + method: 'POST', + body: '{}', + }); }); it('plans the fixed attention capability without accepting public action authority', async () => { diff --git a/frontend-modern/src/api/patrolAttention.ts b/frontend-modern/src/api/patrolAttention.ts index b1c957a32..7ef8a416a 100644 --- a/frontend-modern/src/api/patrolAttention.ts +++ b/frontend-modern/src/api/patrolAttention.ts @@ -91,6 +91,16 @@ export interface AttentionListResponse { }; } +export interface AttentionEvidenceResponse { + evidence: EvidenceEnvelope; + freshness: EvidenceFreshness; + retained: boolean; +} + +export interface AttentionMutationResponse { + success: boolean; +} + export async function getPatrolAttention( filter: AttentionFilter = 'active', page = 1, @@ -114,6 +124,62 @@ export async function getPatrolAttentionDetail(itemId: string): Promise { + return apiFetchJSON( + `/api/ai/patrol/attention/${encodeURIComponent(itemId)}/evidence/${encodeURIComponent(evidenceId)}`, + ); +} + +async function mutatePatrolAttention( + itemId: string, + mutation: 'acknowledge' | 'unacknowledge' | 'suppress' | 'unsuppress', + body = '{}', +): Promise { + return apiFetchJSON( + `/api/ai/patrol/attention/${encodeURIComponent(itemId)}/${mutation}`, + { + method: 'POST', + body, + }, + ); +} + +export async function acknowledgePatrolAttention( + itemId: string, +): Promise { + return mutatePatrolAttention(itemId, 'acknowledge'); +} + +export async function unacknowledgePatrolAttention( + itemId: string, +): Promise { + return mutatePatrolAttention(itemId, 'unacknowledge'); +} + +export async function suppressPatrolAttention( + itemId: string, + reason: string, + expiresAt: string, +): Promise { + return mutatePatrolAttention( + itemId, + 'suppress', + JSON.stringify({ + reason, + expiresAt, + }), + ); +} + +export async function unsuppressPatrolAttention( + itemId: string, +): Promise { + return mutatePatrolAttention(itemId, 'unsuppress'); +} + export async function planPatrolAttentionAction( itemId: string, capability: string, diff --git a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx index d3b1f2607..5c2f14efe 100644 --- a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx +++ b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx @@ -16,6 +16,7 @@ import ClockIcon from 'lucide-solid/icons/clock'; import ExternalLinkIcon from 'lucide-solid/icons/external-link'; import RefreshIcon from 'lucide-solid/icons/refresh-cw'; import RotateCwIcon from 'lucide-solid/icons/rotate-cw'; +import ShieldOffIcon from 'lucide-solid/icons/shield-off'; import SparklesIcon from 'lucide-solid/icons/sparkles'; import XIcon from 'lucide-solid/icons/x'; import type { @@ -24,7 +25,13 @@ import type { AttentionItem, AttentionItemDetail, } from '@/api/patrolAttention'; -import { planPatrolAttentionAction } from '@/api/patrolAttention'; +import { + acknowledgePatrolAttention, + planPatrolAttentionAction, + suppressPatrolAttention, + unacknowledgePatrolAttention, + unsuppressPatrolAttention, +} from '@/api/patrolAttention'; import { ResourceActionsAPI } from '@/api/resourceActions'; import { Button, ButtonLink } from '@/components/shared/Button'; import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; @@ -40,6 +47,7 @@ import { } from '@/routing/resourceLinks'; import type { EvidenceEnvelope } from '@/types/operationalTrust'; import type { ActionDetailResponse } from '@/types/actionAudit'; +import { getAlertResourceIncidentAcknowledgedByLabel } from '@/utils/alertIncidentPresentation'; import { formatRelativeTime } from '@/utils/format'; const PRIMARY_EVIDENCE_LIMIT = 3; @@ -59,6 +67,8 @@ export function PatrolAttentionWorkbench() { const [actionDetail, setActionDetail] = createSignal(null); const [actionBusy, setActionBusy] = createSignal(false); const [actionError, setActionError] = createSignal(''); + const [lifecycleBusy, setLifecycleBusy] = createSignal(false); + const [lifecycleError, setLifecycleError] = createSignal(''); const itemButtons = new Map(); let detailPanel: HTMLDivElement | undefined; let actionTrigger: HTMLButtonElement | undefined; @@ -149,6 +159,25 @@ export function PatrolAttentionWorkbench() { patrolAttentionStore.load(patrolAttentionStore.filter()), ]); }; + const changeLifecycle = async (operation: () => Promise) => { + if (lifecycleBusy()) return; + setLifecycleBusy(true); + setLifecycleError(''); + try { + await operation(); + const selected = selectedItemId(); + await Promise.all([ + selected ? patrolAttentionStore.select(selected) : Promise.resolve(), + patrolAttentionStore.load(patrolAttentionStore.filter()), + ]); + } catch (cause) { + setLifecycleError( + cause instanceof Error ? cause.message : 'The lifecycle change could not be saved.', + ); + } finally { + setLifecycleBusy(false); + } + }; onMount(() => { void patrolAttentionStore.load('active'); @@ -271,6 +300,16 @@ export function PatrolAttentionWorkbench() { actionBusy={actionBusy()} actionError={actionError()} onReviewAction={reviewAction} + lifecycleBusy={lifecycleBusy()} + lifecycleError={lifecycleError()} + onAcknowledge={(itemId) => changeLifecycle(() => acknowledgePatrolAttention(itemId))} + onUnacknowledge={(itemId) => + changeLifecycle(() => unacknowledgePatrolAttention(itemId)) + } + onSuppress={(itemId, reason, expiresAt) => + changeLifecycle(() => suppressPatrolAttention(itemId, reason, expiresAt)) + } + onUnsuppress={(itemId) => changeLifecycle(() => unsuppressPatrolAttention(itemId))} /> @@ -425,6 +464,12 @@ function AttentionDetail(props: { offer: AttentionActionOffer, trigger: HTMLButtonElement, ) => void; + lifecycleBusy: boolean; + lifecycleError: string; + onAcknowledge: (itemId: string) => Promise; + onUnacknowledge: (itemId: string) => Promise; + onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise; + onUnsuppress: (itemId: string) => Promise; }) { const detail = () => props.detail; const item = () => detail()?.item; @@ -548,6 +593,16 @@ function AttentionDetail(props: {

+ +

{loaded().item.subjectResourceName} @@ -739,6 +794,185 @@ function AttentionDetail(props: { ); } +const SUPPRESSION_DURATIONS = [ + { value: 60 * 60 * 1000, label: '1 hour' }, + { value: 24 * 60 * 60 * 1000, label: '24 hours' }, + { value: 7 * 24 * 60 * 60 * 1000, label: '7 days' }, +] as const; + +function AttentionLifecycleControls(props: { + detail: AttentionItemDetail; + busy: boolean; + error: string; + onAcknowledge: (itemId: string) => Promise; + onUnacknowledge: (itemId: string) => Promise; + onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise; + onUnsuppress: (itemId: string) => Promise; +}) { + const [showSuppression, setShowSuppression] = createSignal(false); + const [reason, setReason] = createSignal(''); + const [durationMs, setDurationMs] = createSignal(SUPPRESSION_DURATIONS[1].value); + const state = () => props.detail.item.state; + const canAcknowledge = () => ['open', 'stale', 'unknown', 'resolving'].includes(state()); + const canSuppress = () => + ['open', 'acknowledged', 'stale', 'unknown', 'resolving'].includes(state()); + const submitSuppression = async (event: SubmitEvent) => { + event.preventDefault(); + const value = reason().trim(); + if (!value) return; + await props.onSuppress( + props.detail.item.id, + value, + new Date(Date.now() + durationMs()).toISOString(), + ); + setShowSuppression(false); + setReason(''); + }; + + return ( + +

+ + {(acknowledgement) => ( +

+ {getAlertResourceIncidentAcknowledgedByLabel(acknowledgement().by)}{' '} + {formatRelativeTime(acknowledgement().at, { compact: true })}. +

+ )} +
+ + {(suppression) => ( +
+

+ Suppressed by {suppression().by}: {suppression().reason} +

+ + {(expiresAt) => ( +

+ Returns to active attention {formatRelativeTime(expiresAt(), { compact: true })} + . +

+ )} +
+
+ )} +
+
+ + + + + + + + + + + + +
+ +
+
+ +