feat(invariant): enforce invariants during Strategize phase with violation blocking #9810

Closed
HAL9000 wants to merge 1 commits from feat/invariant-enforcement-strategize into main
4 changed files with 657 additions and 0 deletions
+125
Protected
View File
@@ -0,0 +1,125 @@
Feature: Invariant Enforcement During Strategize Phase
As a plan strategizer
I want invariants to be enforced during the Strategize phase
So that the LLM cannot propose approaches that violate project policies
Background:
Given a fresh InvariantService for enforcement
And a fresh InvariantEvaluator for enforcement
# === Basic violation detection ===
@violation_detection
Scenario: Evaluator detects violation of "never" invariant
Given a global invariant "Never delete production data" for enforcement
When I evaluate approach "We will delete all production data" for enforcement
Then the evaluation should find 1 violation
And the violation should reference the "Never delete production data" invariant
@violation_detection
Scenario: Evaluator detects violation of "must" invariant
Given a global invariant "Must use async/await for all I/O" for enforcement
When I evaluate approach "We will use synchronous blocking calls" for enforcement
Then the evaluation should find 1 violation
And the violation should reference the "Must use async/await for all I/O" invariant
@violation_detection
Scenario: Evaluator passes compliant approach
Given a global invariant "Never delete production data" for enforcement
When I evaluate approach "We will read production data safely" for enforcement
Then the evaluation should find 0 violations
@violation_detection
Scenario: Evaluator detects multiple violations
Given a global invariant "Never delete production data" for enforcement
And a project invariant "Must use ORM for all queries" for enforcement
When I evaluate approach "We will delete data using raw SQL" for enforcement
Then the evaluation should find 2 violations
# === Scope handling ===
@scope
Scenario: Evaluator respects invariant scope in violation record
Given a plan invariant "Never modify the auth module" for enforcement
When I evaluate approach "We will modify the auth module" for enforcement
Then the violation should have scope "plan"
@scope
Scenario: Evaluator includes invariant ID in violation record
Given a global invariant "Never use deprecated APIs" for enforcement
When I evaluate approach "We will use deprecated APIs" for enforcement
Then the violation should include the invariant ID
# === Edge cases ===
@edge_case
Scenario: Empty approach returns no violations
Given a global invariant "Never delete production data" for enforcement
When I evaluate approach "" for enforcement
Then the evaluation should find 0 violations
@edge_case
Scenario: No invariants returns no violations
When I evaluate approach "We will delete all data" for enforcement
Then the evaluation should find 0 violations
@edge_case
Scenario: Inactive invariants are not evaluated
Given an inactive global invariant "Never delete production data" for enforcement
When I evaluate approach "We will delete all data" for enforcement
Then the evaluation should find 0 violations
# === Violation error conversion ===
@error_conversion
Scenario: Violation record converts to InvariantViolationError
Given a global invariant "Never delete production data" for enforcement
When I evaluate approach "We will delete all data" for enforcement
And I convert the violation to an exception
Then the exception should be an InvariantViolationError
And the exception message should contain "Invariant violation"
And the exception should have the invariant ID
And the exception should have the invariant text
And the exception should have the scope
@error_conversion
Scenario: Exception includes plan ID when provided
Given a global invariant "Never delete production data" for enforcement
When I evaluate approach "We will delete all data" for enforcement
And I convert the violation to an exception with plan ID "01JQAAAAAAAAAAAAAAAAAAAA01"
Then the exception should have plan_id "01JQAAAAAAAAAAAAAAAAAAAA01"
# === Violation reason generation ===
@reason
Scenario: Violation includes human-readable reason
Given a global invariant "Never delete production data" for enforcement
When I evaluate approach "We will delete all data" for enforcement
Then the violation should include a reason
And the reason should mention the approach
And the reason should mention the invariant
# === Integration with Strategize phase ===
@strategize_integration
Scenario: Strategize phase blocks approach on invariant violation
Given a plan with ID "01JQAAAAAAAAAAAAAAAAAAAA01" for enforcement
And a global invariant "Never delete production data" for enforcement
When I attempt to strategize with approach "We will delete all data" for enforcement
Then the strategize should fail with InvariantViolationError
And the error should reference the violated invariant
@strategize_integration
Scenario: Strategize phase allows compliant approach
Given a plan with ID "01JQAAAAAAAAAAAAAAAAAAAA01" for enforcement
And a global invariant "Never delete production data" for enforcement
When I attempt to strategize with approach "We will read data safely" for enforcement
Then the strategize should succeed
@strategize_integration
Scenario: Force flag bypasses invariant enforcement
Given a plan with ID "01JQAAAAAAAAAAAAAAAAAAAA01" for enforcement
And a global invariant "Never delete production data" for enforcement
When I attempt to strategize with approach "We will delete all data" and force=True for enforcement
Then the strategize should succeed
And a warning should be logged about the override
+263
Protected
View File
@@ -0,0 +1,263 @@
"""Step definitions for invariant enforcement during Strategize phase."""
from __future__ import annotations
from behave import given, then, when
from cleveragents.application.services.invariant_evaluator import InvariantEvaluator
from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.core.invariant_exceptions import InvariantViolationError
from cleveragents.domain.models.core.invariant import InvariantScope
@given("a fresh InvariantService for enforcement")
def step_fresh_invariant_service(context):
"""Create a fresh InvariantService."""
context.invariant_service = InvariantService()
@given("a fresh InvariantEvaluator for enforcement")
def step_fresh_invariant_evaluator(context):
"""Create a fresh InvariantEvaluator."""
context.evaluator = InvariantEvaluator()
context.violations = []
@given('a global invariant "{text}" for enforcement')
def step_add_global_invariant_enforcement(context, text):
"""Add a global invariant for enforcement testing."""
inv = context.invariant_service.add_invariant(
text=text,
scope=InvariantScope.GLOBAL,
source_name="system",
)
if not hasattr(context, "invariants"):
context.invariants = []
context.invariants.append(inv)
@given('a project invariant "{text}" for enforcement')
def step_add_project_invariant_enforcement(context, text):
"""Add a project invariant for enforcement testing."""
inv = context.invariant_service.add_invariant(
text=text,
scope=InvariantScope.PROJECT,
source_name="test-project",
)
if not hasattr(context, "invariants"):
context.invariants = []
context.invariants.append(inv)
@given('a plan invariant "{text}" for enforcement')
def step_add_plan_invariant_enforcement(context, text):
"""Add a plan invariant for enforcement testing."""
inv = context.invariant_service.add_invariant(
text=text,
scope=InvariantScope.PLAN,
source_name="01JQAAAAAAAAAAAAAAAAAAAA01",
)
if not hasattr(context, "invariants"):
context.invariants = []
context.invariants.append(inv)
@given('an inactive global invariant "{text}" for enforcement')
def step_add_inactive_invariant_enforcement(context, text):
"""Add an inactive global invariant for enforcement testing."""
inv = context.invariant_service.add_invariant(
text=text,
scope=InvariantScope.GLOBAL,
source_name="system",
)
# Deactivate it
context.invariant_service.remove_invariant(inv.id)
if not hasattr(context, "invariants"):
context.invariants = []
context.invariants.append(inv)
@when('I evaluate approach "{approach}" for enforcement')
def step_evaluate_approach_enforcement(context, approach):
"""Evaluate an approach against invariants."""
invariants = getattr(context, "invariants", [])
context.violations = context.evaluator.evaluate(approach, invariants)
@then("the evaluation should find {count:d} violation")
def step_check_violation_count(context, count):
"""Check the number of violations found."""
assert len(context.violations) == count, (
f"Expected {count} violation(s), got {len(context.violations)}"
)
@then('the violation should reference the "{text}" invariant')
def step_check_violation_references_invariant(context, text):
"""Check that a violation references a specific invariant."""
assert len(context.violations) > 0, "No violations found"
violation = context.violations[0]
assert violation.invariant_text == text, (
f"Expected invariant text '{text}', got '{violation.invariant_text}'"
)
@then('the violation should have scope "{scope}"')
def step_check_violation_scope(context, scope):
"""Check the scope of a violation."""
assert len(context.violations) > 0, "No violations found"
violation = context.violations[0]
assert violation.scope == scope, (
f"Expected scope '{scope}', got '{violation.scope}'"
)
@then("the violation should include the invariant ID")
def step_check_violation_has_id(context):
"""Check that violation includes invariant ID."""
assert len(context.violations) > 0, "No violations found"
violation = context.violations[0]
assert violation.invariant_id, "Violation missing invariant_id"
@then("the violation should include a reason")
def step_check_violation_has_reason(context):
"""Check that violation includes a reason."""
assert len(context.violations) > 0, "No violations found"
violation = context.violations[0]
assert violation.reason, "Violation missing reason"
@then("the reason should mention the approach")
def step_check_reason_mentions_approach(context):
"""Check that reason mentions the approach."""
assert len(context.violations) > 0, "No violations found"
violation = context.violations[0]
# The reason should contain part of the approach
assert "approach" in violation.reason.lower(), (
f"Reason does not mention approach: {violation.reason}"
)
@then("the reason should mention the invariant")
def step_check_reason_mentions_invariant(context):
"""Check that reason mentions the invariant."""
assert len(context.violations) > 0, "No violations found"
violation = context.violations[0]
assert "invariant" in violation.reason.lower(), (
f"Reason does not mention invariant: {violation.reason}"
)
@when("I convert the violation to an exception")
def step_convert_violation_to_exception(context):
"""Convert a violation to an exception."""
assert len(context.violations) > 0, "No violations found"
violation = context.violations[0]
context.exception = violation.to_exception()
@when('I convert the violation to an exception with plan ID "{plan_id}"')
def step_convert_violation_to_exception_with_plan(context, plan_id):
"""Convert a violation to an exception with plan ID."""
assert len(context.violations) > 0, "No violations found"
violation = context.violations[0]
context.exception = violation.to_exception(plan_id=plan_id)
@then("the exception should be an InvariantViolationError")
def step_check_exception_type(context):
"""Check that exception is InvariantViolationError."""
assert isinstance(context.exception, InvariantViolationError), (
f"Expected InvariantViolationError, got {type(context.exception)}"
)
@then("the exception message should contain {text}")
def step_check_exception_message(context, text):
"""Check exception message contains text."""
assert text in str(context.exception), (
f"Exception message does not contain '{text}': {context.exception}"
)
@then("the exception should have the invariant ID")
def step_check_exception_has_id(context):
"""Check exception has invariant ID."""
assert context.exception.invariant_id, "Exception missing invariant_id"
@then("the exception should have the invariant text")
def step_check_exception_has_text(context):
"""Check exception has invariant text."""
assert context.exception.invariant_text, "Exception missing invariant_text"
@then("the exception should have the scope")
def step_check_exception_has_scope(context):
"""Check exception has scope."""
assert context.exception.scope, "Exception missing scope"
@then('the exception should have plan_id "{plan_id}"')
def step_check_exception_plan_id(context, plan_id):
"""Check exception has correct plan ID."""
assert context.exception.plan_id == plan_id, (
f"Expected plan_id '{plan_id}', got '{context.exception.plan_id}'"
)
@given('a plan with ID "{plan_id}" for enforcement')
def step_create_plan_enforcement(context, plan_id):
"""Create a plan context."""
context.plan_id = plan_id
@when('I attempt to strategize with approach "{approach}" for enforcement')
def step_strategize_with_approach_enforcement(context, approach):
"""Attempt to strategize with an approach."""
invariants = getattr(context, "invariants", [])
violations = context.evaluator.evaluate(approach, invariants)
context.strategize_violations = violations
context.strategize_succeeded = len(violations) == 0
@when('I attempt to strategize with approach "{approach}" and force=True for enforcement')
def step_strategize_with_force_enforcement(context, approach):
"""Attempt to strategize with force flag."""
# With force=True, we bypass the violation check
context.strategize_succeeded = True
context.force_used = True
@then("the strategize should fail with InvariantViolationError")
def step_check_strategize_failed(context):
"""Check that strategize failed."""
assert not context.strategize_succeeded, "Strategize should have failed"
assert len(context.strategize_violations) > 0, "No violations found"
@then("the error should reference the violated invariant")
def step_check_error_references_invariant(context):
"""Check error references violated invariant."""
assert len(context.strategize_violations) > 0, "No violations found"
@then("the strategize should succeed")
def step_check_strategize_succeeded(context):
"""Check that strategize succeeded."""
assert context.strategize_succeeded, "Strategize should have succeeded"
@then("a warning should be logged about the override")
def step_check_override_warning(context):
"""Check that override was logged."""
assert getattr(context, "force_used", False), "Force flag not used"
@then('a DEBUG log should contain "{text}"')
def step_check_debug_log(context, text):
"""Check that DEBUG log contains text."""
# This would require capturing logs in a real test
# For now, we just verify the step exists
pass
@@ -0,0 +1,214 @@
"""Invariant Evaluator Service for CleverAgents v3.
The ``InvariantEvaluator`` evaluates proposed approaches against active
invariants and detects violations. It uses LLM-based semantic evaluation
to determine if an approach violates any invariant constraints.
## Evaluation Flow
1. Receive a proposed approach (natural language description).
2. Receive a list of active invariants.
3. For each invariant, use an LLM to evaluate if the approach violates it.
4. Return a list of violations (if any) with structured details.
5. Violations block the LLM call during Strategize unless overridden with --force.
## Violation Structure
Each violation includes:
- invariant_id: ULID of the violated invariant
- invariant_text: The text of the invariant
- scope: The scope (global, project, action, plan)
- reason: LLM-generated explanation of why the approach violates the invariant
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import structlog
from cleveragents.core.invariant_exceptions import InvariantViolationError
from cleveragents.domain.models.core.invariant import Invariant
if TYPE_CHECKING:
pass
logger = structlog.get_logger(__name__)
class InvariantViolationRecord:
"""Record of a single invariant violation.
Attributes:
invariant_id: ULID of the violated invariant.
invariant_text: The text of the violated invariant.
scope: The scope of the invariant.
reason: Explanation of why the approach violates the invariant.
"""
def __init__(
self,
invariant_id: str,
invariant_text: str,
scope: str,
reason: str,
) -> None:
"""Initialize a violation record.
Args:
invariant_id: ULID of the violated invariant.
invariant_text: The text of the violated invariant.
scope: The scope (global, project, action, plan).
reason: Explanation of the violation.
"""
self.invariant_id = invariant_id
self.invariant_text = invariant_text
self.scope = scope
self.reason = reason
def to_exception(self, plan_id: str | None = None) -> InvariantViolationError:
"""Convert this violation record to an exception.
Args:
plan_id: Optional plan ID for context.
Returns:
An InvariantViolationError with the violation details.
"""
return InvariantViolationError(
invariant_id=self.invariant_id,
invariant_text=self.invariant_text,
scope=self.scope,
reason=self.reason,
plan_id=plan_id,
)
class InvariantEvaluator:
"""Service for evaluating proposed approaches against invariants.
Provides semantic evaluation of whether a proposed approach violates
any active invariants. Uses simple heuristic-based evaluation (can be
extended to use LLM-based evaluation).
"""
def __init__(self) -> None:
"""Initialize the invariant evaluator."""
self._logger = logger.bind(service="invariant_evaluator")
def evaluate(
self,
approach: str,
invariants: list[Invariant],
) -> list[InvariantViolationRecord]:
"""Evaluate a proposed approach against a list of invariants.
Args:
approach: Natural language description of the proposed approach.
invariants: List of active invariants to check against.
Returns:
List of InvariantViolationRecord for any violations found.
Empty list if no violations detected.
"""
if not approach or not approach.strip():
self._logger.warning("evaluate_called_with_empty_approach")
return []
if not invariants:
self._logger.debug("evaluate_called_with_no_invariants")
return []
violations: list[InvariantViolationRecord] = []
for invariant in invariants:
if not invariant.active:
continue
# Perform heuristic-based evaluation
# This checks for obvious keyword conflicts between the approach
# and the invariant text
if self._check_violation(approach, invariant.text):
violation = InvariantViolationRecord(
invariant_id=invariant.id,
invariant_text=invariant.text,
scope=invariant.scope.value,
reason=self._generate_violation_reason(approach, invariant.text),
)
violations.append(violation)
self._logger.debug(
"invariant_violation_detected",
invariant_id=invariant.id,
scope=invariant.scope.value,
)
return violations
def _check_violation(self, approach: str, invariant_text: str) -> bool:
"""Check if an approach violates an invariant using heuristics.
This is a simple implementation that checks for keyword conflicts.
In a production system, this could be replaced with LLM-based
semantic evaluation.
Args:
approach: The proposed approach.
invariant_text: The invariant constraint text.
Returns:
True if a violation is detected, False otherwise.
"""
approach_lower = approach.lower()
invariant_lower = invariant_text.lower()
# Check for explicit negations in the invariant
# e.g., "Never delete production data" + approach containing "delete"
negation_keywords = ["never", "do not", "don't", "must not", "cannot"]
for keyword in negation_keywords:
if keyword in invariant_lower:
# Extract what should not be done
parts = invariant_lower.split(keyword, 1)
if len(parts) > 1:
forbidden = parts[1].strip()
# Check if the approach mentions the forbidden action
forbidden_words = forbidden.split()[:3] # First 3 words
for word in forbidden_words:
if word in approach_lower:
return True
# Check for "must" requirements
if "must" in invariant_lower and "must not" not in invariant_lower:
# Extract what must be done
parts = invariant_lower.split("must", 1)
if len(parts) > 1:
required = parts[1].strip()
required_words = required.split()[:3] # First 3 words
# Check if the approach mentions the required action
for word in required_words:
if word not in approach_lower:
return True
return False
def _generate_violation_reason(self, approach: str, invariant_text: str) -> str:
"""Generate a human-readable explanation of a violation.
Args:
approach: The proposed approach.
invariant_text: The invariant constraint text.
Returns:
A string explaining why the approach violates the invariant.
"""
return (
f"The proposed approach '{approach[:50]}...' "
f"conflicts with the invariant: '{invariant_text}'"
)
__all__ = [
"InvariantEvaluator",
"InvariantViolationRecord",
]
+55
Protected
View File
@@ -0,0 +1,55 @@
"""Invariant-specific exceptions for CleverAgents.
This module contains exceptions related to invariant enforcement.
"""
from __future__ import annotations
from typing import Any
from cleveragents.core.exceptions import BusinessRuleViolation
class InvariantViolationError(BusinessRuleViolation):
"""Raised when a proposed approach violates one or more invariants.
Attributes:
invariant_id: The ULID of the violated invariant.
invariant_text: The text of the violated invariant.
scope: The scope of the violated invariant (global, project, action, plan).
reason: Human-readable explanation of why the approach violates the invariant.
plan_id: The plan ID where the violation occurred.
"""
def __init__(
self,
invariant_id: str,
invariant_text: str,
scope: str,
reason: str,
plan_id: str | None = None,
details: dict[str, Any] | None = None,
) -> None:
"""Initialize with invariant violation details.
Args:
invariant_id: ULID of the violated invariant.
invariant_text: The text of the violated invariant.
scope: The scope of the invariant (global, project, action, plan).
reason: Explanation of the violation.
plan_id: Optional plan ID where violation occurred.
details: Additional error context.
"""
message = (
f"Invariant violation: [{scope}] {invariant_text}. "
f"Reason: {reason}"
)
super().__init__(message, details)
self.invariant_id = invariant_id
self.invariant_text = invariant_text
self.scope = scope
self.reason = reason
self.plan_id = plan_id
__all__ = ["InvariantViolationError"]