|
| 1 | +# ADR-0054: Analysis Rule Engine |
| 2 | + |
| 3 | +**Decision:** Implement a declarative rule engine at `gnat/analysis/rules/` |
| 4 | +that evaluates `analysis.investigations.Hypothesis` objects and returns |
| 5 | +status transition decisions. Rules are authored as `.hy` (Hy/Lisp) files, |
| 6 | +loaded dynamically, and evaluated on hypothesis mutation. The engine is an |
| 7 | +advisor — it returns decisions but never mutates state directly. |
| 8 | + |
| 9 | +**Problem statement:** |
| 10 | +`InvestigationService.update_hypothesis_status` is a pure setter with no |
| 11 | +evaluation logic. Status transitions happen manually. The `reasoning.HypothesisEngine` |
| 12 | +has hardcoded thresholds at the STIX level but operates on `STIXHypothesis`, |
| 13 | +not `analysis.Hypothesis`. There is an empty slot at the analysis layer for |
| 14 | +automated, auditable, analyst-authorable evaluation logic. |
| 15 | + |
| 16 | +## Why Hy |
| 17 | + |
| 18 | +Hy is a Lisp that compiles to Python AST and runs in the same interpreter. |
| 19 | +It sits between "more declarative than Python" and "less foreign than Prolog," |
| 20 | +embedded in-process with no new service boundary. |
| 21 | + |
| 22 | +**Alternatives considered:** |
| 23 | +- **Prolog:** Strong for pure inference but requires a separate runtime. |
| 24 | + Marshaling STIX objects across the boundary breaks the |
| 25 | + Postgres-as-source-of-truth contract. |
| 26 | +- **Clojure via Babashka:** Same cross-boundary cost as Prolog. |
| 27 | +- **YAML + DSL:** Analyst-familiar but YAML-with-expressions becomes |
| 28 | + its own interpreter. May be added as a second engine post-v1. |
| 29 | +- **Pure Python functions:** Works but loses the declarative-authoring |
| 30 | + property that is the engine's main value. |
| 31 | + |
| 32 | +## Key Decisions |
| 33 | + |
| 34 | +### Rules are advisors, not mutators |
| 35 | + |
| 36 | +The engine's `evaluate()` returns a `RuleEvaluationResult` containing |
| 37 | +decisions. It does not mutate state. An orchestrator reads the decision |
| 38 | +and applies it via `InvestigationService.update_hypothesis_status`. This |
| 39 | +keeps the state machine authority in one place and makes the engine |
| 40 | +testable in isolation. |
| 41 | + |
| 42 | +### Two-engine coexistence |
| 43 | + |
| 44 | +`reasoning.HypothesisEngine` (STIX-level, ADR-0042) remains untouched. |
| 45 | +The new `AnalysisRuleEngine` operates on `analysis.investigations.Hypothesis` |
| 46 | +(analyst workspace level). These are different views of the same concept |
| 47 | +at different layers. They do not merge. |
| 48 | + |
| 49 | +### Evidence resolution via dedicated resolver |
| 50 | + |
| 51 | +`Hypothesis.supporting_evidence` and `refuting_evidence` are lists of |
| 52 | +STIX IDs. The engine resolves each ID to its originating connector via |
| 53 | +`EvidenceResolver`, which queries `WorkspaceStore.get_source_platforms_bulk` |
| 54 | +and looks up `TRUST_LEVEL` from `CLIENT_REGISTRY`. STIX objects are not |
| 55 | +polluted with connector metadata. |
| 56 | + |
| 57 | +### Audit-first with applied flag |
| 58 | + |
| 59 | +Every rule evaluation writes an audit record BEFORE applying the decision. |
| 60 | +The record has `applied: bool` that flips to true after successful mutation. |
| 61 | +No transaction threading — sequential operations with audit as leading write. |
| 62 | + |
| 63 | +### AI-60 confidence ceiling as predicate, not clamp |
| 64 | + |
| 65 | +The AI confidence ceiling is enforced as a helper predicate |
| 66 | +`within-ai-ceiling?` that rules call in their `:when` clause. Rules |
| 67 | +refuse to promote if the ceiling is violated. The ceiling is NOT a |
| 68 | +mutation that clamps the number — it stays visible in rule source code. |
| 69 | + |
| 70 | +### Priority-based first-match semantics |
| 71 | + |
| 72 | +Rules sorted by priority descending. First rule whose `:when` returns |
| 73 | +truthy for a status-transition decision fires and consumes the transition |
| 74 | +slot. Annotations always fire. `no_op` consumes the slot without mutating. |
| 75 | + |
| 76 | +### Dirty-tree policy |
| 77 | + |
| 78 | +In production, rules with uncommitted source file changes will not fire. |
| 79 | +Git SHA captured in audit records. `GNAT_ALLOW_DIRTY_RULES=1` provides |
| 80 | +emergency override. |
| 81 | + |
| 82 | +### Feature flag default OFF |
| 83 | + |
| 84 | +Existing users unaffected. Enable via `[rules] enabled = true` in config. |
| 85 | + |
| 86 | +## Consequences |
| 87 | + |
| 88 | +**Positive:** Analyst-authorable hypothesis evaluation, full audit trail, |
| 89 | +declarative expression, testable in isolation from service layer. |
| 90 | + |
| 91 | +**Negative:** Hy dependency (optional extra), helper library maintenance, |
| 92 | +analyst learning curve for Lisp syntax. |
| 93 | + |
| 94 | +**Neutral:** Second engine implementation (YAML, Python) possible later |
| 95 | +via `RuleEngineProtocol` without refactoring the core. |
| 96 | + |
| 97 | +→ Related: ADR-0031 (Analysis Layer Architecture) |
| 98 | +→ Related: ADR-0033 (Confidence Scoring — Admiralty Scale) |
| 99 | +→ Related: ADR-0042 (Hypothesis Engine — STIX-level, coexists) |
0 commit comments