diff --git a/.secrets.baseline b/.secrets.baseline
index 533b3fc4..63351755 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -143,13 +143,22 @@
"line_number": 53
}
],
+ "api/openapi.yaml": [
+ {
+ "type": "Secret Keyword",
+ "filename": "api/openapi.yaml",
+ "hashed_secret": "6b1fe243c0b63c8e43d2545520492f2f5bc19869",
+ "is_verified": false,
+ "line_number": 127
+ }
+ ],
"docs/guides/API_GUIDE.md": [
{
"type": "Secret Keyword",
"filename": "docs/guides/API_GUIDE.md",
"hashed_secret": "b48cf0140bea12734db05ebcdb012f1d265bed84",
"is_verified": false,
- "line_number": 63
+ "line_number": 65
}
],
"docs/guides/DATABASE_MIGRATIONS.md": [
@@ -158,7 +167,7 @@
"filename": "docs/guides/DATABASE_MIGRATIONS.md",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
- "line_number": 217
+ "line_number": 223
}
],
"docs/guides/ENVIRONMENT_REFERENCE.md": [
@@ -167,7 +176,7 @@
"filename": "docs/guides/ENVIRONMENT_REFERENCE.md",
"hashed_secret": "7205a0abf00d1daec13c63ece029057c974795a9",
"is_verified": false,
- "line_number": 111
+ "line_number": 116
}
],
"docs/guides/INSTALLATION.md": [
@@ -176,21 +185,21 @@
"filename": "docs/guides/INSTALLATION.md",
"hashed_secret": "c99a970222c9f5d73283b8be8021086dd666620b",
"is_verified": false,
- "line_number": 82
+ "line_number": 84
},
{
"type": "Basic Auth Credentials",
"filename": "docs/guides/INSTALLATION.md",
"hashed_secret": "c99a970222c9f5d73283b8be8021086dd666620b",
"is_verified": false,
- "line_number": 148
+ "line_number": 150
},
{
"type": "Basic Auth Credentials",
"filename": "docs/guides/INSTALLATION.md",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
- "line_number": 381
+ "line_number": 383
}
],
"docs/guides/PRODUCTION_DEPLOYMENT.md": [
@@ -199,7 +208,7 @@
"filename": "docs/guides/PRODUCTION_DEPLOYMENT.md",
"hashed_secret": "1a1d18a6cd33ec395692c25b8e26d90b4de8b5b4",
"is_verified": false,
- "line_number": 120
+ "line_number": 121
}
],
"docs/guides/SECRET_ROTATION.md": [
@@ -415,7 +424,7 @@
"filename": "internal/notification/store.go",
"hashed_secret": "98292288b8e2ce2aec8675215186a0a33a0c8f7a",
"is_verified": false,
- "line_number": 258
+ "line_number": 264
}
],
"internal/notification/store_test.go": [
@@ -460,14 +469,14 @@
"filename": "internal/server/api/server.gen.go",
"hashed_secret": "9fd0aaae1a3d0bc789d081307161ea9a821f9dee",
"is_verified": false,
- "line_number": 3761
+ "line_number": 3999
},
{
"type": "Secret Keyword",
"filename": "internal/server/api/server.gen.go",
"hashed_secret": "eca525ee60b3564d9633eb140726685271d52341",
"is_verified": false,
- "line_number": 3890
+ "line_number": 4137
}
],
"internal/server/api_scans_test.go": [
@@ -501,7 +510,7 @@
"filename": "internal/server/notifications_handlers.go",
"hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c",
"is_verified": false,
- "line_number": 242
+ "line_number": 292
}
],
"internal/ssh/sudo.go": [
@@ -613,7 +622,7 @@
"filename": "specs/frontend/settings.spec.yaml",
"hashed_secret": "119c2d04c23cb8e69e813d2185e42c4e105b0d30",
"is_verified": false,
- "line_number": 263
+ "line_number": 278
}
],
"specs/system/config.spec.yaml": [
@@ -631,7 +640,7 @@
"filename": "specs/system/os-intelligence.spec.yaml",
"hashed_secret": "d2feddab9f573c2d8c1f4c0f92ca8990ebe60b50",
"is_verified": false,
- "line_number": 178
+ "line_number": 186
}
],
"specs/system/ssh-connectivity.spec.yaml": [
@@ -665,5 +674,5 @@
}
]
},
- "generated_at": "2026-07-07T03:30:20Z"
+ "generated_at": "2026-07-28T01:05:36Z"
}
diff --git a/api/openapi.yaml b/api/openapi.yaml
index e78b4f7a..1d8ffe11 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -2060,7 +2060,7 @@ paths:
required: false
schema:
type: string
- enum: [pending_approval, approved, rejected, dry_run_complete, executing, executed, rolled_back, failed]
+ enum: [pending_approval, approved, rejected, dry_run_complete, executing, executed, rolled_back, failed, staged, reverted, not_applied, partially_applied]
- name: host_id
in: query
required: false
@@ -5744,7 +5744,7 @@ components:
rule_id: {type: string}
status:
type: string
- enum: [pending_approval, approved, rejected, dry_run_complete, executing, executed, rolled_back, failed]
+ enum: [pending_approval, approved, rejected, dry_run_complete, executing, executed, rolled_back, failed, staged, reverted, not_applied, partially_applied]
requested_by: {type: string, format: uuid}
reviewed_by: {type: string, format: uuid, nullable: true}
review_note: {type: string}
diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts
index ddf0d207..33069ce6 100644
--- a/frontend/src/api/schema.d.ts
+++ b/frontend/src/api/schema.d.ts
@@ -3743,7 +3743,7 @@ export interface components {
host_name?: string;
rule_id: string;
/** @enum {string} */
- status: "pending_approval" | "approved" | "rejected" | "dry_run_complete" | "executing" | "executed" | "rolled_back" | "failed";
+ status: "pending_approval" | "approved" | "rejected" | "dry_run_complete" | "executing" | "executed" | "rolled_back" | "failed" | "staged" | "reverted" | "not_applied" | "partially_applied";
/** Format: uuid */
requested_by: string;
/** Format: uuid */
@@ -7436,7 +7436,7 @@ export interface operations {
listRemediationRequests: {
parameters: {
query?: {
- status?: "pending_approval" | "approved" | "rejected" | "dry_run_complete" | "executing" | "executed" | "rolled_back" | "failed";
+ status?: "pending_approval" | "approved" | "rejected" | "dry_run_complete" | "executing" | "executed" | "rolled_back" | "failed" | "staged" | "reverted" | "not_applied" | "partially_applied";
host_id?: string;
rule_id?: string;
limit?: number;
diff --git a/frontend/src/components/hosts/RequestRemediationModal.tsx b/frontend/src/components/hosts/RequestRemediationModal.tsx
index 93ead49a..4f25d1f9 100644
--- a/frontend/src/components/hosts/RequestRemediationModal.tsx
+++ b/frontend/src/components/hosts/RequestRemediationModal.tsx
@@ -109,8 +109,8 @@ export function RequestRemediationModal({
This files a remediation request for review. An approver decides whether the fix is
- applied. Applying the fix on the host is an OpenWatch Enterprise feature: the free tier governs the
- request and approval only.
+ applied. Applying a single rule on the host, and rolling it back, are included in
+ OpenWatch Community. Bulk and automated remediation are OpenWatch Enterprise features.
{mutation.error && (
diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx
index 5967f005..8ac3baa4 100644
--- a/frontend/src/pages/HostDetailPage.tsx
+++ b/frontend/src/pages/HostDetailPage.tsx
@@ -1233,8 +1233,33 @@ const REM_STATUS_STYLE: Record
- Fixed
+ {isStaged ? 'Staged, reboot required' : 'Fixed'}
{canRollback && (
Rolled back;
}
+ // Host untouched. Neutral, not red: the engine restored the host itself, or
+ // declined to act. Showing these as failures trains operators to ignore the
+ // colour that is supposed to mean "something needs you".
+ if (request.status === 'reverted' || request.status === 'not_applied') {
+ return (
+
+ {request.status === 'reverted' ? 'Reverted, host unchanged' : 'Not applied'}
+ {inlineNote}
+
+ );
+ }
+
if (request.status === 'failed') {
return (
{
expect(PAGE).toContain(
"useAuthStore((s) => s.hasPermission('remediation:rollback')) || isAdmin",
);
- expect(PAGE).toContain("request.status === 'executed'");
+ // Rollback eligibility is a SET, not a single status: 'executed' and
+ // 'staged' both leave a real change on the host, so both keep Roll back.
+ expect(PAGE).toContain('ROLLBACK_ELIGIBLE');
+ expect(PAGE).toContain('ROLLBACK_ELIGIBLE.has(request.status)');
+ expect(PAGE).toContain("new Set(['executed', 'staged'])");
expect(TAB_REGION).toContain('Fixed');
expect(TAB_REGION).toContain('Roll back');
expect(PAGE).toContain('canRollback');
@@ -154,4 +158,30 @@ describe('frontend-remediation-tab — source inspection', () => {
expect(EXPLAINER).not.toContain('—');
expect(UPSELL).not.toContain('—');
});
+
+ // @ac AC-08
+ test('frontend-remediation-tab/AC-08 — outcome vocabulary: colour follows required action', () => {
+ // Staged: warning, never success. The host is NOT protected until reboot,
+ // so a green "Fixed" chip would claim a protection it does not have.
+ expect(TAB_REGION).toContain('Staged, reboot required');
+ expect(PAGE).toContain("isStaged ? 'var(--ow-warn)' : 'var(--ow-ok)'");
+ expect(PAGE).toContain("staged: { fg: 'var(--ow-warn)'");
+
+ // Staged keeps Roll back: it is a real host mutation with captured
+ // pre-state, so the operator must be able to undo it.
+ expect(PAGE).toContain("new Set(['executed', 'staged'])");
+
+ // Reverted: NEUTRAL, deliberately not critical. Kensa restored the host
+ // itself; colouring that red teaches operators to ignore red.
+ expect(TAB_REGION).toContain('Reverted, host unchanged');
+ expect(PAGE).toContain("reverted: { fg: 'var(--ow-fg-2)'");
+ expect(PAGE).not.toContain("reverted: { fg: 'var(--ow-crit)'");
+
+ // Not applied is neutral too; partially applied is the one that needs a human.
+ expect(PAGE).toContain("not_applied: { fg: 'var(--ow-fg-2)'");
+ expect(PAGE).toContain("partially_applied: { fg: 'var(--ow-crit)'");
+
+ // The old lie must not come back anywhere in the tab copy.
+ expect(TAB_REGION).not.toContain('No host change was committed');
+ });
});
diff --git a/internal/db/migrations/0054_remediation_outcome_vocabulary.sql b/internal/db/migrations/0054_remediation_outcome_vocabulary.sql
new file mode 100644
index 00000000..414099c8
--- /dev/null
+++ b/internal/db/migrations/0054_remediation_outcome_vocabulary.sql
@@ -0,0 +1,117 @@
+-- Remediation outcome vocabulary (api-remediation v1.2.0, C-09 / AC-09..11).
+--
+-- WHY: remediation reported exactly two terminal outcomes, executed and failed,
+-- and "failed" conflated five situations that need five different operator
+-- responses. The sharpest case is Kensa v0.8.0's new `staged` status: on a host
+-- whose audit config is immutable (`auditctl -s` reports `enabled 2`) an
+-- audit_rule_set apply writes the reboot-deferred persist layer and terminates
+-- as staged. The host IS mutated. Before this migration OpenWatch recorded that
+-- as failed with the message "No host change was committed", journalled it as
+-- 'skipped', and then refused rollback because rollback requires 'executed'.
+-- The change was on the host, invisible, and unreversible from the UI.
+--
+-- Two enums widen here:
+-- remediation_requests.status gains staged | reverted | not_applied |
+-- partially_applied
+-- remediation_transactions.phase_result gains the same, so the journal stops
+-- collapsing every non-commit into 'skipped'
+--
+-- Semantics (see the spec for the normative text):
+-- staged persist layer written, runtime NOT converged. Host
+-- changed. Rule must NOT be flipped to pass. Rollback stays
+-- reachable.
+-- reverted Kensa auto-restored pre-state after a failed validation.
+-- Host untouched. This is the atomic model working, so it
+-- is deliberately NOT a red failure.
+-- not_applied engine refused without mutating (e.g. the v0.8.0
+-- duplicate-audit-action guard). Host untouched.
+-- partially_applied stranded non-capturable steps. Host state unknown.
+--
+-- The in-flight set for the one-open-request-per-(host,rule) partial unique
+-- index is deliberately NOT widened: every new value is terminal, so a staged
+-- or reverted request must not block a fresh request for the same host+rule.
+
+-- +goose Up
+
+-- remediation_requests.status is CHECK-constrained by 0037 to the original
+-- eight values, so the new terminal outcomes must be admitted explicitly or
+-- every staged/reverted write fails on the constraint.
+ALTER TABLE remediation_requests
+ DROP CONSTRAINT IF EXISTS remediation_requests_status_check;
+
+ALTER TABLE remediation_requests
+ ADD CONSTRAINT remediation_requests_status_check
+ CHECK (status IN (
+ -- lifecycle
+ 'pending_approval', 'approved', 'rejected', 'dry_run_complete', 'executing',
+ -- terminal, pre-0054
+ 'executed', 'rolled_back', 'failed',
+ -- terminal, new in 0054
+ 'staged', 'reverted', 'not_applied', 'partially_applied'
+ ));
+
+-- The one-open-request-per-(host,rule) partial unique index (0037) keys on the
+-- IN-FLIGHT set only. Every value added here is terminal, so the index is
+-- deliberately left alone: a staged or reverted request is history and must not
+-- block a fresh request for the same host and rule.
+
+COMMENT ON COLUMN remediation_requests.status IS
+ 'Lifecycle + terminal outcome. Lifecycle: pending_approval, approved, rejected, dry_run_complete, executing. Terminal: executed, failed, rolled_back, staged, reverted, not_applied, partially_applied. NOTE: this column conflates lifecycle and outcome; splitting them into status + outcome is accepted debt, revisit when bulk remediation lands (see BACKLOG "Option C").';
+
+-- remediation_transactions.phase_result IS CHECK-constrained (0037 line 73) to
+-- committed | rolled_back | skipped. Widen it so the journal records the real
+-- Kensa outcome instead of degrading through a default branch.
+ALTER TABLE remediation_transactions
+ DROP CONSTRAINT IF EXISTS remediation_transactions_phase_result_check;
+
+ALTER TABLE remediation_transactions
+ ADD CONSTRAINT remediation_transactions_phase_result_check
+ CHECK (phase_result IS NULL OR phase_result IN (
+ 'committed',
+ 'rolled_back',
+ 'skipped',
+ 'staged',
+ 'reverted',
+ 'not_applied',
+ 'partially_applied'
+ ));
+
+COMMENT ON COLUMN remediation_transactions.phase_result IS
+ 'True per-transaction Kensa outcome. staged = persist written, runtime not converged, host changed, rollback still possible. reverted = auto-restored after failed validation, host untouched. not_applied = engine refused, host untouched. skipped is retained for historical rows written before migration 0054.';
+
+-- +goose Down
+
+-- Fold the new request statuses back before restoring the narrower CHECK, or
+-- it fails to validate existing rows. staged folds to executed because the host
+-- WAS mutated; the rest fold to failed, which is what the pre-0054 code
+-- recorded for them.
+UPDATE remediation_requests SET status = 'executed' WHERE status = 'staged';
+UPDATE remediation_requests SET status = 'failed'
+ WHERE status IN ('reverted', 'not_applied', 'partially_applied');
+
+ALTER TABLE remediation_requests
+ DROP CONSTRAINT IF EXISTS remediation_requests_status_check;
+
+ALTER TABLE remediation_requests
+ ADD CONSTRAINT remediation_requests_status_check
+ CHECK (status IN ('pending_approval', 'approved', 'rejected',
+ 'dry_run_complete', 'executing', 'executed',
+ 'rolled_back', 'failed'));
+
+ALTER TABLE remediation_transactions
+ DROP CONSTRAINT IF EXISTS remediation_transactions_phase_result_check;
+
+-- Collapse the new values back into the pre-0054 vocabulary before restoring
+-- the narrower CHECK, or the constraint would fail to validate existing rows.
+-- 'staged' folds to 'committed' because the host was in fact mutated; the
+-- others fold to 'skipped', which is what the old code recorded for them.
+UPDATE remediation_transactions SET phase_result = 'committed' WHERE phase_result = 'staged';
+UPDATE remediation_transactions SET phase_result = 'skipped'
+ WHERE phase_result IN ('reverted', 'not_applied', 'partially_applied');
+
+ALTER TABLE remediation_transactions
+ ADD CONSTRAINT remediation_transactions_phase_result_check
+ CHECK (phase_result IS NULL OR phase_result IN ('committed', 'rolled_back', 'skipped'));
+
+COMMENT ON COLUMN remediation_transactions.phase_result IS NULL;
+COMMENT ON COLUMN remediation_requests.status IS NULL;
diff --git a/internal/kensa/remediatefunc.go b/internal/kensa/remediatefunc.go
index 81685711..ce3c95ca 100644
--- a/internal/kensa/remediatefunc.go
+++ b/internal/kensa/remediatefunc.go
@@ -50,9 +50,10 @@ type RemediationRunResult struct {
CompletedAt time.Time
}
-// RemediationTxn is one Kensa transaction outcome (committed / rolled_back /
-// partially_applied / errored), with its signed evidence captured for the
-// remediation_transactions journal.
+// RemediationTxn is one Kensa transaction outcome (committed / staged /
+// rolled_back / partially_applied / errored / recovered), with its signed
+// evidence captured for the remediation_transactions journal. Status is the raw
+// Kensa value; remediation.OutcomeOf maps it.
type RemediationTxn struct {
TxnID uuid.UUID
Status string
@@ -185,6 +186,24 @@ func findRule(rules []*kensaapi.Rule, ruleID string) *kensaapi.Rule {
}
// mapTxns copies kensa TransactionResults into the OpenWatch journal shape.
+//
+// Status is passed through verbatim. Interpreting it is remediation.OutcomeOf's
+// job, deliberately in one place, so a new Kensa terminal status is handled (or
+// loudly rejected) at a single site rather than absorbed by whichever switch
+// happens to see it first.
+//
+// NOT DETECTED HERE: the "engine refused without mutating the host" case, e.g.
+// Kensa v0.8.0's duplicate-audit-action guard declining to write a second
+// drop-in for an already-audited action. Kensa surfaces that as StepResult
+// Success=false plus a human-readable Detail, with no distinguishable
+// TransactionStatus, so telling it apart from a genuine failure would mean
+// pattern-matching step text. That is not a dependency worth taking on a path
+// that mutates hosts as root: a text change upstream would silently reclassify
+// real failures. Until Kensa exposes an explicit signal, a refusal maps by its
+// status like anything else, which lands on "reverted" (host untouched). That
+// is truthful, just less specific than it could be. Tracked as a Kensa request;
+// remediation.StatusNotApplied and the DB enum already accept the value so
+// wiring it later is a one-line change here.
func mapTxns(in []kensaapi.TransactionResult) []RemediationTxn {
out := make([]RemediationTxn, 0, len(in))
for _, t := range in {
diff --git a/internal/remediation/execution.go b/internal/remediation/execution.go
index 08ce4f72..14f05e49 100644
--- a/internal/remediation/execution.go
+++ b/internal/remediation/execution.go
@@ -5,7 +5,7 @@
// applied (or rolled back) the rule on the host — this package still NEVER
// contacts a host itself; the worker owns the *kensa.Executor.
//
-// Spec: api-remediation v1.1.0.
+// Spec: api-remediation v1.2.0.
package remediation
import (
@@ -13,6 +13,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "log/slog"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -26,10 +27,12 @@ func (s *Service) MarkExecuting(ctx context.Context, id uuid.UUID) (Request, err
return s.transition(ctx, id, StatusApproved, StatusExecuting)
}
-// MarkRolledBack transitions an 'executed' request to 'rolled_back'. Returns
-// ErrWrongState when the request is not 'executed'.
+// MarkRolledBack transitions a rollback-eligible request to 'rolled_back'.
+// Eligible means 'executed' OR 'staged' (Status.RollbackEligible): a staged
+// change is a real host mutation with captured pre-state, so it must be
+// reversible. Returns ErrWrongState otherwise.
func (s *Service) MarkRolledBack(ctx context.Context, id uuid.UUID) (Request, error) {
- return s.transition(ctx, id, StatusExecuted, StatusRolledBack)
+ return s.transitionFrom(ctx, id, Status.RollbackEligible, StatusRolledBack)
}
// RevertToApproved transitions an 'executing' request back to 'approved'. The
@@ -62,6 +65,14 @@ func (s *Service) HostHasExecuting(ctx context.Context, hostID uuid.UUID) (bool,
// Unlike review() it does not touch reviewed_by/reviewed_at — execution
// transitions are system-driven, not a human review.
func (s *Service) transition(ctx context.Context, id uuid.UUID, fromState, toState Status) (Request, error) {
+ return s.transitionFrom(ctx, id, func(cur Status) bool { return cur == fromState }, toState)
+}
+
+// transitionFrom is transition() over a SET of acceptable source states,
+// expressed as a predicate. Rollback needs it because two different statuses
+// are rollback-eligible ('executed' and 'staged'), and hard-coding a single
+// source state is what made a staged host change unreversible.
+func (s *Service) transitionFrom(ctx context.Context, id uuid.UUID, eligible func(Status) bool, toState Status) (Request, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return Request{}, fmt.Errorf("remediation: transition begin: %w", err)
@@ -78,7 +89,7 @@ func (s *Service) transition(ctx context.Context, id uuid.UUID, fromState, toSta
if err != nil {
return Request{}, fmt.Errorf("remediation: transition lock: %w", err)
}
- if Status(status) != fromState {
+ if !eligible(Status(status)) {
return Request{}, ErrWrongState
}
@@ -107,29 +118,7 @@ func (s *Service) transition(ctx context.Context, id uuid.UUID, fromState, toSta
// finalStatus is computed from the transactions; the caller does not pass it.
// Returns the updated Request.
func (s *Service) RecordExecution(ctx context.Context, id uuid.UUID, ruleID string, txns []ExecTxn) (Request, error) {
- final := StatusFailed
- anyCommitted := false
- anyErrored := false
- failReason := ""
- for _, t := range txns {
- if t.Committed() {
- anyCommitted = true
- }
- if t.Status == "errored" || t.Err != "" {
- anyErrored = true
- if failReason == "" && t.Err != "" {
- failReason = t.Err
- }
- }
- }
- if anyCommitted && !anyErrored {
- final = StatusExecuted
- }
- // A failed execute with no per-transaction error (e.g. the host was
- // unreachable, so the journal is empty) still gets a usable reason.
- if final == StatusFailed && failReason == "" {
- failReason = "Remediation did not complete. No host change was committed."
- }
+ final, failReason := rollUpOutcome(ctx, txns)
tx, err := s.pool.Begin(ctx)
if err != nil {
@@ -163,7 +152,7 @@ func (s *Service) RecordExecution(ctx context.Context, id uuid.UUID, ruleID stri
if existing == 0 {
for i, t := range txns {
- phase := phaseResult(t.Status)
+ phase := phaseResult(t)
ev := t.Evidence
if len(ev) == 0 {
ev = []byte("{}")
@@ -199,20 +188,27 @@ func (s *Service) RecordExecution(ctx context.Context, id uuid.UUID, ruleID stri
return rq, nil
}
-// FirstCommittedTxn returns the first committed transaction id for a request,
-// or false when none committed. The worker uses it to find the rollback
-// handle when a rollback is requested.
-func (s *Service) FirstCommittedTxn(ctx context.Context, id uuid.UUID) (uuid.UUID, bool, error) {
+// FirstReversibleTxn returns the first REVERSIBLE transaction id for a request,
+// or false when none exists. The worker uses it to find the rollback handle.
+//
+// 'staged' counts as reversible alongside 'committed' (api-remediation AC-09).
+// A staged transaction wrote a real change to the host and Kensa captured its
+// pre-state, so `kensa rollback` reverses it byte-perfect. Matching only
+// 'committed' here was half of why a staged change could not be undone through
+// the UI; the other half was the executed-only precondition in the worker.
+func (s *Service) FirstReversibleTxn(ctx context.Context, id uuid.UUID) (uuid.UUID, bool, error) {
var raw string
err := s.pool.QueryRow(ctx, `
SELECT kensa_txn_id FROM remediation_transactions
- WHERE request_id = $1 AND phase_result = 'committed' AND kensa_txn_id IS NOT NULL
+ WHERE request_id = $1
+ AND phase_result IN ('committed', 'staged')
+ AND kensa_txn_id IS NOT NULL
ORDER BY ordinal ASC, created_at ASC LIMIT 1`, id).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, false, nil
}
if err != nil {
- return uuid.Nil, false, fmt.Errorf("remediation: first committed txn: %w", err)
+ return uuid.Nil, false, fmt.Errorf("remediation: first reversible txn: %w", err)
}
txnID, perr := uuid.Parse(raw)
if perr != nil {
@@ -257,17 +253,101 @@ func (s *Service) EmitRolledBack(ctx context.Context, rq Request, actor uuid.UUI
s.emitAudit(ctx, auditRemediationRolledBack, rq, actor, detail)
}
-// phaseResult maps a kensa transaction status to the remediation_transactions
-// phase_result CHECK enum (committed | rolled_back | skipped). Anything that is
-// not a clean commit or rollback is recorded as 'skipped' (the journal's catch
-// -all for partially_applied / errored), with the raw status preserved in the
-// evidence envelope.
-func phaseResult(status string) string {
- switch status {
- case "committed":
+// rollUpOutcome reduces a request's transactions to one terminal status plus an
+// operator-facing reason.
+//
+// Cardinality note: Kensa's Remediate runs a transaction per FAILING rule and
+// OpenWatch passes exactly one rule, so this is 1 transaction in practice today
+// and the roll-up is lossless. The mixed case is defined anyway rather than left
+// emergent, because bulk remediation (Enterprise) will exercise it: when
+// transactions disagree the honest answer is partially_applied, "go look at this
+// host", not an arbitrary precedence winner.
+//
+// api-remediation AC-09, AC-10, AC-11.
+func rollUpOutcome(ctx context.Context, txns []ExecTxn) (Status, string) {
+ if len(txns) == 0 {
+ // No journal at all, e.g. the host was unreachable so nothing ran.
+ // Nothing was attempted, so the old message is accurate here.
+ return StatusFailed, "Remediation did not complete. No host change was committed."
+ }
+
+ seen := make(map[Status]bool, len(txns))
+ reason := ""
+ for _, t := range txns {
+ st, known := OutcomeOf(t)
+ if !known {
+ // AC-11: never absorb an unknown terminal state silently. This is
+ // the guard that Kensa v0.8.0's `staged` defeated.
+ slog.WarnContext(ctx, "remediation: unrecognised kensa transaction status; failing closed",
+ slog.String("kensa_status", t.Status),
+ slog.String("txn_id", t.TxnID.String()),
+ slog.String("action", "add the status to remediation.OutcomeOf and the phase_result CHECK"))
+ }
+ seen[st] = true
+ if reason == "" && t.Err != "" {
+ reason = t.Err
+ }
+ }
+
+ final := StatusFailed
+ switch {
+ case len(seen) == 1:
+ for st := range seen {
+ final = st
+ }
+ case seen[StatusFailed] || seen[StatusPartiallyApplied]:
+ // Something broke or stranded amid other work: host state is unknown.
+ final = StatusPartiallyApplied
+ default:
+ // Several non-failing outcomes disagree (e.g. one committed, one
+ // staged). The host is partly converged and partly pending a reboot.
+ final = StatusPartiallyApplied
+ }
+
+ if reason == "" {
+ reason = outcomeReason(final)
+ }
+ return final, reason
+}
+
+// outcomeReason is the operator-facing sentence for a terminal status when the
+// engine gave no error string of its own. Each one names the host's actual
+// state, because the single worst thing the old code did was tell an operator
+// nothing had changed on a host that had just been modified.
+func outcomeReason(s Status) string {
+ switch s {
+ case StatusExecuted:
+ return "Fix applied and validated on the host."
+ case StatusStaged:
+ return "Change written and staged. It takes effect after the host reboots; a re-scan reports this rule as still failing until then. You can roll it back."
+ case StatusReverted:
+ return "Validation failed, so the host was restored to its previous state. Nothing was left changed."
+ case StatusNotApplied:
+ return "No change made. The engine declined to apply this rule."
+ case StatusPartiallyApplied:
+ return "Remediation did not complete cleanly and some steps cannot be reversed automatically. Inspect this host."
+ default:
+ return "Remediation did not complete. No host change was committed."
+ }
+}
+
+// phaseResult maps a kensa transaction to the remediation_transactions
+// phase_result enum, widened by migration 0054 so the journal records the true
+// outcome. It no longer collapses everything unknown into 'skipped'; 'skipped'
+// is retained only for historical rows written before 0054.
+func phaseResult(t ExecTxn) string {
+ st, _ := OutcomeOf(t)
+ switch st {
+ case StatusExecuted:
return "committed"
- case "rolled_back":
- return "rolled_back"
+ case StatusStaged:
+ return "staged"
+ case StatusReverted:
+ return "reverted"
+ case StatusNotApplied:
+ return "not_applied"
+ case StatusPartiallyApplied:
+ return "partially_applied"
default:
return "skipped"
}
diff --git a/internal/remediation/outcome_test.go b/internal/remediation/outcome_test.go
new file mode 100644
index 00000000..3b9b15a0
--- /dev/null
+++ b/internal/remediation/outcome_test.go
@@ -0,0 +1,169 @@
+// @spec api-remediation
+//
+// Outcome-vocabulary coverage. These are pure mapping tests (no DB), so unlike
+// service_test.go they are not DSN-gated.
+//
+// AC-09 TestOutcome_StagedIsDistinctAndReversible
+// AC-10 TestOutcome_RemainingKensaStatusesMapOneToOne
+// AC-11 TestOutcome_UnknownStatusFailsClosed
+package remediation
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+// @ac AC-09
+// AC-09: staged is its own terminal outcome, it counts as a host mutation, and
+// it stays rollback-eligible. The bug this pins: Kensa v0.8.0 introduced
+// `staged` (persist layer written, runtime not converged, HOST CHANGED) and
+// OpenWatch mapped it through a default branch to "failed", told the operator
+// "No host change was committed", journalled it as 'skipped', and then refused
+// rollback because rollback required 'executed'.
+func TestOutcome_StagedIsDistinctAndReversible(t *testing.T) {
+ t.Run("api-remediation/AC-09", func(t *testing.T) {
+ staged := ExecTxn{TxnID: uuid.New(), Status: "staged"}
+
+ got, known := OutcomeOf(staged)
+ if !known {
+ t.Fatal("staged must be a recognised Kensa status")
+ }
+ if got != StatusStaged {
+ t.Fatalf("staged mapped to %q, want %q", got, StatusStaged)
+ }
+ if got == StatusFailed {
+ t.Error("staged must never map to failed: the host WAS changed")
+ }
+
+ // The host was mutated, so the operator has something to consider.
+ if !got.HostMutated() {
+ t.Error("staged must report HostMutated: the persist layer was written")
+ }
+
+ // Rollback must stay reachable; Kensa captured pre-state and reverses
+ // a staged drop-in byte-perfect.
+ if !got.RollbackEligible() {
+ t.Error("staged must be rollback-eligible or the change is stranded on the host")
+ }
+
+ // The journal must record the real outcome, not the old 'skipped'
+ // catch-all, or the evidence trail disagrees with the request.
+ if p := phaseResult(staged); p != "staged" {
+ t.Errorf("phase_result for staged = %q, want %q", p, "staged")
+ }
+
+ // The operator-facing sentence must not claim nothing happened.
+ reason := outcomeReason(StatusStaged)
+ if reason == outcomeReason(StatusFailed) {
+ t.Error("staged must not reuse the failure message")
+ }
+ for _, forbidden := range []string{"No host change was committed"} {
+ if strings.Contains(reason, forbidden) {
+ t.Errorf("staged message must not claim no change was made: %q", reason)
+ }
+ }
+ })
+}
+
+// @ac AC-10
+// AC-10: the remaining Kensa statuses each map to their own request status and
+// journal value, so "failed" stops meaning five different things. In particular
+// rolled_back (Kensa auto-restored pre-state, host untouched) is NOT a failure
+// of the same kind as errored, and it must not be reported as one.
+func TestOutcome_RemainingKensaStatusesMapOneToOne(t *testing.T) {
+ t.Run("api-remediation/AC-10", func(t *testing.T) {
+ cases := []struct {
+ kensa string
+ want Status
+ wantPhase string
+ mutated bool
+ }{
+ {"committed", StatusExecuted, "committed", true},
+ {"rolled_back", StatusReverted, "reverted", false},
+ {"recovered", StatusReverted, "reverted", false},
+ {"partially_applied", StatusPartiallyApplied, "partially_applied", true},
+ {"errored", StatusFailed, "skipped", false},
+ }
+ for _, c := range cases {
+ txn := ExecTxn{TxnID: uuid.New(), Status: c.kensa}
+ got, known := OutcomeOf(txn)
+ if !known {
+ t.Errorf("%s: must be a recognised Kensa status", c.kensa)
+ }
+ if got != c.want {
+ t.Errorf("%s mapped to %q, want %q", c.kensa, got, c.want)
+ }
+ if got.HostMutated() != c.mutated {
+ t.Errorf("%s HostMutated = %v, want %v", c.kensa, got.HostMutated(), c.mutated)
+ }
+ if p := phaseResult(txn); p != c.wantPhase {
+ t.Errorf("%s phase_result = %q, want %q", c.kensa, p, c.wantPhase)
+ }
+ }
+
+ // The distinction that matters most for the operator: an auto-revert
+ // (safety net worked, host untouched) must not collapse into the same
+ // status as an errored run.
+ reverted, _ := OutcomeOf(ExecTxn{Status: "rolled_back"})
+ errored, _ := OutcomeOf(ExecTxn{Status: "errored"})
+ if reverted == errored {
+ t.Error("rolled_back and errored must not share a status: one left the host clean, the other did not")
+ }
+
+ // A refusal (engine declined without touching the host) is its own
+ // outcome once Kensa gives us a signal for it.
+ refused, known := OutcomeOf(ExecTxn{Status: "rolled_back", Refused: true})
+ if !known || refused != StatusNotApplied {
+ t.Errorf("a refused transaction must map to not_applied, got %q", refused)
+ }
+ if refused.HostMutated() {
+ t.Error("not_applied must not report the host as mutated")
+ }
+ })
+}
+
+// @ac AC-11
+// AC-11 (NEGATIVE PATH): an unrecognised Kensa status must not be silently
+// absorbed. This is the guard whose absence caused the staged defect: a
+// `default:` branch quietly swallowed a brand-new terminal state. The mapper
+// must report the status as unknown so the caller logs it, and must fail
+// CLOSED, never to a success-shaped outcome.
+func TestOutcome_UnknownStatusFailsClosed(t *testing.T) {
+ t.Run("api-remediation/AC-11", func(t *testing.T) {
+ // Stand-in for whatever Kensa adds next.
+ future := ExecTxn{TxnID: uuid.New(), Status: "quarantined_pending_attestation"}
+
+ got, known := OutcomeOf(future)
+ if known {
+ t.Fatal("an unrecognised Kensa status must be reported as unknown, not silently mapped")
+ }
+ if got != StatusFailed {
+ t.Errorf("unknown status must fail closed to %q, got %q", StatusFailed, got)
+ }
+
+ // Fail-closed means: never a success-shaped outcome, and never one that
+ // claims the host is fine when we do not know that.
+ for _, forbidden := range []Status{StatusExecuted, StatusStaged, StatusReverted, StatusNotApplied} {
+ if got == forbidden {
+ t.Errorf("unknown status must not map to %q", forbidden)
+ }
+ }
+ if got.HostMutated() {
+ t.Error("an unknown outcome must not assert the host was mutated; we do not know")
+ }
+
+ // Every status the CURRENT Kensa api package can produce must be
+ // recognised. If this fails after a Kensa bump, that is the bump
+ // telling you to update OutcomeOf before shipping.
+ for _, s := range []string{
+ kensaCommitted, kensaRolledBack, kensaPartiallyApplied,
+ kensaErrored, kensaRecovered, kensaStaged,
+ } {
+ if _, ok := OutcomeOf(ExecTxn{Status: s}); !ok {
+ t.Errorf("known Kensa status %q is not mapped by OutcomeOf", s)
+ }
+ }
+ })
+}
diff --git a/internal/remediation/types.go b/internal/remediation/types.go
index df552cc7..984212cf 100644
--- a/internal/remediation/types.go
+++ b/internal/remediation/types.go
@@ -38,8 +38,61 @@ const (
StatusExecuted Status = "executed"
StatusRolledBack Status = "rolled_back"
StatusFailed Status = "failed"
+
+ // The terminal outcomes below distinguish states that need DIFFERENT
+ // operator actions. Before they existed, "failed" meant any of five
+ // things and carried the message "No host change was committed", which
+ // was false for one of them. See api-remediation C-09.
+
+ // StatusStaged: the persist layer was written but the runtime did NOT
+ // converge, so the change takes effect at the next reboot. THE HOST IS
+ // MUTATED. Produced by Kensa when a mechanism cannot change live kernel
+ // state under the host's current configuration, today audit_rule_set on
+ // a host with an immutable audit config (`auditctl -s` = `enabled 2`).
+ // A staged rule must NOT be flipped to pass in host_rule_state (a
+ // re-scan correctly still reports it failing), and rollback must remain
+ // reachable.
+ StatusStaged Status = "staged"
+
+ // StatusReverted: Kensa's validation failed after apply and the engine
+ // auto-restored the captured pre-state. THE HOST IS UNTOUCHED. This is
+ // the atomic model working as designed, so it is deliberately not
+ // presented as the same kind of failure as an errored run.
+ StatusReverted Status = "reverted"
+
+ // StatusNotApplied: the engine refused to act without mutating the host,
+ // e.g. Kensa v0.8.0's duplicate-audit-action guard declining to write a
+ // second drop-in for an action another rules.d file already audits. THE
+ // HOST IS UNTOUCHED and is arguably already compliant. Carries the
+ // refusal detail so an operator can tell it from a defect.
+ StatusNotApplied Status = "not_applied"
+
+ // StatusPartiallyApplied: non-capturable steps succeeded before a later
+ // failure, so they are stranded and rollback cannot reverse them. HOST
+ // STATE IS UNKNOWN and needs inspection.
+ StatusPartiallyApplied Status = "partially_applied"
)
+// HostMutated reports whether reaching this terminal status means the host was
+// changed. It is the predicate that decides whether an operator has cleanup to
+// consider, and it is why "reverted" must not render like "failed".
+func (s Status) HostMutated() bool {
+ switch s {
+ case StatusExecuted, StatusStaged, StatusPartiallyApplied:
+ return true
+ default:
+ return false
+ }
+}
+
+// RollbackEligible reports whether a request in this status can be rolled back.
+// Staged is included deliberately: Kensa captured pre-state and `kensa rollback`
+// reverses a staged drop-in byte-perfect, so refusing here would strand a real
+// host change with no route back. See api-remediation AC-09.
+func (s Status) RollbackEligible() bool {
+ return s == StatusExecuted || s == StatusStaged
+}
+
// ProjectedLift is the estimated per-framework compliance-score delta
// (percentage points) if the rule flips to pass. A nil field means that
// framework's data was unavailable for the host (best-effort projection).
@@ -90,10 +143,15 @@ type ExecTxn struct {
// TxnID is the Kensa transaction id (the rollback handle). Stored as
// remediation_transactions.kensa_txn_id.
TxnID uuid.UUID
- // Status is the per-transaction outcome: committed | rolled_back |
- // partially_applied | errored. Mapped to the phase_result CHECK enum
- // (committed | rolled_back | skipped) for the journal row.
+ // Status is the raw per-transaction Kensa outcome: committed |
+ // rolled_back | partially_applied | errored | staged. Passed through
+ // verbatim from api.TransactionStatus and mapped by OutcomeOf.
Status string
+ // Refused is true when the engine declined to act WITHOUT mutating the
+ // host (Kensa reported Success=false with a detail rather than an error),
+ // e.g. the v0.8.0 duplicate-audit-action guard. Kensa has no distinct
+ // TransactionStatus for this, so it is carried alongside.
+ Refused bool
// Evidence is the signed evidence envelope (or a summary), stored in the
// remediation_transactions.evidence JSONB column.
Evidence []byte
@@ -106,6 +164,57 @@ type ExecTxn struct {
// rule's check passed on the host.
func (t ExecTxn) Committed() bool { return t.Status == "committed" }
+// Staged reports whether the transaction wrote a reboot-deferred change. The
+// host IS mutated but the runtime has not converged.
+func (t ExecTxn) Staged() bool { return t.Status == "staged" }
+
+// Kensa transaction statuses, named so the mapping below reads against the
+// upstream vocabulary rather than bare strings. Mirrors api.TransactionStatus;
+// duplicated as untyped strings because ExecTxn deliberately does not import
+// the kensa package (it would create an import cycle through credential).
+const (
+ kensaCommitted = "committed"
+ kensaRolledBack = "rolled_back"
+ kensaPartiallyApplied = "partially_applied"
+ kensaErrored = "errored"
+ kensaRecovered = "recovered"
+ kensaStaged = "staged"
+)
+
+// OutcomeOf maps one Kensa transaction to the request status it implies, and
+// reports whether the status was recognised.
+//
+// The bool is the point of this function. api-remediation AC-11 requires that
+// an unrecognised Kensa status is never silently absorbed: the previous code
+// collapsed everything unknown through a `default:` branch, which is exactly
+// how Kensa v0.8.0's `staged` came to be reported as "failed, no host change
+// was committed" on a host that had just been modified. A false return means
+// the caller MUST log the offending value and fail closed.
+func OutcomeOf(t ExecTxn) (Status, bool) {
+ // A refusal is not a Kensa status; check it before the status switch.
+ // The engine declined without touching the host.
+ if t.Refused {
+ return StatusNotApplied, true
+ }
+ switch t.Status {
+ case kensaCommitted:
+ return StatusExecuted, true
+ case kensaStaged:
+ return StatusStaged, true
+ case kensaRolledBack, kensaRecovered:
+ // Kensa restored pre-state itself. The host is untouched, so this is
+ // NOT the same event as an errored run and must not read like one.
+ return StatusReverted, true
+ case kensaPartiallyApplied:
+ return StatusPartiallyApplied, true
+ case kensaErrored:
+ return StatusFailed, true
+ default:
+ // Fail closed: unknown means unknown, never a success-shaped outcome.
+ return StatusFailed, false
+ }
+}
+
var (
// ErrNotFound is returned when a remediation request id does not exist.
ErrNotFound = errors.New("remediation: not found")
diff --git a/internal/server/api/server.gen.go b/internal/server/api/server.gen.go
index 3cea7d1c..08432cb1 100644
--- a/internal/server/api/server.gen.go
+++ b/internal/server/api/server.gen.go
@@ -1021,14 +1021,18 @@ func (e NotificationFeedItemSeverity) Valid() bool {
// Defines values for RemediationRequestStatus.
const (
- RemediationRequestStatusApproved RemediationRequestStatus = "approved"
- RemediationRequestStatusDryRunComplete RemediationRequestStatus = "dry_run_complete"
- RemediationRequestStatusExecuted RemediationRequestStatus = "executed"
- RemediationRequestStatusExecuting RemediationRequestStatus = "executing"
- RemediationRequestStatusFailed RemediationRequestStatus = "failed"
- RemediationRequestStatusPendingApproval RemediationRequestStatus = "pending_approval"
- RemediationRequestStatusRejected RemediationRequestStatus = "rejected"
- RemediationRequestStatusRolledBack RemediationRequestStatus = "rolled_back"
+ RemediationRequestStatusApproved RemediationRequestStatus = "approved"
+ RemediationRequestStatusDryRunComplete RemediationRequestStatus = "dry_run_complete"
+ RemediationRequestStatusExecuted RemediationRequestStatus = "executed"
+ RemediationRequestStatusExecuting RemediationRequestStatus = "executing"
+ RemediationRequestStatusFailed RemediationRequestStatus = "failed"
+ RemediationRequestStatusNotApplied RemediationRequestStatus = "not_applied"
+ RemediationRequestStatusPartiallyApplied RemediationRequestStatus = "partially_applied"
+ RemediationRequestStatusPendingApproval RemediationRequestStatus = "pending_approval"
+ RemediationRequestStatusRejected RemediationRequestStatus = "rejected"
+ RemediationRequestStatusReverted RemediationRequestStatus = "reverted"
+ RemediationRequestStatusRolledBack RemediationRequestStatus = "rolled_back"
+ RemediationRequestStatusStaged RemediationRequestStatus = "staged"
)
// Valid indicates whether the value is a known member of the RemediationRequestStatus enum.
@@ -1044,12 +1048,20 @@ func (e RemediationRequestStatus) Valid() bool {
return true
case RemediationRequestStatusFailed:
return true
+ case RemediationRequestStatusNotApplied:
+ return true
+ case RemediationRequestStatusPartiallyApplied:
+ return true
case RemediationRequestStatusPendingApproval:
return true
case RemediationRequestStatusRejected:
return true
+ case RemediationRequestStatusReverted:
+ return true
case RemediationRequestStatusRolledBack:
return true
+ case RemediationRequestStatusStaged:
+ return true
default:
return false
}
@@ -1456,14 +1468,18 @@ func (e GetIntelligenceEventsParamsSeverity) Valid() bool {
// Defines values for ListRemediationRequestsParamsStatus.
const (
- ListRemediationRequestsParamsStatusApproved ListRemediationRequestsParamsStatus = "approved"
- ListRemediationRequestsParamsStatusDryRunComplete ListRemediationRequestsParamsStatus = "dry_run_complete"
- ListRemediationRequestsParamsStatusExecuted ListRemediationRequestsParamsStatus = "executed"
- ListRemediationRequestsParamsStatusExecuting ListRemediationRequestsParamsStatus = "executing"
- ListRemediationRequestsParamsStatusFailed ListRemediationRequestsParamsStatus = "failed"
- ListRemediationRequestsParamsStatusPendingApproval ListRemediationRequestsParamsStatus = "pending_approval"
- ListRemediationRequestsParamsStatusRejected ListRemediationRequestsParamsStatus = "rejected"
- ListRemediationRequestsParamsStatusRolledBack ListRemediationRequestsParamsStatus = "rolled_back"
+ ListRemediationRequestsParamsStatusApproved ListRemediationRequestsParamsStatus = "approved"
+ ListRemediationRequestsParamsStatusDryRunComplete ListRemediationRequestsParamsStatus = "dry_run_complete"
+ ListRemediationRequestsParamsStatusExecuted ListRemediationRequestsParamsStatus = "executed"
+ ListRemediationRequestsParamsStatusExecuting ListRemediationRequestsParamsStatus = "executing"
+ ListRemediationRequestsParamsStatusFailed ListRemediationRequestsParamsStatus = "failed"
+ ListRemediationRequestsParamsStatusNotApplied ListRemediationRequestsParamsStatus = "not_applied"
+ ListRemediationRequestsParamsStatusPartiallyApplied ListRemediationRequestsParamsStatus = "partially_applied"
+ ListRemediationRequestsParamsStatusPendingApproval ListRemediationRequestsParamsStatus = "pending_approval"
+ ListRemediationRequestsParamsStatusRejected ListRemediationRequestsParamsStatus = "rejected"
+ ListRemediationRequestsParamsStatusReverted ListRemediationRequestsParamsStatus = "reverted"
+ ListRemediationRequestsParamsStatusRolledBack ListRemediationRequestsParamsStatus = "rolled_back"
+ ListRemediationRequestsParamsStatusStaged ListRemediationRequestsParamsStatus = "staged"
)
// Valid indicates whether the value is a known member of the ListRemediationRequestsParamsStatus enum.
@@ -1479,12 +1495,20 @@ func (e ListRemediationRequestsParamsStatus) Valid() bool {
return true
case ListRemediationRequestsParamsStatusFailed:
return true
+ case ListRemediationRequestsParamsStatusNotApplied:
+ return true
+ case ListRemediationRequestsParamsStatusPartiallyApplied:
+ return true
case ListRemediationRequestsParamsStatusPendingApproval:
return true
case ListRemediationRequestsParamsStatusRejected:
return true
+ case ListRemediationRequestsParamsStatusReverted:
+ return true
case ListRemediationRequestsParamsStatusRolledBack:
return true
+ case ListRemediationRequestsParamsStatusStaged:
+ return true
default:
return false
}
diff --git a/internal/worker/remediation_worker.go b/internal/worker/remediation_worker.go
index 9b6c7af8..adf2c14d 100644
--- a/internal/worker/remediation_worker.go
+++ b/internal/worker/remediation_worker.go
@@ -228,7 +228,7 @@ func (w *RemediationWorker) processExecute(ctx context.Context, j *queue.Job, p
}
txns := mapExecTxns(result.Transactions)
- committed := anyCommitted(txns)
+ committed := anyConverged(txns)
w.finishExecute(ctx, j, rq, p, txns, committed)
}
@@ -288,7 +288,13 @@ func (w *RemediationWorker) finishExecute(ctx context.Context, j *queue.Job,
RuleFlipped: committed,
CompletedAt: w.clock().UTC(),
})
- if final.Status == remediation.StatusFailed {
+ // Alert only on outcomes that actually need someone. 'reverted' is the
+ // atomic model working (validation failed, host restored, nothing left
+ // changed) and 'not_applied' is the engine declining without touching the
+ // host; paging on either trains operators to ignore the alert. 'staged' is
+ // not an alert either: it succeeded, it just needs a reboot.
+ // api-remediation AC-10.
+ if final.Status == remediation.StatusFailed || final.Status == remediation.StatusPartiallyApplied {
w.notifyRemediationFailed(ctx, final.HostID, final.RuleID, RemediationActionExecute)
}
@@ -310,8 +316,13 @@ func (w *RemediationWorker) processRollback(ctx context.Context, j *queue.Job, p
_ = queue.Fail(ctx, w.pool, j.ID, "rollback precondition: "+err.Error())
return
}
- if rq.Status != remediation.StatusExecuted {
- _ = queue.Fail(ctx, w.pool, j.ID, "rollback precondition: request not in executed state")
+ // Rollback is reachable from any rollback-eligible status, which means
+ // 'executed' OR 'staged'. A staged change is a real host mutation with
+ // captured pre-state; refusing it here stranded the change with no route
+ // back through the UI (api-remediation AC-09).
+ if !rq.Status.RollbackEligible() {
+ _ = queue.Fail(ctx, w.pool, j.ID,
+ "rollback precondition: request is "+string(rq.Status)+", which is not rollback-eligible")
return
}
@@ -326,9 +337,9 @@ func (w *RemediationWorker) processRollback(ctx context.Context, j *queue.Job, p
// committed transaction recorded for the request.
txnID := p.TxnID
if txnID == uuid.Nil {
- resolved, ok, ferr := w.svc.FirstCommittedTxn(ctx, p.RequestID)
+ resolved, ok, ferr := w.svc.FirstReversibleTxn(ctx, p.RequestID)
if ferr != nil || !ok {
- _ = queue.Fail(ctx, w.pool, j.ID, "rollback: no committed transaction to revert")
+ _ = queue.Fail(ctx, w.pool, j.ID, "rollback: no reversible transaction to revert")
return
}
txnID = resolved
@@ -446,7 +457,16 @@ func mapExecTxns(in []kensa.RemediationTxn) []remediation.ExecTxn {
return out
}
-func anyCommitted(txns []remediation.ExecTxn) bool {
+// anyConverged reports whether any transaction left the host in the target
+// RUNTIME state, which is the only condition under which the rule may be
+// flipped to pass in host_rule_state.
+//
+// A staged transaction is deliberately NOT converged. It wrote the persist
+// layer, so the host changed, but the kernel has not loaded the change and a
+// re-scan correctly still reports the rule failing until reboot. Flipping it to
+// pass would make the compliance score claim a protection the host does not yet
+// have (api-remediation AC-09).
+func anyConverged(txns []remediation.ExecTxn) bool {
for _, t := range txns {
if t.Committed() {
return true
diff --git a/specs/api/remediation.spec.yaml b/specs/api/remediation.spec.yaml
index 7b61e9c9..c685d3e4 100644
--- a/specs/api/remediation.spec.yaml
+++ b/specs/api/remediation.spec.yaml
@@ -1,7 +1,7 @@
spec:
id: api-remediation
title: Remediation governance - request/approve lifecycle, projected lift, and queued single-rule execute/rollback (OpenWatch Core, free)
- version: "1.1.0"
+ version: "1.2.0"
status: approved
tier: 1
@@ -118,6 +118,24 @@ spec:
description: 'ProjectLift is read-only and best-effort: for a failing rule it returns per-framework projected score deltas derived from host_rule_state.framework_refs (delta ~= 100/N where N is the count of that framework''s rules on the host); when framework data is absent or unparseable it returns an empty projection and never errors or blocks the request'
type: technical
enforcement: warning
+ - id: C-09
+ description: >
+ A remediation outcome MUST distinguish states that require different
+ operator actions, and MUST NOT report a host as unchanged when it was
+ changed. Every Kensa TransactionStatus maps to exactly one request
+ status: committed->executed, staged->staged, rolled_back->reverted,
+ partially_applied->partially_applied, errored->failed; a transaction
+ that Kensa refused (Success=false with a detail, e.g. the audit-rule
+ duplicate-action guard) maps to not_applied. staged means the persist
+ layer was written but the runtime has NOT converged: the host IS
+ mutated, so the rule MUST NOT be flipped to pass in host_rule_state,
+ and rollback MUST remain reachable. reverted means Kensa auto-restored
+ pre-state after a failed validation: the host is untouched, so it MUST
+ NOT be presented as a failure of the same kind as an errored run. An
+ unrecognised TransactionStatus MUST be logged at WARN naming the
+ status and MUST NOT be silently absorbed by a default branch.
+ type: security
+ enforcement: error
acceptance_criteria:
- id: AC-01
@@ -151,3 +169,39 @@ spec:
- id: AC-08
description: 'Per-host remediation serialization: HostHasExecuting(host) reports whether a request is in the executing state on the host; RevertToApproved(id) returns an executing request to approved (and is ErrWrongState otherwise). The worker uses these plus a backoff requeue (queue.EnqueueAfter) so a second concurrent execute/rollback on a busy host requeues and retries once the host frees, instead of colliding on the per-host SSH guard and being marked failed.'
priority: high
+ - id: AC-09
+ description: >
+ Staged is a distinct terminal outcome and is never reported as a
+ failure that changed nothing. A Kensa transaction with Status=staged
+ drives the request to status=staged (not failed), journals
+ phase_result=staged, and does NOT flip the rule to pass in
+ host_rule_state because the runtime has not converged. The operator
+ message MUST NOT claim no host change was committed. Rollback is
+ reachable from staged: the rollback precondition accepts staged as
+ well as executed, and the rollback transaction handle resolves from a
+ staged journal row as well as a committed one.
+ priority: critical
+ references_constraints: [C-09]
+ - id: AC-10
+ description: >
+ The remaining Kensa outcomes map one-to-one and are individually
+ distinguishable. rolled_back (Kensa auto-restored pre-state after a
+ failed validation, host untouched) drives status=reverted, not failed.
+ partially_applied drives status=partially_applied. A transaction the
+ engine refused without mutating the host drives status=not_applied and
+ carries the refusal detail so the operator can tell it from a defect.
+ errored and host-unreachable still drive status=failed. Each status is
+ journalled with the matching phase_result rather than being collapsed
+ into skipped.
+ priority: critical
+ references_constraints: [C-09]
+ - id: AC-11
+ description: >
+ Negative path: an unrecognised Kensa TransactionStatus does not
+ degrade silently. Mapping an unknown status logs at WARN naming the
+ offending value, and the request is driven to failed (fail-closed,
+ never to a success-shaped outcome). This is the guard that the Kensa
+ v0.8.0 staged status defeated: a default branch absorbed a new
+ terminal state and reported a mutated host as unchanged.
+ priority: critical
+ references_constraints: [C-09]
diff --git a/specs/frontend/remediation-tab.spec.yaml b/specs/frontend/remediation-tab.spec.yaml
index b64f9b95..cfc80556 100644
--- a/specs/frontend/remediation-tab.spec.yaml
+++ b/specs/frontend/remediation-tab.spec.yaml
@@ -1,7 +1,7 @@
spec:
id: frontend-remediation-tab
title: Host detail Remediation tab + per-rule request/apply affordance
- version: "1.1.0"
+ version: "1.2.0"
status: approved
tier: 2
@@ -108,3 +108,18 @@ spec:
description: 'Source inspection: the OpenWatch Enterprise upsell now describes BULK and AUTOMATED remediation (RemediationUpsell, "Bulk and automated remediation (OpenWatch Enterprise)"), is DISABLED, and the single-rule "Execute on host (OpenWatch Enterprise)" upsell copy is gone. No em-dash characters appear in the RemediationTab copy'
priority: high
references_constraints: [C-03, C-04]
+ - id: AC-08
+ description: >
+ Source inspection: the outcome vocabulary is rendered so that colour and
+ wording follow what the operator must DO. Rollback eligibility is a set
+ (ROLLBACK_ELIGIBLE = new Set(['executed', 'staged'])) rather than a
+ single status, so a staged change keeps its Roll back action. A staged
+ row renders "Staged, reboot required" in the warning colour and NEVER in
+ the success colour, because the host is not protected until it reboots.
+ A reverted row renders "Reverted, host unchanged" in a NEUTRAL colour,
+ not the critical colour: Kensa restored the host itself, so presenting
+ it as a failure would train operators to distrust the safety net.
+ not_applied likewise renders neutral. partially_applied renders
+ critical, because that host needs a human.
+ priority: critical
+ references_constraints: [C-04]