drift

package
v0.8.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 9, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package drift implements OpenWatch's compliance drift detector.

Spec: specs/system/drift-detector.spec.yaml (status: approved)

Architectural choices:

  • Pure consumer of the transaction log (B.1c). The detector reads prior + current state from host_rule_state and the transactions for this scan_id. It does not write to either table.

  • No baselines table. The Python-era baselines table is explicitly dropped — the prior host_rule_state aggregate IS the baseline. Spec C-07 / AC-12 source-inspects to enforce this.

  • Percentage-point math. A compliance score drop from 80% to 70% is a 10pp delta — NOT a 12.5pp delta (which would be the percent-of-percent miscalculation). Spec C-03.

  • Classify is a pure function. Given (prior_score, current_score, thresholds), it returns one of four Kind values deterministically. No I/O, no side effects, trivially testable without a database. Spec C-01.

  • Audit on non-stable kinds only. Stable scans (delta below the minor threshold) DO NOT emit compliance.drift.detected. Operators see audit traffic only when something actually changed. Spec C-04.

  • Thresholds from policy. Defaults are major=10pp, minor=5pp, improvement=5pp. Operators override via policy.AlertThresholds. ValidateThresholds rejects any value outside (0, 100] or any configuration where major < minor.

  • Single read transaction. DetectForScan wraps the prior+current score computations in one DB transaction so a concurrent writer cannot produce a torn view. Spec C-06.

Index

Constants

This section is empty.

Variables

AllKinds is the closed set, in registration order. Used by AC-13's reflection-style check.

View Source
var ErrInvalidThresholds = errors.New("drift: invalid thresholds")

ErrInvalidThresholds is returned by ValidateThresholds when any value falls outside (0, 100] or major < minor.

Functions

func Score added in v0.8.0

func Score(passed, failed int) compliance.Score

Score returns the host's compliance score from confirmed verdicts, or an absent score when nothing produced one.

It delegates to internal/compliance so drift and every other surface share one definition. Skipped and error outcomes are excluded from the denominator, which the old doc comment said only of skipped.

This replaced ComplianceScore, which returned 0 for an empty denominator and called it a "conservative default". It was not conservative: 0 is a real verdict meaning every evaluated rule failed, so a host nothing could assess was indistinguishable from a host that failed everything, and the difference was a major-drift alert routed to an operator (bugs/OW-023).

func TypeForAudit

func TypeForAudit(k Kind) string

TypeForAudit converts a Kind to the detail.drift_type string the compliance.drift.detected event uses ({major, minor, improvement}). Returns "" for DriftStable (which doesn't emit).

func ValidateThresholds

func ValidateThresholds(t Thresholds) error

ValidateThresholds checks that every value is in (0, 100] and that MajorWorseningPP >= MinorWorseningPP. Spec AC-07.

Types

type EmitFunc

type EmitFunc func(ctx context.Context, code audit.Code, ev audit.Event)

EmitFunc mirrors audit.Emit. Same pattern as B.1a / B.1b / B.1c / B.2a.

type Kind

type Kind string

Kind classifies a per-host score delta. Closed enum per spec AC-13. Maps 1:1 to compliance.drift.detected detail.drift_type values (where "stable" is not emitted — see spec C-04).

const (
	DriftStable         Kind = "stable"
	DriftMinorWorsening Kind = "minor_worsening"
	DriftMajorWorsening Kind = "major_worsening"
	DriftImprovement    Kind = "improvement"
)

func Classify

func Classify(prior, current float64, t Thresholds) Kind

Classify maps a score delta to a Kind given the active thresholds. Pure function — no I/O, no side effects, deterministic.

Spec ACs satisfied here:

  • AC-01 (C-01, C-02, C-03): pure-function classifier returning a closed-enum Kind from percentage-point math.
  • AC-05 (C-04): scores within all thresholds return DriftStable.
  • AC-06 (C-05): the function uses the passed thresholds, NOT hardcoded values.

Math:

delta := current - prior   (positive = improvement, negative = worsening)
if delta >= ImprovementPP                                  → Improvement
if delta <= -MajorWorseningPP                              → MajorWorsening
if delta <= -MinorWorseningPP (and not major)              → MinorWorsening
otherwise                                                  → Stable

Comparisons use >= / <= so the threshold value itself fires the classification (a 5pp gain matches Improvement when ImprovementPP=5).

type Report

type Report struct {
	HostID       uuid.UUID
	ScanID       uuid.UUID
	Kind         Kind
	PriorScore   float64
	CurrentScore float64
	// CurrentScorePresent is false when no rule produced a verdict, in which
	// case CurrentScore is meaningless rather than zero percent.
	CurrentScorePresent bool

	// PriorScorePresent is false when no prior score is reconstructible.
	//
	// It is NOT the same fact as HasPriorBaseline. AC-19 is the case that
	// separates them: an all-skipped rescan has a baseline (transactions exist)
	// and no reconstructible prior percentage, because reconstructPriorCounts
	// inverts pass and fail transitions only. Without this flag such a report
	// exposes a plausible PriorScore of 0.
	PriorScorePresent bool

	// ComparisonPresent is true only when both scores exist and a delta was
	// actually computed. When false, ScoreDelta is the zero value and means
	// nothing: no comparison was made, rather than a comparison that found no
	// change. A consumer rendering "0.0pp" from an absent comparison would be
	// the same defect the score type exists to prevent, one field along.
	ComparisonPresent bool
	ScoreDelta        float64 // current - prior; negative when worsening

	// HasPriorBaseline is false on the first-ever scan against this
	// host. Kind is forced to DriftStable in that case (no baseline to
	// drift from). Spec AC-08.
	HasPriorBaseline bool

	// Per-severity transition counts. Spec C-08 / AC-09.
	CriticalBecameFailing int
	HighBecameFailing     int
	MediumBecameFailing   int
	LowBecameFailing      int

	CriticalBecamePassing int
	HighBecamePassing     int
	MediumBecamePassing   int
	LowBecamePassing      int
}

Report is what DetectForScan returns. Carries the classified kind, the raw scores + delta, and per-severity transition counts so the alert router (B.3) can route by severity.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service computes drift for a given (scanID, hostID) pair against the transaction log. Construct once at boot via NewService.

func NewService

func NewService(pool *pgxpool.Pool, emit EmitFunc, thresholds Thresholds, bus *eventbus.Bus) *Service

NewService wires the detector. emit is audit.Emit in production, a fake recorder in tests. thresholds defaults to DefaultThresholds when unset (any value 0); production wires from policy.AlertThresholds.

v1.1.0: bus may be nil. When nil, the service still emits audit events but skips bus publishes. Spec system-drift-detector C-10.

func (*Service) DetectForScan

func (s *Service) DetectForScan(ctx context.Context, hostID, scanID uuid.UUID) (Report, error)

DetectForScan computes drift for hostID against scanID's transactions.

Prior score: pulled from host_rule_state EXCLUDING rules whose last_scan_id == this scanID. This way, the "prior" view is the state before this scan landed (writer.Apply already moved host_rule_state to the new state, so we have to reconstruct).

Current score: this host's host_rule_state rows carrying THIS scanID (post-Apply). That is the corpus the scan evaluated. Rules the host once carried but this scan no longer ships are excluded, so a retired rule's frozen verdict cannot drag the current score.

Per-severity transition counts: read from the transactions table filtered to this scanID + state_changed change_kind.

Spec ACs satisfied:

  • AC-08 (C-01, C-06): no prior data → DriftStable with HasPriorBaseline=false. The first-ever scan against a host cannot drift.
  • AC-09 (C-08): per-severity transition counts populated from transactions.
  • AC-10/11 (C-04): emission gates on Kind != DriftStable.

func (*Service) Thresholds

func (s *Service) Thresholds() Thresholds

Thresholds returns the active thresholds — useful for tests and observability.

type Thresholds

type Thresholds struct {
	// MajorWorseningPP is the score-drop threshold (pp) for the major
	// classification. A drop ≥ this value is major worsening.
	MajorWorseningPP float64

	// MinorWorseningPP is the score-drop threshold (pp) for minor.
	// A drop ≥ this AND below major is minor worsening.
	MinorWorseningPP float64

	// ImprovementPP is the score-gain threshold (pp) for improvement.
	// A gain ≥ this value is classified as improvement.
	ImprovementPP float64
}

Thresholds defines the percentage-point boundaries between drift kinds. All values are percentage points (pp), not percent-of-percent. Spec C-05 / AC-07: validated via ValidateThresholds.

func DefaultThresholds

func DefaultThresholds() Thresholds

DefaultThresholds returns the spec-defined defaults. Spec C-05.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL