feat(invariant): add invariant models and enforcement
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Failing after 14s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 17s
CI / typecheck (pull_request) Successful in 30s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / security (pull_request) Successful in 33s
CI / integration_tests (pull_request) Successful in 2m49s
CI / unit_tests (pull_request) Successful in 5m25s
CI / docker (pull_request) Has been skipped

This commit is contained in:
2026-02-22 09:23:35 +00:00
parent ca1c341b18
commit 2d4b330f75
13 changed files with 2183 additions and 18 deletions
+180
View File
@@ -0,0 +1,180 @@
"""ASV benchmarks for invariant merge operations.
Measures the performance of:
- Invariant model construction (Pydantic validation)
- merge_invariants() with varying list sizes
- InvariantSet.merge() class method
- InvariantService.get_effective_invariants()
"""
from __future__ import annotations
import sys
from pathlib import Path
try:
from cleveragents.domain.models.core.invariant import (
Invariant,
InvariantScope,
InvariantSet,
merge_invariants,
)
from cleveragents.application.services.invariant_service import InvariantService
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.invariant import (
Invariant,
InvariantScope,
InvariantSet,
merge_invariants,
)
from cleveragents.application.services.invariant_service import InvariantService
def _make_invariants(scope: InvariantScope, count: int) -> list[Invariant]:
"""Create a list of invariants for benchmarking."""
return [
Invariant(
text=f"Constraint {scope.value} {i}",
scope=scope,
source_name=f"source-{scope.value}",
)
for i in range(count)
]
class InvariantConstructionSuite:
"""Benchmark Invariant model construction."""
def time_invariant_construction(self) -> None:
"""Benchmark single invariant creation."""
Invariant(
text="Never delete production data",
scope=InvariantScope.GLOBAL,
source_name="system",
)
def time_invariant_construction_plan(self) -> None:
"""Benchmark plan-scoped invariant creation."""
Invariant(
text="Use Python 3.13 only",
scope=InvariantScope.PLAN,
source_name="plan-001",
)
class MergeSmallSuite:
"""Benchmark merge with small invariant lists."""
def setup(self) -> None:
"""Create small invariant lists."""
self.plan = _make_invariants(InvariantScope.PLAN, 3)
self.project = _make_invariants(InvariantScope.PROJECT, 5)
self.global_invs = _make_invariants(InvariantScope.GLOBAL, 5)
def time_merge_small(self) -> None:
"""Benchmark merge with ~13 invariants."""
merge_invariants(self.plan, self.project, self.global_invs)
class MergeMediumSuite:
"""Benchmark merge with medium invariant lists."""
def setup(self) -> None:
"""Create medium invariant lists."""
self.plan = _make_invariants(InvariantScope.PLAN, 10)
self.project = _make_invariants(InvariantScope.PROJECT, 20)
self.global_invs = _make_invariants(InvariantScope.GLOBAL, 20)
def time_merge_medium(self) -> None:
"""Benchmark merge with ~50 invariants."""
merge_invariants(self.plan, self.project, self.global_invs)
class MergeLargeSuite:
"""Benchmark merge with large invariant lists."""
def setup(self) -> None:
"""Create large invariant lists."""
self.plan = _make_invariants(InvariantScope.PLAN, 50)
self.project = _make_invariants(InvariantScope.PROJECT, 100)
self.global_invs = _make_invariants(InvariantScope.GLOBAL, 100)
def time_merge_large(self) -> None:
"""Benchmark merge with ~250 invariants."""
merge_invariants(self.plan, self.project, self.global_invs)
class MergeDeduplicationSuite:
"""Benchmark merge with heavy de-duplication."""
def setup(self) -> None:
"""Create invariant lists with many duplicates."""
self.plan = [
Invariant(
text=f"Shared constraint {i}",
scope=InvariantScope.PLAN,
source_name="plan-001",
)
for i in range(20)
]
self.project = [
Invariant(
text=f"Shared constraint {i}",
scope=InvariantScope.PROJECT,
source_name="proj-001",
)
for i in range(20)
]
self.global_invs = [
Invariant(
text=f"Shared constraint {i}",
scope=InvariantScope.GLOBAL,
source_name="system",
)
for i in range(20)
]
def time_merge_dedup(self) -> None:
"""Benchmark merge with 60 invariants, all duplicates."""
merge_invariants(self.plan, self.project, self.global_invs)
class InvariantSetMergeSuite:
"""Benchmark InvariantSet.merge() class method."""
def setup(self) -> None:
"""Create invariant lists."""
self.plan = _make_invariants(InvariantScope.PLAN, 5)
self.project = _make_invariants(InvariantScope.PROJECT, 10)
self.global_invs = _make_invariants(InvariantScope.GLOBAL, 10)
def time_invariant_set_merge(self) -> None:
"""Benchmark InvariantSet.merge()."""
InvariantSet.merge(self.plan, self.project, self.global_invs)
class ServiceEffectiveSuite:
"""Benchmark InvariantService.get_effective_invariants()."""
def setup(self) -> None:
"""Populate service with invariants."""
self.service = InvariantService()
for i in range(10):
self.service.add_invariant(
f"Global {i}", InvariantScope.GLOBAL, "system"
)
for i in range(10):
self.service.add_invariant(
f"Project {i}", InvariantScope.PROJECT, "myapp"
)
for i in range(5):
self.service.add_invariant(
f"Plan {i}", InvariantScope.PLAN, "plan-001"
)
def time_get_effective(self) -> None:
"""Benchmark get_effective_invariants()."""
self.service.get_effective_invariants(
plan_id="plan-001", project_name="myapp"
)
+134
View File
@@ -0,0 +1,134 @@
# Invariants
Invariants are natural-language constraints on plan execution. They flow into the
decision tree during Strategize and are reconciled by the Invariant Reconciliation
Actor before any strategy work begins.
## Scope Hierarchy
| Scope | Description |
|-----------|----------------------------------------------------------|
| `GLOBAL` | Applies to every plan in the system |
| `PROJECT` | Applies to plans targeting a specific project |
| `ACTION` | Defined in an action template; promoted on `plan use` |
| `PLAN` | Attached directly to a specific plan |
## Merge Precedence
When computing the effective set of invariants for a plan, the precedence chain is:
```
plan > project > global
```
- **Plan-level** invariants take highest precedence.
- **Project-level** invariants apply next.
- **Global-level** invariants are lowest precedence.
- **Action-level** invariants are promoted to plan scope when `plan use` is called.
### De-duplication
Invariants are de-duplicated by text (case-insensitive). When the same constraint
text appears at multiple scopes, only the highest-precedence copy is kept.
### Merge Example
Given:
- Global: `"Never delete production data"`, `"Log all changes"`
- Project (`myapp`): `"All API changes need tests"`, `"Log all changes"`
- Plan: `"Use Python 3.13 only"`
The effective set for a plan targeting `myapp` would be:
1. `"Use Python 3.13 only"` (plan)
2. `"All API changes need tests"` (project)
3. `"Log all changes"` (project — shadows the global duplicate)
4. `"Never delete production data"` (global)
## Enforcement
At the start of the Strategize phase, the Invariant Reconciliation Actor:
1. Collects the effective invariant set for the plan.
2. Evaluates each invariant against the current plan context.
3. Creates an `InvariantEnforcementRecord` for each invariant.
4. Records an `invariant_enforced` decision in the decision tree.
### Enforcement Record
Each record contains:
| Field | Description |
|------------------|--------------------------------------------------|
| `invariant_id` | ULID of the invariant |
| `enforced` | Whether the invariant was successfully enforced |
| `actor_response` | Response text from the reconciliation actor |
| `decision_id` | ULID of the associated decision node |
## Violation Model
When an invariant is violated during execution, an `InvariantViolation` is created:
| Field | Description |
|-----------------|---------------------------------------------------|
| `invariant_id` | ULID of the violated invariant |
| `violated_text` | The invariant text that was violated |
| `severity` | `error`, `warning`, or `info` |
| `details` | Additional violation context |
## CLI Usage
### Add an invariant
```bash
# Global invariant
agents invariant add --global "Never delete production data"
# Project-scoped invariant
agents invariant add --project myapp "All API changes need tests"
# Plan-specific invariant
agents invariant add --plan 01HXYZ... "Use Python 3.13 only"
# Action-scoped invariant
agents invariant add --action local/code-coverage "Minimum 80% coverage"
```
### List invariants
```bash
# List all active invariants
agents invariant list
# Filter by scope
agents invariant list --global
agents invariant list --project myapp
# Show merged effective set for a project
agents invariant list --effective --project myapp
# Filter by regex
agents invariant list "data.*safe"
# JSON output
agents invariant list --format json
```
### Remove an invariant
```bash
# With confirmation prompt
agents invariant remove 01HXYZ...
# Skip confirmation
agents invariant remove --yes 01HXYZ...
```
## Relationship with PlanInvariant
The existing `PlanInvariant` model in `plan.py` represents invariants already
attached to a plan with an `InvariantSource` provenance tag. The new `Invariant`
model in `invariant.py` is the standalone storage model used by `InvariantService`
for cross-scope management. When a plan is created via `plan use`, action
invariants are converted to `PlanInvariant` entries on the plan itself.
+242
View File
@@ -0,0 +1,242 @@
Feature: Invariant Models and Enforcement
As a developer
I want invariant models that enforce natural-language constraints on plans
So that plan execution respects configured constraints at every scope level
# === Invariant Creation with Different Scopes ===
Scenario: Create a global invariant
When I create an invariant with text "Never delete production data" scope "global" and source "system"
Then the invariant should be created
And the invariant scope should be "global"
And the invariant source name should be "system"
And the invariant should be active
Scenario: Create a project invariant
When I create an invariant with text "All API changes need tests" scope "project" and source "myapp"
Then the invariant should be created
And the invariant scope should be "project"
And the invariant source name should be "myapp"
Scenario: Create an action invariant
When I create an invariant with text "Minimum 80% coverage" scope "action" and source "local/code-coverage"
Then the invariant should be created
And the invariant scope should be "action"
Scenario: Create a plan invariant
When I create an invariant with text "Use Python 3.13 only" scope "plan" and source "PLAN123"
Then the invariant should be created
And the invariant scope should be "plan"
Scenario: Invariant gets a ULID id
When I create an invariant with text "Test constraint" scope "global" and source "system"
Then the invariant id should be a valid ULID
Scenario: Invariant text is stripped of whitespace
When I create an invariant with text " padded text " scope "global" and source "system"
Then the invariant text should be "padded text"
# === Empty Invariant Text Rejection ===
Scenario: Empty invariant text is rejected
When I try to create an invariant with empty text
Then a validation error should be raised
And the error should mention "at least 1 character"
Scenario: Whitespace-only invariant text is rejected
When I try to create an invariant with whitespace-only text
Then a validation error should be raised
# === InvariantScope Enum ===
Scenario: InvariantScope has four values
Then InvariantScope should have values "global, project, action, plan"
# === Merge Precedence (plan > project > global) ===
Scenario: Merge with no duplicates preserves all invariants
Given I have plan invariants
| text | source |
| Plan constraint | plan1 |
And I have project invariants
| text | source |
| Project constraint | proj1 |
And I have global invariants
| text | source |
| Global constraint | system |
When I merge the invariants
Then the merged set should have 3 invariants
And the merged invariant at index 0 should have text "Plan constraint"
And the merged invariant at index 1 should have text "Project constraint"
And the merged invariant at index 2 should have text "Global constraint"
Scenario: Plan invariant overrides project invariant with same text
Given I have plan invariants
| text | source |
| Log all changes | plan1 |
And I have project invariants
| text | source |
| Log all changes | proj1 |
And I have global invariants
| text | source |
When I merge the invariants
Then the merged set should have 1 invariants
And the merged invariant at index 0 should have scope "plan"
Scenario: Project invariant overrides global invariant with same text
Given I have plan invariants
| text | source |
And I have project invariants
| text | source |
| Log all changes | proj1 |
And I have global invariants
| text | source |
| Log all changes | system |
When I merge the invariants
Then the merged set should have 1 invariants
And the merged invariant at index 0 should have scope "project"
Scenario: Merge precedence is case-insensitive for de-duplication
Given I have plan invariants
| text | source |
| LOG ALL CHANGES | plan1 |
And I have global invariants
| text | source |
| log all changes | system |
And I have project invariants
| text | source |
When I merge the invariants
Then the merged set should have 1 invariants
And the merged invariant at index 0 should have text "LOG ALL CHANGES"
Scenario: Inactive invariants are excluded from merge
Given I have plan invariants with an inactive entry
And I have project invariants
| text | source |
And I have global invariants
| text | source |
When I merge the invariants
Then the merged set should have 0 invariants
# === InvariantSet merge class method ===
Scenario: InvariantSet.merge produces correct result
Given I have plan invariants
| text | source |
| Plan rule | plan1 |
And I have project invariants
| text | source |
| Project rule | proj1 |
And I have global invariants
| text | source |
| Global rule | system |
When I merge using InvariantSet
Then the invariant set should have 3 invariants
# === Service: Add/List/Remove ===
Scenario: Service add invariant
Given an invariant service
When I add an invariant via service with text "No secrets in logs" scope "global" source "system"
Then the service should return the created invariant
And the service invariant text should be "No secrets in logs"
Scenario: Service add invariant rejects empty text
Given an invariant service
When I try to add an invariant via service with empty text
Then a service validation error should be raised
Scenario: Service add invariant rejects empty source
Given an invariant service
When I try to add an invariant via service with empty source
Then a service validation error should be raised
Scenario: Service list all invariants
Given an invariant service with 3 invariants
When I list all invariants via service
Then the service should return 3 invariants
Scenario: Service list invariants filtered by scope
Given an invariant service with mixed scopes
When I list invariants via service with scope "global"
Then all returned invariants should have scope "global"
Scenario: Service list invariants filtered by source
Given an invariant service with mixed sources
When I list invariants via service with source "myapp"
Then all returned invariants should have source "myapp"
Scenario: Service remove invariant soft-deletes
Given an invariant service with 1 invariant
When I remove the invariant via service
Then the invariant should be inactive
And listing active invariants should return 0
Scenario: Service remove non-existent invariant raises not found
Given an invariant service
When I try to remove a non-existent invariant
Then a not found error should be raised
Scenario: Service remove with empty ID raises validation error
Given an invariant service
When I try to remove an invariant with empty ID
Then a service validation error should be raised
# === Effective Invariant Computation ===
Scenario: Effective invariants for a plan with project context
Given an invariant service with invariants at all scopes
When I get effective invariants for plan "plan1" and project "proj1"
Then the effective set should contain plan invariants first
And the effective set should contain project invariants second
And the effective set should contain global invariants last
Scenario: Effective invariants de-duplicate across scopes
Given an invariant service with duplicate text across scopes
When I get effective invariants for plan "plan1" and project "proj1"
Then the effective set should have no duplicates
# === Enforcement Record Creation ===
Scenario: Enforce invariants creates records
Given an invariant service with 2 invariants
When I enforce invariants for plan "PLAN_XYZ"
Then 2 enforcement records should be created
And each enforcement record should be marked as enforced
And each enforcement record should have a decision ID
Scenario: Enforce invariants with actor response
Given an invariant service with 1 invariant
When I enforce invariants with actor response "All constraints satisfied"
Then the enforcement record actor response should be "All constraints satisfied"
Scenario: Enforce invariants with empty plan ID raises error
Given an invariant service
When I try to enforce invariants with empty plan ID
Then a service validation error should be raised
# === InvariantViolation Model ===
Scenario: Create an invariant violation
When I create an invariant violation with severity "error"
Then the violation should be created
And the invariant violation severity should be "error"
Scenario: Violation severity is case-insensitive
When I create an invariant violation with severity "WARNING"
Then the invariant violation severity should be "warning"
Scenario: Invalid violation severity is rejected
When I try to create a violation with invalid severity "critical"
Then a validation error should be raised
# === InvariantEnforcementRecord Model ===
Scenario: Create an enforcement record
When I create an enforcement record with enforced true
Then the record should be created
And the record enforced flag should be true
Scenario: Create an enforcement record with enforced false
When I create an enforcement record with enforced false
Then the record enforced flag should be false
+563
View File
@@ -0,0 +1,563 @@
"""Step definitions for invariant_models.feature.
Tests the Invariant domain models, InvariantService, merge precedence,
enforcement records, and violation models.
"""
from __future__ import annotations
import re
from behave import given, then, when # type: ignore[import-untyped]
from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core.invariant import (
Invariant,
InvariantEnforcementRecord,
InvariantScope,
InvariantSet,
InvariantViolation,
merge_invariants,
)
# -- ULID pattern for validation --
ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
# ================================================================
# Invariant Creation
# ================================================================
@when('I create an invariant with text "{text}" scope "{scope}" and source "{source}"')
def step_create_invariant(context, text, scope, source):
context.invariant = Invariant(
text=text,
scope=InvariantScope(scope),
source_name=source,
)
@then("the invariant should be created")
def step_invariant_created(context):
assert context.invariant is not None
@then('the invariant scope should be "{scope}"')
def step_invariant_scope(context, scope):
assert context.invariant.scope.value == scope
@then('the invariant source name should be "{source}"')
def step_invariant_source(context, source):
assert context.invariant.source_name == source
@then("the invariant should be active")
def step_invariant_active(context):
assert context.invariant.active is True
@then("the invariant id should be a valid ULID")
def step_invariant_ulid(context):
assert ULID_RE.match(context.invariant.id), (
f"Expected ULID, got: {context.invariant.id}"
)
@then('the invariant text should be "{text}"')
def step_invariant_text(context, text):
assert context.invariant.text == text
@when("I try to create an invariant with empty text")
def step_create_empty_text(context):
try:
Invariant(text="", scope=InvariantScope.GLOBAL, source_name="system")
context.error = None
except Exception as e:
context.error = e
@when("I try to create an invariant with whitespace-only text")
def step_create_whitespace_text(context):
try:
Invariant(text=" ", scope=InvariantScope.GLOBAL, source_name="system")
context.error = None
except Exception as e:
context.error = e
# NOTE: "a validation error should be raised" and "the error should mention"
# are already defined in features/steps/domain_models_steps.py and shared
# across all features via Behave's global step registry.
# ================================================================
# InvariantScope Enum
# ================================================================
@then('InvariantScope should have values "{values}"')
def step_scope_values(context, values):
expected = {v.strip() for v in values.split(",")}
actual = {s.value for s in InvariantScope}
assert expected == actual, f"Expected {expected}, got {actual}"
# ================================================================
# Merge Precedence
# ================================================================
def _parse_invariant_table(context, scope: InvariantScope):
"""Parse a Behave table into a list of Invariant objects."""
invariants = []
if context.table:
for row in context.table:
text = row["text"].strip()
source = row["source"].strip()
if text:
invariants.append(
Invariant(
text=text,
scope=scope,
source_name=source,
)
)
return invariants
@given("I have plan invariants")
def step_plan_invariants(context):
context.plan_invariants = _parse_invariant_table(context, InvariantScope.PLAN)
@given("I have project invariants")
def step_project_invariants(context):
context.project_invariants = _parse_invariant_table(context, InvariantScope.PROJECT)
@given("I have global invariants")
def step_global_invariants(context):
context.global_invariants = _parse_invariant_table(context, InvariantScope.GLOBAL)
@given("I have plan invariants with an inactive entry")
def step_plan_invariants_inactive(context):
inv = Invariant(
text="Inactive constraint",
scope=InvariantScope.PLAN,
source_name="plan1",
active=False,
)
context.plan_invariants = [inv]
@when("I merge the invariants")
def step_merge(context):
context.merged = merge_invariants(
getattr(context, "plan_invariants", []),
getattr(context, "project_invariants", []),
getattr(context, "global_invariants", []),
)
@then("the merged set should have {count:d} invariants")
def step_merged_count(context, count):
assert len(context.merged) == count, f"Expected {count}, got {len(context.merged)}"
@then('the merged invariant at index {idx:d} should have text "{text}"')
def step_merged_text(context, idx, text):
assert context.merged[idx].text == text
@then('the merged invariant at index {idx:d} should have scope "{scope}"')
def step_merged_scope(context, idx, scope):
assert context.merged[idx].scope.value == scope
# ================================================================
# InvariantSet
# ================================================================
@when("I merge using InvariantSet")
def step_merge_invariant_set(context):
inv_set = InvariantSet.merge(
getattr(context, "plan_invariants", []),
getattr(context, "project_invariants", []),
getattr(context, "global_invariants", []),
)
context.invariant_set = inv_set
@then("the invariant set should have {count:d} invariants")
def step_set_count(context, count):
assert len(context.invariant_set.invariants) == count
# ================================================================
# Service: Add / List / Remove
# ================================================================
@given("an invariant service")
def step_service(context):
context.service = InvariantService()
@when(
'I add an invariant via service with text "{text}" scope "{scope}" source "{source}"'
)
def step_service_add(context, text, scope, source):
context.service_invariant = context.service.add_invariant(
text=text,
scope=InvariantScope(scope),
source_name=source,
)
@then("the service should return the created invariant")
def step_service_created(context):
assert context.service_invariant is not None
@then('the service invariant text should be "{text}"')
def step_service_text(context, text):
assert context.service_invariant.text == text
@when("I try to add an invariant via service with empty text")
def step_service_add_empty_text(context):
try:
context.service.add_invariant(
text="", scope=InvariantScope.GLOBAL, source_name="system"
)
context.error = None
except ValidationError as e:
context.error = e
@when("I try to add an invariant via service with empty source")
def step_service_add_empty_source(context):
try:
context.service.add_invariant(
text="Some text", scope=InvariantScope.GLOBAL, source_name=""
)
context.error = None
except ValidationError as e:
context.error = e
@then("a service validation error should be raised")
def step_service_validation_error(context):
assert context.error is not None
assert isinstance(context.error, ValidationError)
@given("an invariant service with {count:d} invariants")
def step_service_with_n(context, count):
context.service = InvariantService()
context.service_ids = []
for i in range(count):
inv = context.service.add_invariant(
text=f"Constraint {i + 1}",
scope=InvariantScope.GLOBAL,
source_name="system",
)
context.service_ids.append(inv.id)
@when("I list all invariants via service")
def step_service_list_all(context):
context.service_results = context.service.list_invariants()
@then("the service should return {count:d} invariants")
def step_service_list_count(context, count):
assert len(context.service_results) == count
@given("an invariant service with mixed scopes")
def step_service_mixed_scopes(context):
context.service = InvariantService()
context.service.add_invariant("G1", InvariantScope.GLOBAL, "system")
context.service.add_invariant("G2", InvariantScope.GLOBAL, "system")
context.service.add_invariant("P1", InvariantScope.PROJECT, "myapp")
@when('I list invariants via service with scope "{scope}"')
def step_service_list_scope(context, scope):
context.service_results = context.service.list_invariants(
scope=InvariantScope(scope)
)
@then('all returned invariants should have scope "{scope}"')
def step_all_scope(context, scope):
for inv in context.service_results:
assert inv.scope.value == scope
@given("an invariant service with mixed sources")
def step_service_mixed_sources(context):
context.service = InvariantService()
context.service.add_invariant("P1", InvariantScope.PROJECT, "myapp")
context.service.add_invariant("P2", InvariantScope.PROJECT, "myapp")
context.service.add_invariant("P3", InvariantScope.PROJECT, "other")
@when('I list invariants via service with source "{source}"')
def step_service_list_source(context, source):
context.service_results = context.service.list_invariants(source_name=source)
@then('all returned invariants should have source "{source}"')
def step_all_source(context, source):
for inv in context.service_results:
assert inv.source_name == source
@given("an invariant service with {count:d} invariant")
def step_service_with_one(context, count):
context.service = InvariantService()
context.service_ids = []
for i in range(count):
inv = context.service.add_invariant(
text=f"Constraint {i + 1}",
scope=InvariantScope.GLOBAL,
source_name="system",
)
context.service_ids.append(inv.id)
@when("I remove the invariant via service")
def step_service_remove(context):
context.removed_inv = context.service.remove_invariant(context.service_ids[0])
@then("the invariant should be inactive")
def step_invariant_inactive(context):
assert context.removed_inv.active is False
@then("listing active invariants should return {count:d}")
def step_list_active(context, count):
results = context.service.list_invariants()
assert len(results) == count
@when("I try to remove a non-existent invariant")
def step_remove_nonexistent(context):
try:
context.service.remove_invariant("01ZZZZZZZZZZZZZZZZZZZZZZZZ")
context.error = None
except NotFoundError as e:
context.error = e
# NOTE: "a not found error should be raised" is already defined in
# features/steps/plan_lifecycle_service_steps.py and shared globally.
@when("I try to remove an invariant with empty ID")
def step_remove_empty_id(context):
try:
context.service.remove_invariant("")
context.error = None
except ValidationError as e:
context.error = e
# ================================================================
# Effective Invariant Computation
# ================================================================
@given("an invariant service with invariants at all scopes")
def step_service_all_scopes(context):
context.service = InvariantService()
context.service.add_invariant("Global rule", InvariantScope.GLOBAL, "system")
context.service.add_invariant("Project rule", InvariantScope.PROJECT, "proj1")
context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1")
@when('I get effective invariants for plan "{plan_id}" and project "{project}"')
def step_effective(context, plan_id, project):
context.effective = context.service.get_effective_invariants(
plan_id=plan_id, project_name=project
)
@then("the effective set should contain plan invariants first")
def step_effective_plan_first(context):
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
if plan_invs:
first_plan_idx = context.effective.index(plan_invs[0])
assert first_plan_idx == 0
@then("the effective set should contain project invariants second")
def step_effective_project_second(context):
proj_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
if proj_invs:
first_proj_idx = context.effective.index(proj_invs[0])
assert first_proj_idx >= len(plan_invs)
@then("the effective set should contain global invariants last")
def step_effective_global_last(context):
glob_invs = [i for i in context.effective if i.scope == InvariantScope.GLOBAL]
if glob_invs:
last_glob_idx = context.effective.index(glob_invs[-1])
assert last_glob_idx == len(context.effective) - 1
@given("an invariant service with duplicate text across scopes")
def step_service_duplicates(context):
context.service = InvariantService()
context.service.add_invariant("Log all changes", InvariantScope.GLOBAL, "system")
context.service.add_invariant("Log all changes", InvariantScope.PROJECT, "proj1")
context.service.add_invariant("Log all changes", InvariantScope.PLAN, "plan1")
@then("the effective set should have no duplicates")
def step_no_duplicates(context):
texts = [inv.text.lower() for inv in context.effective]
assert len(texts) == len(set(texts)), f"Duplicates found: {texts}"
# ================================================================
# Enforcement
# ================================================================
@when('I enforce invariants for plan "{plan_id}"')
def step_enforce(context, plan_id):
invs = context.service.list_invariants()
context.enforcement_records = context.service.enforce_invariants(
plan_id=plan_id, invariants=invs
)
@then("{count:d} enforcement records should be created")
def step_enforcement_count(context, count):
assert len(context.enforcement_records) == count
@then("each enforcement record should be marked as enforced")
def step_enforcement_enforced(context):
for rec in context.enforcement_records:
assert rec.enforced is True
@then("each enforcement record should have a decision ID")
def step_enforcement_decision_id(context):
for rec in context.enforcement_records:
assert rec.decision_id != ""
@when('I enforce invariants with actor response "{response}"')
def step_enforce_with_response(context, response):
invs = context.service.list_invariants()
context.enforcement_records = context.service.enforce_invariants(
plan_id="PLAN_XYZ", invariants=invs, actor_response=response
)
@then('the enforcement record actor response should be "{response}"')
def step_enforcement_response(context, response):
assert context.enforcement_records[0].actor_response == response
@when("I try to enforce invariants with empty plan ID")
def step_enforce_empty_plan(context):
try:
context.service.enforce_invariants(plan_id="", invariants=[])
context.error = None
except ValidationError as e:
context.error = e
# ================================================================
# InvariantViolation
# ================================================================
@when('I create an invariant violation with severity "{severity}"')
def step_create_violation(context, severity):
try:
context.violation = InvariantViolation(
invariant_id="01TESTINVARIANTID000000000",
violated_text="Some constraint was violated",
severity=severity,
)
context.error = None
except Exception as e:
context.error = e
context.violation = None
@then("the violation should be created")
def step_violation_created(context):
assert context.violation is not None
@then('the invariant violation severity should be "{severity}"')
def step_violation_severity(context, severity):
assert context.violation.severity == severity
@when('I try to create a violation with invalid severity "{severity}"')
def step_create_invalid_violation(context, severity):
try:
InvariantViolation(
invariant_id="01TESTINVARIANTID000000000",
violated_text="Some constraint",
severity=severity,
)
context.error = None
except Exception as e:
context.error = e
# ================================================================
# InvariantEnforcementRecord
# ================================================================
@when("I create an enforcement record with enforced true")
def step_create_record_true(context):
context.record = InvariantEnforcementRecord(
invariant_id="01TESTINVARIANTID000000000",
enforced=True,
actor_response="OK",
decision_id="01TESTDECISIONID0000000000",
)
@when("I create an enforcement record with enforced false")
def step_create_record_false(context):
context.record = InvariantEnforcementRecord(
invariant_id="01TESTINVARIANTID000000000",
enforced=False,
)
@then("the record should be created")
def step_record_created(context):
assert context.record is not None
@then("the record enforced flag should be true")
def step_record_enforced_true(context):
assert context.record.enforced is True
@then("the record enforced flag should be false")
def step_record_enforced_false(context):
assert context.record.enforced is False
+18 -18
View File
@@ -2527,24 +2527,24 @@ This section replaces all unfinished work items with the Day 14 plan. Work is or
- [ ] Git [Luis]: `git push -u origin feature/m3-validation-runner`
- [ ] Forgejo PR [Luis]: Open PR from `feature/m3-validation-runner` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Jeff | Group: M3.5.invariants | Branch: feature/m3-invariants | Planned: Day 21 | Expected: Day 22) - Commit message: "feat(invariant): add invariant models and enforcement"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m3-invariants`
- [ ] Code [Jeff]: Add invariant models, merge order (plan > project > action > global), and enforcement before strategize.
- [ ] Code [Jeff]: Add Invariant Reconciliation Actor role and record `invariant_enforced` decisions.
- [ ] Code [Jeff]: Add `agents invariant add/list/remove` CLI with scope flags and validation errors.
- [ ] Docs [Jeff]: Add `docs/reference/invariants.md` with precedence and reconciliation examples.
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Tests (Behave) [Jeff]: Add invariant merge + violation scenarios.
- [ ] Tests (Robot) [Jeff]: Add invariant CLI integration tests.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/invariant_merge_bench.py` for merge overhead.
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Jeff]: `git commit -m "feat(invariant): add invariant models and enforcement"`
- [ ] Git [Jeff]: `git push -u origin feature/m3-invariants`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-invariants` to `master` with a suitable and thorough description
- [X] **COMMIT (Owner: Jeff | Group: M3.5.invariants | Branch: feature/m3-invariants | Planned: Day 21 | Expected: Day 22) - Commit message: "feat(invariant): add invariant models and enforcement"** Done: 2026-02-22
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m3-invariants`
- [X] Code [Jeff]: Add invariant models, merge order (plan > project > action > global), and enforcement before strategize.
- [X] Code [Jeff]: Add Invariant Reconciliation Actor role and record `invariant_enforced` decisions.
- [X] Code [Jeff]: Add `agents invariant add/list/remove` CLI with scope flags and validation errors.
- [X] Docs [Jeff]: Add `docs/reference/invariants.md` with precedence and reconciliation examples.
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [X] Tests (Behave) [Jeff]: Add invariant merge + violation scenarios.
- [X] Tests (Robot) [Jeff]: Add invariant CLI integration tests.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/invariant_merge_bench.py` for merge overhead.
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Jeff]: `git commit -m "feat(invariant): add invariant models and enforcement"`
- [X] Git [Jeff]: `git push -u origin feature/m3-invariants`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m3-invariants` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Brent | Group: M3.tests | Branch: feature/m3-decision-validation-smoke | Planned: Day 21 | Expected: Day 22) - Commit message: "test(e2e): add M3 decision + validation suites"**
- [ ] Git [Brent]: `git checkout master`
+186
View File
@@ -0,0 +1,186 @@
"""Helper script for invariant_cli.robot smoke tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import patch
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from typer.testing import CliRunner # noqa: E402
from cleveragents.application.services.invariant_service import ( # noqa: E402
InvariantService,
)
from cleveragents.cli.commands.invariant import app as invariant_app # noqa: E402
runner = CliRunner()
def _fresh_service() -> InvariantService:
"""Create a fresh in-memory service for each test."""
return InvariantService()
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def add_global() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(
invariant_app, ["add", "--global", "Never delete production data"]
)
if result.exit_code == 0 and "Invariant added" in result.stdout:
print("invariant-add-global-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def add_project() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(
invariant_app, ["add", "--project", "myapp", "All API changes need tests"]
)
if result.exit_code == 0 and "Invariant added" in result.stdout:
print("invariant-add-project-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def add_plan() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(
invariant_app,
["add", "--plan", "01TESTPLANID00000000000000", "Use Python 3.13"],
)
if result.exit_code == 0 and "Invariant added" in result.stdout:
print("invariant-add-plan-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def add_action() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(
invariant_app,
["add", "--action", "local/code-coverage", "Min 80% coverage"],
)
if result.exit_code == 0 and "Invariant added" in result.stdout:
print("invariant-add-action-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def list_empty() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, ["list"])
if result.exit_code == 0 and "No invariants found" in result.stdout:
print("invariant-list-empty-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def list_filter() -> None:
svc = _fresh_service()
svc.add_invariant("Global rule", scope=_scope("global"), source_name="system")
svc.add_invariant("Project rule", scope=_scope("project"), source_name="myapp")
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, ["list", "--global"])
if result.exit_code == 0 and "Global rule" in result.stdout:
print("invariant-list-filter-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def remove() -> None:
svc = _fresh_service()
inv = svc.add_invariant(
"To be removed", scope=_scope("global"), source_name="system"
)
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, ["remove", "--yes", inv.id])
if result.exit_code == 0 and "Invariant removed" in result.stdout:
print("invariant-remove-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def scope_conflict() -> None:
svc = _fresh_service()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(
invariant_app,
["add", "--global", "--project", "myapp", "Conflicting scopes"],
)
# Should fail with a bad parameter error
if result.exit_code != 0:
print("invariant-scope-conflict-ok")
else:
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def list_json() -> None:
svc = _fresh_service()
svc.add_invariant("JSON test rule", scope=_scope("global"), source_name="system")
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
result = runner.invoke(invariant_app, ["list", "--format", "json"])
if result.exit_code == 0:
# Verify output is valid JSON-like
output = result.stdout.strip()
if "JSON test rule" in output:
print("invariant-list-json-ok")
return
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
sys.exit(1)
def _scope(name: str):
from cleveragents.domain.models.core.invariant import InvariantScope
return InvariantScope(name)
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
COMMANDS = {
"add-global": add_global,
"add-project": add_project,
"add-plan": add_plan,
"add-action": add_action,
"list-empty": list_empty,
"list-filter": list_filter,
"remove": remove,
"scope-conflict": scope_conflict,
"list-json": list_json,
}
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else None
if cmd not in COMMANDS:
print(f"Unknown command: {cmd}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
sys.exit(2)
COMMANDS[cmd]()
+65
View File
@@ -0,0 +1,65 @@
*** Settings ***
Documentation Smoke tests for Invariant CLI commands
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_invariant_cli.py
*** Test Cases ***
Invariant Add Global
[Documentation] Verify that ``invariant add --global`` creates a global invariant
${result}= Run Process ${PYTHON} ${HELPER} add-global cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-add-global-ok
Invariant Add Project
[Documentation] Verify that ``invariant add --project`` creates a project invariant
${result}= Run Process ${PYTHON} ${HELPER} add-project cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-add-project-ok
Invariant Add Plan
[Documentation] Verify that ``invariant add --plan`` creates a plan invariant
${result}= Run Process ${PYTHON} ${HELPER} add-plan cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-add-plan-ok
Invariant Add Action
[Documentation] Verify that ``invariant add --action`` creates an action invariant
${result}= Run Process ${PYTHON} ${HELPER} add-action cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-add-action-ok
Invariant List Empty
[Documentation] Verify that ``invariant list`` works when no invariants exist
${result}= Run Process ${PYTHON} ${HELPER} list-empty cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-list-empty-ok
Invariant List With Filter
[Documentation] Verify that ``invariant list --global`` filters by scope
${result}= Run Process ${PYTHON} ${HELPER} list-filter cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-list-filter-ok
Invariant Remove
[Documentation] Verify that ``invariant remove --yes`` soft-deletes
${result}= Run Process ${PYTHON} ${HELPER} remove cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-remove-ok
Invariant Scope Conflict Rejected
[Documentation] Verify that conflicting scope flags are rejected
${result}= Run Process ${PYTHON} ${HELPER} scope-conflict cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-scope-conflict-ok
Invariant List JSON Format
[Documentation] Verify that ``invariant list --format json`` outputs JSON
${result}= Run Process ${PYTHON} ${HELPER} list-json cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} invariant-list-json-ok
@@ -3,6 +3,9 @@
Contains service classes that orchestrate business operations.
"""
from cleveragents.application.services.invariant_service import (
InvariantService,
)
from cleveragents.application.services.session_service import (
PersistentSessionService,
)
@@ -14,6 +17,7 @@ from cleveragents.application.services.tool_registry_service import (
)
__all__ = [
"InvariantService",
"PersistentSessionService",
"SkillRegistryService",
"ToolRegistryService",
@@ -0,0 +1,228 @@
"""Invariant Service for CleverAgents v3.
The ``InvariantService`` manages invariant constraints across scopes
(global, project, action, plan) and provides merge, enforcement, and
lifecycle operations.
## Storage
Uses in-memory storage (same pattern as ``PlanLifecycleService``) with
a dict keyed by invariant ID.
## Merge Precedence
Effective invariants are computed using plan > project > global order.
See ``merge_invariants`` for de-duplication semantics.
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
"""
from __future__ import annotations
import structlog
from ulid import ULID
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core.invariant import (
Invariant,
InvariantEnforcementRecord,
InvariantScope,
merge_invariants,
)
logger = structlog.get_logger(__name__)
class InvariantService:
"""Service for managing invariant constraints.
Provides add, list, remove (soft-delete), effective-set computation,
and enforcement record creation. All storage is in-memory.
"""
def __init__(self) -> None:
"""Initialise the invariant service with empty in-memory storage."""
self._invariants: dict[str, Invariant] = {}
self._enforcement_records: list[InvariantEnforcementRecord] = []
self._logger = logger.bind(service="invariant")
def add_invariant(
self,
text: str,
scope: InvariantScope,
source_name: str,
) -> Invariant:
"""Add a new invariant with validation.
Args:
text: The natural-language constraint text.
scope: The scope at which this invariant applies.
source_name: Name of the owning project/action/plan.
Returns:
The created ``Invariant``.
Raises:
ValidationError: If text is empty/blank or source_name is blank.
"""
if not text or not text.strip():
raise ValidationError("Invariant text must not be empty")
if not source_name or not source_name.strip():
raise ValidationError("Source name must not be empty")
invariant = Invariant(
id=str(ULID()),
text=text.strip(),
scope=scope,
source_name=source_name.strip(),
)
self._invariants[invariant.id] = invariant
self._logger.info(
"Invariant added",
invariant_id=invariant.id,
scope=scope.value,
source_name=source_name,
)
return invariant
def list_invariants(
self,
scope: InvariantScope | None = None,
source_name: str | None = None,
effective: bool = False,
) -> list[Invariant]:
"""Filter and list invariants.
Args:
scope: Filter by scope (None = all scopes).
source_name: Filter by source name (None = all sources).
effective: When True, returns merged set for the given
scope chain (requires ``scope`` and ``source_name``).
Returns:
List of matching ``Invariant`` objects.
"""
if effective:
return self.get_effective_invariants(
plan_id=source_name if scope == InvariantScope.PLAN else None,
project_name=source_name if scope == InvariantScope.PROJECT else None,
)
result = [inv for inv in self._invariants.values() if inv.active]
if scope is not None:
result = [inv for inv in result if inv.scope == scope]
if source_name is not None:
result = [inv for inv in result if inv.source_name == source_name]
return result
def remove_invariant(self, invariant_id: str) -> Invariant:
"""Soft-delete an invariant by setting active=False.
Args:
invariant_id: The ULID of the invariant to remove.
Returns:
The updated ``Invariant``.
Raises:
NotFoundError: If the invariant does not exist.
"""
if not invariant_id or not invariant_id.strip():
raise ValidationError("Invariant ID must not be empty")
inv = self._invariants.get(invariant_id)
if inv is None:
raise NotFoundError(
resource_type="invariant",
resource_id=invariant_id,
)
inv.active = False
self._logger.info("Invariant removed (soft-delete)", invariant_id=invariant_id)
return inv
def get_effective_invariants(
self,
plan_id: str | None = None,
project_name: str | None = None,
) -> list[Invariant]:
"""Return the merged precedence chain for a plan/project context.
Collects active invariants from each scope tier and merges them
using plan > project > global precedence.
Args:
plan_id: Optional plan identifier to collect plan-scoped
invariants.
project_name: Optional project name to collect project-scoped
invariants.
Returns:
Merged, de-duplicated list of effective invariants.
"""
active = [inv for inv in self._invariants.values() if inv.active]
plan_invs = [
inv
for inv in active
if inv.scope == InvariantScope.PLAN
and (plan_id is None or inv.source_name == plan_id)
]
project_invs = [
inv
for inv in active
if inv.scope == InvariantScope.PROJECT
and (project_name is None or inv.source_name == project_name)
]
global_invs = [
inv for inv in active if inv.scope == InvariantScope.GLOBAL
]
return merge_invariants(plan_invs, project_invs, global_invs)
def enforce_invariants(
self,
plan_id: str,
invariants: list[Invariant],
actor_response: str | None = None,
) -> list[InvariantEnforcementRecord]:
"""Create enforcement records for a set of invariants.
Called by the Invariant Reconciliation Actor at the start of
Strategize to record ``invariant_enforced`` decisions.
Args:
plan_id: The plan being checked.
invariants: The invariants to enforce.
actor_response: Optional response from the reconciliation actor.
Returns:
List of ``InvariantEnforcementRecord`` objects.
Raises:
ValidationError: If plan_id is empty.
"""
if not plan_id or not plan_id.strip():
raise ValidationError("Plan ID must not be empty")
records: list[InvariantEnforcementRecord] = []
response = actor_response or ""
for inv in invariants:
record = InvariantEnforcementRecord(
invariant_id=inv.id,
enforced=True,
actor_response=response,
decision_id=str(ULID()),
)
records.append(record)
self._enforcement_records.extend(records)
self._logger.info(
"Invariants enforced",
plan_id=plan_id,
count=len(records),
)
return records
+282
View File
@@ -0,0 +1,282 @@
"""Invariant management commands for CleverAgents CLI.
The ``agents invariant`` command group manages natural-language constraints
that flow into plan execution decisions.
## Commands
| Command | Description |
|----------------------------|------------------------------------------|
| ``agents invariant add`` | Add a new invariant constraint |
| ``agents invariant list`` | List invariants with optional filters |
| ``agents invariant remove``| Soft-delete an invariant by ID |
## Scope Flags
Each invariant belongs to exactly one scope. Pass the matching flag:
- ``--global``: System-wide invariant
- ``--project PROJECT``: Project-scoped invariant
- ``--action ACTION``: Action-template invariant
- ``--plan PLAN_ID``: Plan-specific invariant
If no scope flag is given, ``--global`` is assumed.
## Examples
```bash
agents invariant add --global "Never delete production data"
agents invariant add --project myapp "All API changes need tests"
agents invariant list --effective --project myapp
agents invariant remove INV_ULID
```
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
"""
from __future__ import annotations
import re
from typing import Annotated
import typer
from rich.console import Console
from rich.table import Table
from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.core.exceptions import CleverAgentsError, NotFoundError
from cleveragents.domain.models.core.invariant import Invariant, InvariantScope
app = typer.Typer(
help="Manage invariant constraints for plan execution.",
)
console = Console()
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
# Module-level service instance (in-memory, same lifetime as CLI process)
_service: InvariantService | None = None
def _get_service() -> InvariantService:
"""Return (or lazily create) the module-level InvariantService."""
global _service
if _service is None:
_service = InvariantService()
return _service
def _resolve_scope(
is_global: bool,
project: str | None,
plan: str | None,
action: str | None,
) -> tuple[InvariantScope, str]:
"""Resolve scope flag combination to (scope, source_name).
Raises:
typer.BadParameter: On conflicting or missing flags.
"""
flags_set = sum(
[is_global, project is not None, plan is not None, action is not None]
)
if flags_set > 1:
raise typer.BadParameter(
"Specify at most one scope flag: --global, --project, --plan, or --action"
)
if project is not None:
return InvariantScope.PROJECT, project
if plan is not None:
return InvariantScope.PLAN, plan
if action is not None:
return InvariantScope.ACTION, action
# Default to global
return InvariantScope.GLOBAL, "system"
def _invariant_dict(inv: Invariant) -> dict[str, object]:
"""Serialise an Invariant for non-rich output formats."""
return {
"id": inv.id,
"text": inv.text,
"scope": inv.scope.value,
"source_name": inv.source_name,
"active": inv.active,
"created_at": inv.created_at.isoformat(),
}
@app.command()
def add(
text: Annotated[str, typer.Argument(help="The invariant constraint text")],
is_global: Annotated[
bool, typer.Option("--global", help="System-wide invariant")
] = False,
project: Annotated[
str | None, typer.Option("--project", help="Project name")
] = None,
plan: Annotated[str | None, typer.Option("--plan", help="Plan ID (ULID)")] = None,
action: Annotated[str | None, typer.Option("--action", help="Action name")] = None,
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
) -> None:
"""Add a new invariant constraint.
Examples:
agents invariant add --global "Never delete production data"
agents invariant add --project myapp "All API changes need tests"
"""
try:
scope, source_name = _resolve_scope(is_global, project, plan, action)
service = _get_service()
inv = service.add_invariant(text=text, scope=scope, source_name=source_name)
if fmt != OutputFormat.RICH.value:
console.print(format_output(_invariant_dict(inv), fmt))
return
console.print(f"[green]Invariant added:[/green] {inv.id}")
console.print(f" Text: {inv.text}")
console.print(f" Scope: {inv.scope.value}")
console.print(f" Source: {inv.source_name}")
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
@app.command("list")
def list_invariants(
is_global: Annotated[
bool, typer.Option("--global", help="Filter global invariants")
] = False,
project: Annotated[
str | None, typer.Option("--project", help="Filter by project")
] = None,
plan: Annotated[
str | None, typer.Option("--plan", help="Filter by plan ID")
] = None,
action: Annotated[
str | None, typer.Option("--action", help="Filter by action")
] = None,
effective: Annotated[
bool, typer.Option("--effective", help="Show merged effective set")
] = False,
regex: Annotated[
str | None,
typer.Argument(help="Optional regex to filter invariant text"),
] = None,
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
) -> None:
"""List invariants with optional filters.
Examples:
agents invariant list
agents invariant list --global
agents invariant list --effective --project myapp
agents invariant list "data.*safe"
"""
try:
service = _get_service()
scope: InvariantScope | None = None
source_name: str | None = None
if is_global:
scope = InvariantScope.GLOBAL
elif project is not None:
scope = InvariantScope.PROJECT
source_name = project
elif plan is not None:
scope = InvariantScope.PLAN
source_name = plan
elif action is not None:
scope = InvariantScope.ACTION
source_name = action
invariants = service.list_invariants(
scope=scope,
source_name=source_name,
effective=effective,
)
# Apply regex filter
if regex:
try:
pattern = re.compile(regex)
except re.error as exc:
console.print(f"[red]Invalid regex:[/red] {regex}")
raise typer.Abort() from exc
invariants = [inv for inv in invariants if pattern.search(inv.text)]
if not invariants:
console.print("[yellow]No invariants found.[/yellow]")
return
if fmt != OutputFormat.RICH.value:
data = [_invariant_dict(inv) for inv in invariants]
console.print(format_output(data, fmt))
return
table = Table(title=f"Invariants ({len(invariants)} total)")
table.add_column("ID", style="cyan", max_width=26)
table.add_column("Scope", style="yellow")
table.add_column("Source", style="magenta")
table.add_column("Text", style="white")
table.add_column("Active", justify="center")
for inv in invariants:
table.add_row(
inv.id,
inv.scope.value,
inv.source_name,
inv.text,
"yes" if inv.active else "no",
)
console.print(table)
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
@app.command()
def remove(
invariant_id: Annotated[str, typer.Argument(help="Invariant ULID to remove")],
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
) -> None:
"""Remove (soft-delete) an invariant by ID.
Examples:
agents invariant remove 01HXYZ...
agents invariant remove --yes 01HXYZ...
"""
try:
if not yes:
confirmed = typer.confirm(
f"Remove invariant {invariant_id}?", default=False
)
if not confirmed:
console.print("[yellow]Cancelled.[/yellow]")
raise typer.Abort()
service = _get_service()
inv = service.remove_invariant(invariant_id)
if fmt != OutputFormat.RICH.value:
data = _invariant_dict(inv)
console.print(format_output(data, fmt))
return
console.print(f"[green]Invariant removed:[/green] {inv.id}")
except NotFoundError as e:
console.print(f"[red]Invariant not found:[/red] {invariant_id}")
raise typer.Abort() from e
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
+7
View File
@@ -84,6 +84,7 @@ def _register_subcommands() -> None:
cleanup,
config,
context,
invariant,
plan,
project,
resource,
@@ -170,6 +171,11 @@ def _register_subcommands() -> None:
name="automation-profile",
help="Manage automation profiles that control plan execution autonomy",
)
app.add_typer(
invariant.app,
name="invariant",
help="Manage invariant constraints for plan execution",
)
_subcommands_registered = True
@@ -562,6 +568,7 @@ def main(args: list[str] | None = None) -> int:
"validation", # Validation management
"auto-debug", # Auto-debug commands
"automation-profile", # Automation profile management
"invariant", # Invariant constraint management
"tell", # Shortcut for plan tell
"build", # Shortcut for plan build
"apply", # Shortcut for plan apply
@@ -38,6 +38,16 @@ from cleveragents.domain.models.core.context_policy import (
ProjectContextPolicy,
)
from cleveragents.domain.models.core.debug_attempt import DebugAttempt
# Invariant domain models
from cleveragents.domain.models.core.invariant import (
Invariant,
InvariantEnforcementRecord,
InvariantScope,
InvariantSet,
InvariantViolation,
merge_invariants,
)
from cleveragents.domain.models.core.org import (
CloudBillingFields,
CreditsTransaction,
@@ -172,7 +182,12 @@ __all__ = [
"DebugAttempt",
"InMemoryChangeSetStore",
"InMemoryInvocationTracker",
"Invariant",
"InvariantEnforcementRecord",
"InvariantScope",
"InvariantSet",
"InvariantSource",
"InvariantViolation",
"Invite",
"InvocationTracker",
"LegacyChangeSet",
@@ -243,6 +258,7 @@ __all__ = [
"ValidationMode",
"can_transition",
"get_builtin_profile",
"merge_invariants",
"normalize_change_path",
"parse_namespaced_name",
]
@@ -0,0 +1,258 @@
"""Invariant domain models for CleverAgents.
Invariants are natural-language constraints on plan execution, scoped at
global, project, action, or plan level. When an action is used, its
invariants are promoted to plan-level. The runtime precedence chain is:
plan > project > global
They are reconciled by the Invariant Reconciliation Actor at the start
of Strategize and recorded as ``invariant_enforced`` decisions.
## Scope Hierarchy
| Scope | Description |
|------------|----------------------------------------------------|
| ``GLOBAL`` | Applies to every plan in the system |
| ``PROJECT``| Applies to plans targeting a specific project |
| ``ACTION`` | Defined in an action template; promoted on ``use`` |
| ``PLAN`` | Attached directly to a specific plan |
## Merge Precedence
When computing the effective set of invariants for a plan, the merge
order is **plan > project > global**. Duplicate texts (case-insensitive)
are de-duplicated, keeping the highest-precedence copy.
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
"""
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ulid import ULID
class InvariantScope(StrEnum):
"""Scope at which an invariant applies.
Precedence (highest to lowest): PLAN > PROJECT > GLOBAL.
ACTION invariants are promoted to PLAN scope at ``plan use`` time.
"""
GLOBAL = "global"
PROJECT = "project"
ACTION = "action"
PLAN = "plan"
class Invariant(BaseModel):
"""A single invariant constraint.
Invariants carry a unique ULID, the constraint text, scope, source
name (identifying the project/action/plan that owns it), creation
timestamp, and an active flag for soft-delete.
"""
id: str = Field(
default_factory=lambda: str(ULID()),
description="Unique ULID identifier for this invariant",
)
text: str = Field(
...,
min_length=1,
description="The natural-language constraint text",
)
scope: InvariantScope = Field(
...,
description="Scope at which this invariant applies",
)
source_name: str = Field(
...,
min_length=1,
description="Name of the project/action/plan that owns this invariant",
)
created_at: datetime = Field(
default_factory=datetime.now,
description="When this invariant was created",
)
active: bool = Field(
default=True,
description="Whether this invariant is active (False = soft-deleted)",
)
@field_validator("text")
@classmethod
def validate_text_not_blank(cls: type[Invariant], v: str) -> str:
"""Reject whitespace-only invariant text."""
stripped = v.strip()
if not stripped:
raise ValueError("Invariant text must not be blank")
return stripped
@field_validator("source_name")
@classmethod
def validate_source_name(cls: type[Invariant], v: str) -> str:
"""Reject whitespace-only source names."""
stripped = v.strip()
if not stripped:
raise ValueError("Source name must not be blank")
return stripped
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
class InvariantSet(BaseModel):
"""An ordered collection of invariants with merge logic.
Provides a ``merge`` class method that combines invariant lists from
different scopes respecting precedence and de-duplication.
"""
invariants: list[Invariant] = Field(
default_factory=list,
description="Ordered list of invariants",
)
@classmethod
def merge(
cls,
plan_invariants: list[Invariant],
project_invariants: list[Invariant],
global_invariants: list[Invariant],
) -> InvariantSet:
"""Merge invariants respecting plan > project > global precedence.
De-duplicates by text (case-insensitive), keeping the copy from
the highest-precedence tier. Within each tier, source ordering
is preserved.
Args:
plan_invariants: Plan-level invariants (highest precedence).
project_invariants: Project-level invariants.
global_invariants: Global-level invariants (lowest precedence).
Returns:
An ``InvariantSet`` with the merged, de-duplicated invariants.
"""
return cls(
invariants=merge_invariants(
plan_invariants, project_invariants, global_invariants
)
)
model_config = ConfigDict(validate_assignment=True)
def merge_invariants(
plan_invariants: list[Invariant],
project_invariants: list[Invariant],
global_invariants: list[Invariant],
) -> list[Invariant]:
"""Merge invariants implementing plan > project > global precedence.
De-duplicates by text (case-insensitive). The first occurrence
(from the highest-precedence tier) wins. Within each tier, the
original ordering is preserved.
Args:
plan_invariants: Plan-level invariants (highest precedence).
project_invariants: Project-level invariants.
global_invariants: Global-level invariants (lowest precedence).
Returns:
A de-duplicated list of invariants in precedence order.
"""
seen: set[str] = set()
result: list[Invariant] = []
for inv_list in (plan_invariants, project_invariants, global_invariants):
for inv in inv_list:
if not inv.active:
continue
key = inv.text.strip().lower()
if key not in seen:
seen.add(key)
result.append(inv)
return result
class InvariantViolation(BaseModel):
"""Record of an invariant being violated during plan execution.
Captures which invariant was violated, the violating text/action,
severity level, and optional details.
"""
invariant_id: str = Field(
...,
description="ULID of the invariant that was violated",
)
violated_text: str = Field(
...,
min_length=1,
description="The invariant text that was violated",
)
severity: str = Field(
default="error",
description="Severity level: error, warning, info",
)
details: str = Field(
default="",
description="Additional details about the violation",
)
@field_validator("severity")
@classmethod
def validate_severity(cls: type[InvariantViolation], v: str) -> str:
"""Validate severity is one of the allowed values."""
allowed = {"error", "warning", "info"}
v_lower = v.lower()
if v_lower not in allowed:
raise ValueError(
f"Severity must be one of {sorted(allowed)}, got '{v}'"
)
return v_lower
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
class InvariantEnforcementRecord(BaseModel):
"""Record of invariant enforcement at Strategize start.
Created by the Invariant Reconciliation Actor to document which
invariants were enforced, whether enforcement succeeded, and any
actor response or decision reference.
"""
invariant_id: str = Field(
...,
description="ULID of the invariant being enforced",
)
enforced: bool = Field(
...,
description="Whether the invariant was successfully enforced",
)
actor_response: str = Field(
default="",
description="Response from the reconciliation actor",
)
decision_id: str = Field(
default="",
description="ULID of the associated decision node",
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)